mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(director): separate timing clocks and scene pack presentation
This commit is contained in:
1 parent
5728dec24d
commit
8a3c4868b0
19 files changed
+1190
-471
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -96,3 +96,10 @@ const SHOT_PHASES = [
|
||||
'hold',
|
||||
'completeShot',
|
||||
];
|
||||
|
||||
export {
|
||||
sceneTimingForShot,
|
||||
sceneSeekState,
|
||||
cameraAtProgress,
|
||||
} from './timeline.js';
|
||||
export { createPlaybackClock } from './clock.js';
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user