refactor(director): separate timing clocks and scene pack presentation

This commit is contained in:
Sameh Khamis committed 2026-09-15 15:41:42 -07:00
1 parent 5728dec24d
commit 8a3c4868b0
19 files changed
+1190 -471

No files matched your search

+4
View File
@@ -5,6 +5,8 @@ import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKos
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
import {
anchoredCalloutPresentation,
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
@@ -503,6 +505,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
+301
View File
@@ -0,0 +1,301 @@
const clamp01 = (value) => Math.max(0, Math.min(1, value));
/** Own playback timers and scene-clock subscriptions independently of renderer and editor state. */
export function createPlaybackClock(options) {
return new PlaybackClock(options);
}
class PlaybackClock {
constructor({
isRunning,
timingForShot,
onProgress,
now = () => Date.now(),
schedule = (callback, ms) => setInterval(callback, ms),
cancel = (timer) => clearInterval(timer),
}) {
this.isRunning = isRunning;
this.timingForShot = timingForShot;
this.onProgress = onProgress;
this.now = now;
this.schedule = schedule;
this.cancel = cancel;
this._progressTimer = null;
this._shotProgressTimer = null;
this._sceneClockTimer = null;
this._sceneClockListeners = new Set();
this._sceneClockSnapshot = null;
this._destroyed = false;
this._waitGeneration = 0;
this._shotGeneration = 0;
this._sceneGeneration = 0;
this._waiters = new Set();
}
get snapshot() {
return this._sceneClockSnapshot ? { ...this._sceneClockSnapshot } : null;
}
get activeTimers() {
return (
[
this._progressTimer,
this._shotProgressTimer,
this._sceneClockTimer,
].filter((timer) => timer !== null).length + this._waiters.size
);
}
stopShot() {
this._shotGeneration++;
this.cancel(this._shotProgressTimer);
this._shotProgressTimer = null;
}
stopSceneTimer() {
this._sceneGeneration++;
this.cancel(this._sceneClockTimer);
this._sceneClockTimer = null;
}
finish() {
this.cancel(this._progressTimer);
this._progressTimer = null;
this.stopSceneTimer();
}
stop() {
this.cancelWaits();
this.stopShot();
this.finish();
if (
!this._sceneClockSnapshot ||
this._sceneClockSnapshot.stopped ||
this._destroyed
)
return;
this._sceneClockSnapshot = {
...this._sceneClockSnapshot,
running: false,
stopped: true,
};
for (const listener of this._sceneClockListeners) {
try {
listener(this.snapshot);
} catch {
/* Observers cannot block cancellation. */
}
}
}
destroy() {
this.cancelWaits();
this._destroyed = true;
this.stopShot();
this.finish();
this._sceneClockListeners.clear();
this._sceneClockSnapshot = null;
}
cancelWaits() {
this._waitGeneration++;
for (const finish of [...this._waiters]) finish();
}
/** Own cancellable hold delays; Stop and destroy settle pending waiters immediately. */
async wait(ms, token) {
const generation = this._waitGeneration;
const endAt = this.now() + ms;
const current = () =>
!this._destroyed &&
generation === this._waitGeneration &&
!token.cancelled &&
!token.signal?.aborted;
while (current() && this.now() < endAt) {
await new Promise((resolve) => {
const finish = () => {
clearTimeout(timer);
token.signal?.removeEventListener('abort', finish);
this._waiters.delete(finish);
resolve();
};
const timer = setTimeout(finish, 70);
this._waiters.add(finish);
token.signal?.addEventListener('abort', finish, { once: true });
});
}
}
subscribe(listener) {
if (this._destroyed || typeof listener !== 'function') return () => {};
this._sceneClockListeners.add(listener);
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
return () => this._sceneClockListeners.delete(listener);
}
publish(
scene,
shot,
sceneElapsedSec,
{ running = this.isRunning(), seeking = false } = {},
) {
if (this._destroyed || !scene || !shot) return;
const timing = this.timingForShot(scene, shot);
const elapsedSec = Math.max(
0,
Math.min(timing.totalSec, Number(sceneElapsedSec) || 0),
);
this._sceneClockSnapshot = {
sceneId: scene.id,
shotId: shot.id,
shotIndex: timing.shotIndex,
shotCount: scene.shots.length,
sceneElapsedSec: elapsedSec,
sceneDurationSec: timing.totalSec,
sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0,
running: Boolean(running),
seeking: Boolean(seeking),
};
for (const listener of this._sceneClockListeners) {
try {
listener({ ...this._sceneClockSnapshot });
} catch (error) {
console.warn('[Scenes] Scene clock listener failed:', error);
}
}
}
startRunProgress(totalSec) {
this.cancel(this._progressTimer);
if (this._destroyed) return;
const startMs = this.now();
const totalMs = Math.max(1000, totalSec * 1000);
const timer = this.schedule(() => {
if (this._destroyed || this._progressTimer !== timer || !this.isRunning())
return;
const elapsedMs = this.now() - startMs;
this.onProgress(elapsedMs / totalMs);
}, 100);
this._progressTimer = timer;
}
startShotProgress(token, seconds, from, to, sceneClock = null) {
this.stopShot();
const generation = this._shotGeneration;
if (
generation !== this._shotGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
this.isRunning()
)
return;
this.onProgress(from);
if (generation !== this._shotGeneration || this._destroyed) return;
if (sceneClock) {
this.publish(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedFrom,
{ running: false },
);
}
if (
generation !== this._shotGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
this.isRunning()
)
return;
if (seconds <= 0) {
this.onProgress(to);
if (generation !== this._shotGeneration || this._destroyed) return;
if (sceneClock) {
this.publish(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedTo,
{ running: false },
);
}
return;
}
const startedAt = this.now();
const timer = this.schedule(() => {
if (this._shotProgressTimer !== timer) return;
if (
generation !== this._shotGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
this.isRunning()
) {
this.cancel(timer);
if (this._shotProgressTimer === timer) this._shotProgressTimer = null;
return;
}
const fraction = clamp01((this.now() - startedAt) / (seconds * 1000));
this.onProgress(from + (to - from) * fraction);
if (generation !== this._shotGeneration || this._destroyed) return;
if (sceneClock) {
this.publish(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedFrom +
(sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) *
fraction,
{ running: false },
);
}
if (fraction >= 1) {
this.cancel(timer);
if (this._shotProgressTimer === timer) this._shotProgressTimer = null;
}
}, 100);
timer.unref?.();
this._shotProgressTimer = timer;
}
startScene(scene, shot, token) {
this.stopSceneTimer();
const generation = this._sceneGeneration;
const timing = this.timingForShot(scene, shot);
if (
generation !== this._sceneGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted
)
return timing;
const startedAt = this.now();
this.publish(scene, shot, timing.startElapsedSec, {
running: true,
});
if (
generation !== this._sceneGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted
)
return timing;
const timer = this.schedule(() => {
if (this._sceneClockTimer !== timer) return;
if (
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
!this.isRunning()
) {
this.cancel(timer);
if (this._sceneClockTimer === timer) this._sceneClockTimer = null;
return;
}
const shotElapsedSec = Math.min(
timing.durationSec,
(this.now() - startedAt) / 1000,
);
this.publish(scene, shot, timing.startElapsedSec + shotElapsedSec, {
running: true,
});
}, 50);
timer.unref?.();
this._sceneClockTimer = timer;
return timing;
}
}
+173
View File
@@ -0,0 +1,173 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPlaybackClock } from './clock.js';
const scene = { id: 'scene', shots: [{ id: 'shot' }] };
const shot = scene.shots[0];
const timing = {
shotIndex: 0,
totalSec: 4,
durationSec: 4,
startElapsedSec: 0,
};
function fixture() {
const timers = new Map();
let id = 0,
now = 0,
running = true;
const progress = [];
const clock = createPlaybackClock({
isRunning: () => running,
timingForShot: () => timing,
onProgress: (value) => progress.push(value),
now: () => now,
schedule: (callback) => {
timers.set(++id, callback);
return id;
},
cancel: (handle) => timers.delete(handle),
});
return {
clock,
timers,
progress,
time: (value) => {
now = value;
},
running: (value) => {
running = value;
},
};
}
test('Stop releases every clock timer and queued callbacks cannot publish later', () => {
const f = fixture();
const observed = [];
f.clock.subscribe((snapshot) => observed.push(snapshot));
f.clock.startRunProgress(4);
f.clock.startScene(scene, shot, { cancelled: false });
f.time(2000);
for (const tick of f.timers.values()) tick();
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
const stale = [...f.timers.values()];
f.clock.stop();
assert.equal(f.clock.activeTimers, 0);
assert.equal(f.timers.size, 0);
assert.equal(f.clock.snapshot.stopped, true);
const count = observed.length,
progressCount = f.progress.length;
f.time(3000);
for (const tick of stale) tick();
assert.equal(observed.length, count);
assert.equal(f.progress.length, progressCount);
});
test('replacing a shot clock rejects the old callback without clearing the replacement', () => {
const f = fixture();
f.clock.startScene(scene, shot, {});
const stale = [...f.timers.values()][0];
f.time(1000);
f.clock.startScene(scene, shot, {});
f.time(2000);
stale();
assert.equal(f.clock.snapshot.sceneElapsedSec, 0);
assert.equal(f.clock.activeTimers, 1);
[...f.timers.values()][0]();
assert.equal(f.clock.snapshot.sceneElapsedSec, 1);
f.clock.destroy();
});
test('direct-load progress finishes once and snapshots cannot mutate the clock', () => {
const f = fixture();
f.running(false);
f.clock.startShotProgress({}, 2, 0.25, 0.75, {
scene,
shot,
sceneElapsedFrom: 1,
sceneElapsedTo: 3,
});
const tick = [...f.timers.values()][0];
f.time(1000);
tick();
assert.equal(f.progress.at(-1), 0.5);
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
const copy = f.clock.snapshot;
copy.sceneElapsedSec = 99;
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
f.time(2000);
tick();
assert.equal(f.progress.at(-1), 0.75);
assert.equal(f.clock.activeTimers, 0);
const count = f.progress.length;
tick();
assert.equal(f.progress.length, count);
});
test('aborted starts and destruction during initial notification leave no timers or listeners', () => {
const f = fixture();
const controller = new AbortController();
controller.abort();
f.clock.startScene(scene, shot, { signal: controller.signal });
assert.equal(f.timers.size, 0);
let calls = 0;
f.clock.subscribe(() => {
calls++;
f.clock.destroy();
});
f.clock.startScene(scene, shot, {});
assert.equal(calls, 1);
assert.equal(f.clock.activeTimers, 0);
f.clock.startRunProgress(4);
f.clock.publish(scene, shot, 1);
f.clock.subscribe(() => calls++);
assert.equal(calls, 1);
assert.equal(f.timers.size, 0);
});
test('Stop, abort and destroy settle long holds immediately and release their deadlines', async () => {
for (const action of ['stop', 'abort', 'destroy']) {
const clock = createPlaybackClock({
isRunning: () => true,
timingForShot: () => timing,
onProgress() {},
});
const abort = new AbortController();
const wait = clock.wait(60000, { signal: abort.signal });
assert.equal(clock.activeTimers, 1);
if (action === 'abort') abort.abort();
else clock[action]();
await wait;
assert.equal(clock.activeTimers, 0);
clock.destroy();
}
});
test('a subscriber can Stop or replace the initial clock without the old start acquiring a timer', () => {
for (const action of ['stop', 'replace']) {
const f = fixture();
let first = true;
f.clock.subscribe(() => {
if (!first) return;
first = false;
if (action === 'stop') f.clock.stop();
else f.clock.startScene(scene, { id: 'replacement' }, {});
});
f.clock.startScene(scene, shot, {});
assert.equal(f.timers.size, action === 'stop' ? 0 : 1);
assert.equal(
f.clock.snapshot.shotId,
action === 'stop' ? shot.id : 'replacement',
);
f.clock.destroy();
}
const f = fixture();
f.running(false);
f.clock.subscribe(() => f.clock.stop());
f.clock.startShotProgress({}, 10, 0, 1, {
scene,
shot,
sceneElapsedFrom: 0,
sceneElapsedTo: 4,
});
assert.equal(f.timers.size, 0);
});
+7
View File
@@ -96,3 +96,10 @@ const SHOT_PHASES = [
'hold',
'completeShot',
];
export {
sceneTimingForShot,
sceneSeekState,
cameraAtProgress,
} from './timeline.js';
export { createPlaybackClock } from './clock.js';
+97
View File
@@ -0,0 +1,97 @@
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
/** Calculate one shot's boundaries using the supplied content duration resolver. */
export function sceneTimingForShot(scene, shot, durationForShot) {
const durations =
scene?.shots?.map((item) => durationForShot(scene, item)) || [];
const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const startElapsedSec = durations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const durationSec = shotIndex >= 0 ? durations[shotIndex] : 0;
const endElapsedSec = Math.min(totalSec, startElapsedSec + durationSec);
return {
shotIndex,
totalSec,
durationSec,
startElapsedSec,
endElapsedSec,
startProgress: totalSec > 0 ? startElapsedSec / totalSec : 0,
endProgress: totalSec > 0 ? endElapsedSec / totalSec : 1,
durationProgress: totalSec > 0 ? durationSec / totalSec : 0,
};
}
/** Interpolate the existing cubic camera pose, including shortest-angle orientation. */
export function cameraAtProgress(fromCamera, toCamera, progress) {
const target = toCamera || fromCamera;
const source = fromCamera || target;
if (!source || !target) return target || source || null;
const t = clamp01(Number(progress) || 0);
const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
roll: lerpAngle(source.roll, target.roll),
};
}
/** Resolve a normalized scene time to a shot, flight/hold phase and camera pose. */
export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
if (!scene?.shots?.length) return null;
const normalized = clamp01(Number(progress) || 0);
const durations = scene.shots.map((shot) => durationForShot(scene, shot));
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const targetSec = normalized * Math.max(0, totalSec);
let startElapsedSec = 0;
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
);
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
const holdProgress =
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera;
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
shotElapsedSec,
shotProgress: clamp01(shotElapsedSec / shotDurationSec),
flightDurationSec,
holdDurationSec,
holdElapsedSec,
cameraProgress,
holdProgress,
camera: cameraAtProgress(previousCamera, shot.camera, cameraProgress),
};
}
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
sceneTimingForShot,
sceneSeekState,
cameraAtProgress,
} from './timeline.js';
const camera = (lon, heading) => ({
lat: 10,
lon,
alt: 1000,
heading,
pitch: -40,
roll: 0,
});
const scene = {
shots: [
{ id: 'a', durationSec: 2, holdSec: 1, camera: camera(20, 350) },
{ id: 'b', durationSec: 4, holdSec: 3, camera: camera(24, 10) },
],
};
const duration = (_, shot) => shot.durationSec + shot.holdSec;
const hold = (_, shot) => shot.holdSec;
test('scene time accounts for flight and hold; exact boundaries select the next shot', () => {
const time = sceneTimingForShot(scene, scene.shots[1], duration);
assert.equal(time.startElapsedSec, 3);
assert.equal(time.endElapsedSec, 10);
const seek = (progress) => sceneSeekState(scene, progress, duration, hold);
assert.equal(seek(-1).shot.id, 'a');
assert.equal(seek(0.3).shot.id, 'b');
assert.equal(seek(0.3).cameraProgress, 0);
assert.equal(seek(0.5).cameraProgress, 0.5);
assert.equal(seek(0.5).camera.lon, 22);
assert.equal(seek(0.8).holdElapsedSec, 1);
assert.equal(seek(2).holdProgress, 1);
assert.equal(seek(0).sceneElapsedSec, 0);
assert.equal(sceneSeekState({ shots: [] }, 1, duration, hold), null);
});
test('camera seeking preserves cubic easing and shortest-angle orientation', () => {
assert.equal(
cameraAtProgress(camera(20, 350), camera(24, 10), 0.5).heading,
360,
);
assert.equal(
cameraAtProgress(camera(20, 350), camera(24, 10), 0.25).lon,
20.25,
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
+70 -453
View File
@@ -24,7 +24,15 @@ import {
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { MAP_STACKS } from '../maps/catalog.js';
import { INCIDENT_OVERVIEW_HOLD_SEC } from '../data/bhoteKoshiIncidentPlaces.js';
import { createDefaultScenePacks } from './packs/defaults.js';
import {
layerStatesForShot,
visualStateForShot,
effectiveShotHoldSec,
shotRuntimeDurationSec,
} from './shotPresentation.js';
import { sceneTimingForShot, sceneSeekState } from '../director/timeline.js';
import { createPlaybackClock } from '../director/clock.js';
import {
BLOOM_INTENSITY_DEFAULT,
BLOOM_SCALE_VERSION,
@@ -42,10 +50,6 @@ const DEFAULT_SHOT_DURATION_SEC = 4;
const DEFAULT_HOLD_SEC = 0.9;
const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id));
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
/**
* Generate a short random identifier with the given prefix.
* @param {string} prefix - e.g. 'shot', 'scene'
@@ -422,7 +426,10 @@ export class SceneDirector {
viewer,
styleManager,
dataManager,
{ isMapStackAvailable = () => false } = {},
{
isMapStackAvailable = () => false,
scenePacks = createDefaultScenePacks(),
} = {},
) {
this._destroyed = false;
this._pendingWork = new Set();
@@ -430,6 +437,7 @@ export class SceneDirector {
this.styleManager = styleManager;
this.dataManager = dataManager;
this._isMapStackAvailable = isMapStackAvailable;
this._scenePacks = scenePacks;
/** @type {boolean} True while a scene run is in progress */
this._running = false;
@@ -446,12 +454,11 @@ export class SceneDirector {
this._runAbort = null;
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
this._loadAbort = null;
/** @type {number|null} setInterval ID for the progress bar ticker */
this._progressTimer = null;
this._shotProgressTimer = null;
this._sceneClockTimer = null;
this._sceneClockListeners = new Set();
this._sceneClockSnapshot = null;
this._clock = createPlaybackClock({
isRunning: () => this._running,
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
onProgress: (progress) => this._setProgress(progress),
});
this._runIdleResolvers = new Set();
this._sceneSeekGeneration = 0;
/** @type {Object|null} Telemetry accumulator for the current run */
@@ -533,11 +540,8 @@ export class SceneDirector {
this.viewer.camera.cancelFlight();
clearTimeout(this._storageToastTimer);
await Promise.allSettled(this._pendingWork || []);
clearInterval(this._progressTimer);
clearInterval(this._shotProgressTimer);
clearInterval(this._sceneClockTimer);
this._clock.destroy();
this._cancelActiveSceneTravel();
this._sceneClockListeners.clear();
});
return this._destroyPromise;
}
@@ -1307,8 +1311,7 @@ export class SceneDirector {
const controller = new AbortController();
this._loadAbort = controller;
const token = this._loadToken(++this._loadGeneration, controller.signal);
clearInterval(this._shotProgressTimer);
this._shotProgressTimer = null;
this._clock.stopShot();
this._setProgress(0);
if (previousScene && previousScene.id !== scene.id) {
@@ -1385,8 +1388,7 @@ export class SceneDirector {
} catch (error) {
this._cancelActiveSceneTravel();
if (!token.cancelled) {
clearInterval(this._shotProgressTimer);
this._shotProgressTimer = null;
this._clock.stopShot();
}
throw error;
}
@@ -1417,188 +1419,27 @@ export class SceneDirector {
* This keeps an appended event lens from leaking backward into the scene's
* original authored shots while leaving ordinary sparse layer state intact.
*/
_layerStatesForShot(
scene,
shot,
{ cameraSettled = false, cameraTravel = null, sceneSeek = null } = {},
) {
const states = deepClone(shot?.layers || {});
const shotIndex =
scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const sceneShotDurations =
scene?.shots?.map((sceneShot) =>
this._shotRuntimeDurationSec(scene, sceneShot),
) || [];
const sceneDurationSec = sceneShotDurations.reduce(
(sum, duration) => sum + duration,
0,
);
const authoredSceneElapsedSec = sceneShotDurations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const sceneElapsedSec = sceneSeek
? Math.max(
0,
Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0),
)
: authoredSceneElapsedSec;
const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
state.params.sceneSurface ||= shot.sourcePackId
? 'evidence-beat'
: 'panel-only';
const runtimeControls =
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
...(runtimeControls.deferEvidenceUntilCameraSettled
? { cameraSettled }
: {}),
...(sceneSeek
? {
timelineSeek: {
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
flightDurationSec: Number(sceneSeek.flightDurationSec) || 0,
holdDurationSec: Number(sceneSeek.holdDurationSec) || 0,
holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0,
cameraProgress: clamp01(
Number(sceneSeek.cameraProgress) || 0,
),
holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0),
},
}
: {}),
};
if (runtimeControls.evidencePathDuringCamera && cameraTravel) {
state.params.sceneControls.cameraTravel = { ...cameraTravel };
}
}
state.params.sceneContext = {
sceneId: scene.id,
sceneTitle: scene.title,
shotId: shot.id,
shotTitle: shot.title,
shotIndex,
shotCount: scene.shots.length,
durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
holdSec: this._effectiveShotHoldSec(scene, shot, states),
sceneElapsedSec,
sceneDurationSec,
sceneProgress:
sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0,
...(sceneSeek
? {
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0),
seeking: true,
}
: {}),
};
}
for (const layerId of scene?.releaseLayerIds || []) {
if (!Object.hasOwn(states, layerId)) states[layerId] = { enabled: false };
}
return states;
_layerStatesForShot(scene, shot, options) {
return layerStatesForShot(scene, shot, this._scenePacks, options);
}
/** Keep installed append-pack shots on any surface declared by their source recipe. */
_visualStateForShot(shot) {
const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId);
const sceneBeatState = Object.values(shot?.layers || {}).find(
(state) => state?.params?.presentation === 'scene-beat',
return visualStateForShot(
shot,
this._scenePacks,
this._isMapStackAvailable,
);
const controls = {
...sceneBeatState?.params?.sceneControls,
...sourceRecipe?.runtimeControlsByBeat?.[sceneBeatState?.params?.beatId],
};
if (controls.imageryComparison === true) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
if (
sourceRecipe?.standaloneSurfaceBeatIds?.includes(
sceneBeatState?.params?.beatId,
)
) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes(
sceneBeatState?.params?.beatId,
)
? { ...(shot.visual || {}), mapStack: 'photoreal' }
: shot?.visual || {};
const isNepalShot =
shot?.layers?.['bhote-koshi-2026']?.enabled ||
shot?.layers?.['bhote-koshi-locator']?.enabled;
// Check actual loaded capability, not the presence of a credential. A key
// can exist even when its tileset failed to initialize. Resolve at playback
// time so saved cameras and provider preferences remain portable.
if (
isNepalShot &&
visual.mapStack === 'photoreal' &&
!this._isMapStackAvailable('photoreal')
) {
return { ...visual, mapStack: 'esri-imagery' };
}
return visual;
}
/** Keep a shot on its final camera pose until its authored layer reveal finishes. */
_effectiveShotHoldSec(scene, shot, resolvedStates = null) {
const states = resolvedStates || this._layerStatesForShot(scene, shot);
const recipe = getSceneAppendRecipeById(shot?.sourcePackId);
const overrides = Object.values(states)
.filter(
(state) =>
state?.enabled && state.params?.presentation === 'scene-beat',
)
.map((state) => recipe?.runtimeHoldSecByBeat?.[state.params.beatId])
.filter((value) => Number.isFinite(value) && value >= 0);
const authoredHoldSec = overrides.length
? Math.max(...overrides)
: Number(shot?.holdSec) || 0;
const overview = states['bhote-koshi-locator'];
const overviewHoldSec =
overview?.enabled &&
overview.params?.presentation === 'bhote-koshi-incident-places'
? INCIDENT_OVERVIEW_HOLD_SEC
: 0;
return Object.values(states).reduce(
(holdSec, state) => {
const controls = state?.params?.sceneControls;
return Math.max(
holdSec,
Number(controls?.minimumHoldSec) || 0,
controls?.evidenceSequence === true
? Number(controls.evidenceSequenceDurationSec) || 0
: 0,
);
},
Math.max(overviewHoldSec, authoredHoldSec),
);
return effectiveShotHoldSec(scene, shot, this._scenePacks, resolvedStates);
}
/** Resolve one shot's authored flight plus any runtime-enforced hold. */
_shotRuntimeDurationSec(scene, shot) {
const states = deepClone(shot?.layers || {});
const sourceRecipe = getSceneAppendRecipeById(shot?.sourcePackId);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
const runtimeControls =
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
};
}
}
return (
(shot?.durationSec || DEFAULT_SHOT_DURATION_SEC) +
this._effectiveShotHoldSec(scene, shot, states)
);
return shotRuntimeDurationSec(scene, shot, this._scenePacks);
}
/** Signal camera-dependent layers only after the authored flight completes. */
@@ -1668,9 +1509,9 @@ export class SceneDirector {
// The opening locator owns an additional delayed approach/orbit even after
// the director's authored flight has settled. Revoke it before cancelling
// the camera, whose moveEnd/complete callbacks may already be queued.
this.dataManager?.layers
?.get('bhote-koshi-locator')
?.module.cancelSceneMotion?.();
this._scenePacks.cancelMotion(
(id) => this.dataManager?.layers?.get(id)?.module,
);
const active = this._activeSceneTravel;
if (!active) return false;
this._activeSceneTravel = null;
@@ -1767,150 +1608,37 @@ export class SceneDirector {
/** Resolve absolute authored-clock timing for one shot. */
_sceneTimingForShot(scene, shot) {
const durations =
scene?.shots?.map((item) => this._shotRuntimeDurationSec(scene, item)) ||
[];
const shotIndex =
scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const startElapsedSec = durations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const durationSec = shotIndex >= 0 ? durations[shotIndex] : 0;
const endElapsedSec = Math.min(totalSec, startElapsedSec + durationSec);
return {
shotIndex,
totalSec,
durationSec,
startElapsedSec,
endElapsedSec,
startProgress: totalSec > 0 ? startElapsedSec / totalSec : 0,
endProgress: totalSec > 0 ? endElapsedSec / totalSec : 1,
durationProgress: totalSec > 0 ? durationSec / totalSec : 0,
};
}
/** Interpolate an authored camera flight at a normalized time. */
_cameraAtProgress(fromCamera, toCamera, progress) {
const target = toCamera || fromCamera;
const source = fromCamera || target;
if (!source || !target) return target || source || null;
const t = clamp01(Number(progress) || 0);
const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
const lerp = (from, to) =>
Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
roll: lerpAngle(source.roll, target.roll),
};
return sceneTimingForShot(scene, shot, (scene, shot) =>
this._shotRuntimeDurationSec(scene, shot),
);
}
/** Resolve a normalized scene-clock position to a shot, phase, and camera pose. */
_sceneSeekState(scene, progress) {
if (!scene?.shots?.length) return null;
const normalized = clamp01(Number(progress) || 0);
const durations = scene.shots.map((shot) =>
this._shotRuntimeDurationSec(scene, shot),
return sceneSeekState(
scene,
progress,
(scene, shot) => this._shotRuntimeDurationSec(scene, shot),
(scene, shot) => this._effectiveShotHoldSec(scene, shot),
);
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const targetSec = normalized * Math.max(0, totalSec);
let startElapsedSec = 0;
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = this._effectiveShotHoldSec(scene, shot);
const shotDurationSec = Math.max(
0.001,
flightDurationSec + holdDurationSec,
);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
);
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
const holdProgress =
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera;
}
/** Read timing diagnostics without exposing timer handles or mutable clock state. */
getPlaybackTimingState() {
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
shotElapsedSec,
shotProgress: clamp01(shotElapsedSec / shotDurationSec),
flightDurationSec,
holdDurationSec,
holdElapsedSec,
cameraProgress,
holdProgress,
camera: this._cameraAtProgress(
previousCamera,
shot.camera,
cameraProgress,
),
activeTimers: this._clock.activeTimers,
snapshot: this._clock.snapshot,
};
}
/** Subscribe a scene-owned panel to the authoritative authored clock. */
subscribeSceneClock(listener) {
if (typeof listener !== 'function') return () => {};
this._sceneClockListeners.add(listener);
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
return () => this._sceneClockListeners.delete(listener);
return this._clock.subscribe(listener);
}
/** Publish one authoritative scene-clock snapshot to attached panels. */
_publishSceneClock(
scene,
shot,
sceneElapsedSec,
{ running = this._running, seeking = false } = {},
) {
if (!scene || !shot) return;
const timing = this._sceneTimingForShot(scene, shot);
const elapsedSec = Math.max(
0,
Math.min(timing.totalSec, Number(sceneElapsedSec) || 0),
);
this._sceneClockSnapshot = {
sceneId: scene.id,
shotId: shot.id,
shotIndex: timing.shotIndex,
shotCount: scene.shots.length,
sceneElapsedSec: elapsedSec,
sceneDurationSec: timing.totalSec,
sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0,
running: Boolean(running),
seeking: Boolean(seeking),
};
for (const listener of this._sceneClockListeners) {
try {
listener({ ...this._sceneClockSnapshot });
} catch (error) {
console.warn('[Scenes] Scene clock listener failed:', error);
}
}
_publishSceneClock(scene, shot, sceneElapsedSec, options) {
return this._clock.publish(scene, shot, sceneElapsedSec, options);
}
/** Wait for an interrupted run to release camera and layer ownership. */
@@ -1935,8 +1663,7 @@ export class SceneDirector {
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration += 1;
clearInterval(this._shotProgressTimer);
this._shotProgressTimer = null;
this._clock.stopShot();
const states = this._layerStatesForShot(scene, shot, {
cameraSettled: seekState.cameraProgress >= 1,
sceneSeek: seekState,
@@ -2187,8 +1914,7 @@ export class SceneDirector {
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration++;
clearInterval(this._shotProgressTimer);
this._shotProgressTimer = null;
this._clock.stopShot();
// Transition to running state
this._running = true;
@@ -2297,24 +2023,10 @@ export class SceneDirector {
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration++;
clearInterval(this._shotProgressTimer);
this._shotProgressTimer = null;
this._clock.stopShot();
this._cancelActiveSceneTravel();
this.viewer.camera.cancelFlight();
if (this._sceneClockSnapshot) {
this._sceneClockSnapshot = {
...this._sceneClockSnapshot,
running: false,
stopped: true,
};
for (const listener of this._sceneClockListeners) {
try {
listener({ ...this._sceneClockSnapshot });
} catch {
/* Observer cannot block cancellation. */
}
}
}
this._clock.stop();
if (!this._running || !this._runToken) return;
this._runToken.cancelled = true;
this._runAbort?.abort();
@@ -2653,14 +2365,9 @@ export class SceneDirector {
}
}
/** Cancellable sleep, polling every ~70 ms for prompt interruption. */
/** Wait through the playback clock so Stop also releases pending holds. */
async _sleep(ms, token) {
if (ms <= 0 || token.cancelled) return;
const endAt = Date.now() + ms;
while (Date.now() < endAt) {
if (token.cancelled) return;
await new Promise((resolve) => setTimeout(resolve, 70));
}
return this._clock.wait(ms, token);
}
/**
@@ -2669,100 +2376,17 @@ export class SceneDirector {
* @param {number} totalSec - Estimated total duration in seconds
*/
_startProgressTicker(totalSec) {
clearInterval(this._progressTimer);
const startMs = Date.now();
const totalMs = Math.max(1000, totalSec * 1000);
this._progressTimer = setInterval(() => {
if (!this._running) return;
const elapsedMs = Date.now() - startMs;
this._setProgress(elapsedMs / totalMs);
}, 100);
return this._clock.startRunProgress(totalSec);
}
/** Track one LOAD/replay flight and hold without pretending a full run is active. */
_startShotProgress(token, seconds, from, to, sceneClock = null) {
clearInterval(this._shotProgressTimer);
this._shotProgressTimer = null;
if (token.cancelled || this._running) return;
this._setProgress(from);
if (sceneClock) {
this._publishSceneClock(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedFrom,
{ running: false },
);
}
if (seconds <= 0) {
this._setProgress(to);
if (sceneClock) {
this._publishSceneClock(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedTo,
{ running: false },
);
}
return;
}
const startedAt = Date.now();
const timer = setInterval(() => {
if (token.cancelled || this._running) {
clearInterval(timer);
if (this._shotProgressTimer === timer) this._shotProgressTimer = null;
return;
}
const fraction = clamp01((Date.now() - startedAt) / (seconds * 1000));
this._setProgress(from + (to - from) * fraction);
if (sceneClock) {
this._publishSceneClock(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedFrom +
(sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) *
fraction,
{ running: false },
);
}
if (fraction >= 1) {
clearInterval(timer);
if (this._shotProgressTimer === timer) this._shotProgressTimer = null;
}
}, 100);
timer.unref?.();
this._shotProgressTimer = timer;
return this._clock.startShotProgress(token, seconds, from, to, sceneClock);
}
/** Drive the public authored clock through one running shot. */
_startSceneClockTicker(scene, shot, token) {
clearInterval(this._sceneClockTimer);
const timing = this._sceneTimingForShot(scene, shot);
const startedAt = Date.now();
this._publishSceneClock(scene, shot, timing.startElapsedSec, {
running: true,
});
const timer = setInterval(() => {
if (token.cancelled || !this._running) {
clearInterval(timer);
if (this._sceneClockTimer === timer) this._sceneClockTimer = null;
return;
}
const shotElapsedSec = Math.min(
timing.durationSec,
(Date.now() - startedAt) / 1000,
);
this._publishSceneClock(
scene,
shot,
timing.startElapsedSec + shotElapsedSec,
{
running: true,
},
);
}, 50);
timer.unref?.();
this._sceneClockTimer = timer;
return timing;
return this._clock.startScene(scene, shot, token);
}
/**
@@ -2771,10 +2395,7 @@ export class SceneDirector {
* and resets UI buttons to the idle state.
*/
_finishRun() {
clearInterval(this._progressTimer);
this._progressTimer = null;
clearInterval(this._sceneClockTimer);
this._sceneClockTimer = null;
this._clock.finish();
this._setPlaybackKeyboardEnabled(false);
// Covers the error path too: a run that threw mid-shot must not leave a
// layer transition running against a director that has stopped watching.
@@ -2801,23 +2422,17 @@ export class SceneDirector {
this._runToken = null;
this._setButtons(false);
if (this._sceneClockSnapshot) {
const clockSnapshot = this._clock.snapshot;
if (clockSnapshot) {
const scene = this._project.scenes.find(
({ id }) => id === this._sceneClockSnapshot.sceneId,
);
const shot = scene?.shots?.find(
({ id }) => id === this._sceneClockSnapshot.shotId,
({ id }) => id === clockSnapshot.sceneId,
);
const shot = scene?.shots?.find(({ id }) => id === clockSnapshot.shotId);
if (scene && shot) {
this._publishSceneClock(
scene,
shot,
this._sceneClockSnapshot.sceneElapsedSec,
{
running: false,
seeking: this._sceneClockSnapshot.seeking,
},
);
this._publishSceneClock(scene, shot, clockSnapshot.sceneElapsedSec, {
running: false,
seeking: clockSnapshot.seeking,
});
}
}
for (const resolve of this._runIdleResolvers) resolve();
@@ -2885,3 +2500,5 @@ export class SceneDirector {
});
}
}
export { createScenePackRegistry } from './packs/registry.js';
+11
View File
@@ -0,0 +1,11 @@
import { SCENE_APPEND_RECIPES } from '../recipes.js';
import { createScenePackRegistry } from './registry.js';
import { nepalScenePresentation } from './nepal.js';
/** Compose the installed scene recipes and their trusted presentation rules. */
export function createDefaultScenePacks() {
return createScenePackRegistry({
recipes: SCENE_APPEND_RECIPES,
adapters: [nepalScenePresentation],
});
}
+26
View File
@@ -0,0 +1,26 @@
import { INCIDENT_OVERVIEW_HOLD_SEC } from '../../data/bhoteKoshiIncidentPlaces.js';
/** Existing Nepal presentation rules; authored assets and saved shots stay untouched. */
export const nepalScenePresentation = {
minimumHoldSec(states) {
const overview = states['bhote-koshi-locator'];
return overview?.enabled &&
overview.params?.presentation === 'bhote-koshi-incident-places'
? INCIDENT_OVERVIEW_HOLD_SEC
: 0;
},
resolveVisual(shot, visual, isMapStackAvailable) {
const isNepalShot =
shot?.layers?.['bhote-koshi-2026']?.enabled ||
shot?.layers?.['bhote-koshi-locator']?.enabled;
// A credential is not proof of a loaded tileset. Resolve actual capability at playback time.
return isNepalShot &&
visual.mapStack === 'photoreal' &&
!isMapStackAvailable('photoreal')
? { ...visual, mapStack: 'esri-imagery' }
: visual;
},
cancelMotion(getLayerModule) {
getLayerModule('bhote-koshi-locator')?.cancelSceneMotion?.();
},
};
+24
View File
@@ -0,0 +1,24 @@
/** Register trusted scene-pack recipes and presentation adapters at composition time. */
export function createScenePackRegistry({ recipes = [], adapters = [] } = {}) {
const byId = new Map(recipes.map((recipe) => [recipe.id, recipe]));
const rules = [...adapters];
return {
recipeForShot: (shot) => byId.get(shot?.sourcePackId) || null,
minimumHoldSec(states) {
return Math.max(
0,
...rules.map((rule) => rule.minimumHoldSec?.(states) || 0),
);
},
resolveVisual(shot, visual, isMapStackAvailable) {
return rules.reduce(
(result, rule) =>
rule.resolveVisual?.(shot, result, isMapStackAvailable) || result,
visual,
);
},
cancelMotion(getLayerModule) {
for (const rule of rules) rule.cancelMotion?.(getLayerModule);
},
};
}
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScenePackRegistry } from './registry.js';
import {
effectiveShotHoldSec,
shotRuntimeDurationSec,
layerStatesForShot,
visualStateForShot,
} from '../shotPresentation.js';
test('registered pack rules apply to another source without editing the Director or saved shots', () => {
const recipe = {
id: 'sample',
runtimeHoldSecByBeat: { arrival: 6 },
runtimeControlsByBeat: {
arrival: { minimumHoldSec: 8, deferEvidenceUntilCameraSettled: true },
},
};
let cancelled = 0;
const packs = createScenePackRegistry({
recipes: [recipe],
adapters: [
{
resolveVisual: (_, visual) => ({ ...visual, mapStack: 'osm' }),
cancelMotion: (getModule) => getModule('sample').cancelSceneMotion(),
},
],
});
const shot = {
id: 'a',
sourcePackId: 'sample',
durationSec: 2,
holdSec: 1,
visual: { style: 'normal' },
layers: {
sample: {
enabled: true,
params: { presentation: 'scene-beat', beatId: 'arrival' },
},
},
};
const scene = { id: 'scene', shots: [shot], releaseLayerIds: ['other'] };
const before = structuredClone(shot);
assert.equal(shotRuntimeDurationSec(scene, shot, packs), 10);
assert.equal(effectiveShotHoldSec(scene, shot, packs), 8);
const states = layerStatesForShot(scene, shot, packs, {
cameraSettled: true,
});
assert.equal(states.sample.params.sceneControls.cameraSettled, true);
assert.equal(states.sample.params.sceneContext.holdSec, 8);
assert.equal(states.other.enabled, false);
assert.equal(visualStateForShot(shot, packs, () => true).mapStack, 'osm');
packs.cancelMotion(() => ({ cancelSceneMotion: () => cancelled++ }));
assert.equal(cancelled, 1);
assert.deepEqual(shot, before);
});
+1 -2
View File
@@ -51,8 +51,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
return director._holdShot(scene, shot, token);
},
completeShot({ scene, shot, index }) {
clearInterval(director._sceneClockTimer);
director._sceneClockTimer = null;
director._clock.stopSceneTimer();
director._publishSceneClock(scene, shot, timing.endElapsedSec, {
running: true,
});
+171
View File
@@ -0,0 +1,171 @@
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const deepClone = (value) => JSON.parse(JSON.stringify(value));
/** Prepare scene-owned layer state, timing context and seek/travel controls. */
export function layerStatesForShot(
scene,
shot,
packs,
{ cameraSettled = false, cameraTravel = null, sceneSeek = null } = {},
) {
const states = deepClone(shot?.layers || {});
const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const sceneShotDurations =
scene?.shots?.map((sceneShot) =>
shotRuntimeDurationSec(scene, sceneShot, packs),
) || [];
const sceneDurationSec = sceneShotDurations.reduce(
(sum, duration) => sum + duration,
0,
);
const authoredSceneElapsedSec = sceneShotDurations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const sceneElapsedSec = sceneSeek
? Math.max(
0,
Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0),
)
: authoredSceneElapsedSec;
const sourceRecipe = packs.recipeForShot(shot);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
state.params.sceneSurface ||= shot.sourcePackId
? 'evidence-beat'
: 'panel-only';
const runtimeControls =
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
...(runtimeControls.deferEvidenceUntilCameraSettled
? { cameraSettled }
: {}),
...(sceneSeek
? {
timelineSeek: {
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
flightDurationSec: Number(sceneSeek.flightDurationSec) || 0,
holdDurationSec: Number(sceneSeek.holdDurationSec) || 0,
holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0,
cameraProgress: clamp01(Number(sceneSeek.cameraProgress) || 0),
holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0),
},
}
: {}),
};
if (runtimeControls.evidencePathDuringCamera && cameraTravel) {
state.params.sceneControls.cameraTravel = { ...cameraTravel };
}
}
state.params.sceneContext = {
sceneId: scene.id,
sceneTitle: scene.title,
shotId: shot.id,
shotTitle: shot.title,
shotIndex,
shotCount: scene.shots.length,
durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
holdSec: effectiveShotHoldSec(scene, shot, packs, states),
sceneElapsedSec,
sceneDurationSec,
sceneProgress:
sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0,
...(sceneSeek
? {
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0),
seeking: true,
}
: {}),
};
}
for (const layerId of scene?.releaseLayerIds || []) {
if (!Object.hasOwn(states, layerId)) states[layerId] = { enabled: false };
}
return states;
}
/** Resolve recipe map preferences and registered presentation fallbacks. */
export function visualStateForShot(shot, packs, isMapStackAvailable) {
const sourceRecipe = packs.recipeForShot(shot);
const sceneBeatState = Object.values(shot?.layers || {}).find(
(state) => state?.params?.presentation === 'scene-beat',
);
const controls = {
...sceneBeatState?.params?.sceneControls,
...sourceRecipe?.runtimeControlsByBeat?.[sceneBeatState?.params?.beatId],
};
if (controls.imageryComparison === true) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
if (
sourceRecipe?.standaloneSurfaceBeatIds?.includes(
sceneBeatState?.params?.beatId,
)
) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes(
sceneBeatState?.params?.beatId,
)
? { ...(shot.visual || {}), mapStack: 'photoreal' }
: shot?.visual || {};
return packs.resolveVisual(shot, visual, isMapStackAvailable);
}
/** Combine authored holds, registered pack overrides and layer reveal requirements. */
export function effectiveShotHoldSec(
scene,
shot,
packs,
resolvedStates = null,
) {
const states = resolvedStates || layerStatesForShot(scene, shot, packs);
const recipe = packs.recipeForShot(shot);
const overrides = Object.values(states)
.filter(
(state) => state?.enabled && state.params?.presentation === 'scene-beat',
)
.map((state) => recipe?.runtimeHoldSecByBeat?.[state.params.beatId])
.filter((value) => Number.isFinite(value) && value >= 0);
const authoredHoldSec = overrides.length
? Math.max(...overrides)
: Number(shot?.holdSec) || 0;
return Object.values(states).reduce(
(holdSec, state) => {
const controls = state?.params?.sceneControls;
return Math.max(
holdSec,
Number(controls?.minimumHoldSec) || 0,
controls?.evidenceSequence === true
? Number(controls.evidenceSequenceDurationSec) || 0
: 0,
);
},
Math.max(packs.minimumHoldSec(states), authoredHoldSec),
);
}
/** Estimate flight plus effective hold without mutating saved layer parameters. */
export function shotRuntimeDurationSec(scene, shot, packs) {
const states = deepClone(shot?.layers || {});
const sourceRecipe = packs.recipeForShot(shot);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
const runtimeControls =
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
};
}
}
return (
(shot?.durationSec || DEFAULT_SHOT_DURATION_SEC) +
effectiveShotHoldSec(scene, shot, packs, states)
);
}