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

+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));
});