mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: extract reusable Director playback
This commit is contained in:
1 parent
2746ec753c
commit
9f7adf71f6
11 files changed
+498
-122
No files matched your search
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Build an ordered shot queue, starting at a scene and optionally wrapping.
|
||||
* Scene and shot objects are opaque to the runner apart from their identifiers.
|
||||
* @param {Array<{id:string, shots:Object[]}>} scenes
|
||||
* @param {string} startSceneId
|
||||
* @param {{single?:boolean}} [options]
|
||||
* @returns {Array<{scene:Object, shot:Object}>}
|
||||
*/
|
||||
export function buildPlaybackQueue(
|
||||
scenes,
|
||||
startSceneId,
|
||||
{ single = false } = {},
|
||||
) {
|
||||
const start = Math.max(
|
||||
0,
|
||||
scenes.findIndex((scene) => scene.id === startSceneId),
|
||||
);
|
||||
const ordered = single
|
||||
? scenes.slice(start, start + 1)
|
||||
: [...scenes.slice(start), ...scenes.slice(0, start)];
|
||||
return ordered.flatMap((scene) =>
|
||||
scene.shots.map((shot) => ({ scene, shot })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ordered shots through an application-supplied playback adapter.
|
||||
*
|
||||
* Each phase receives {scene, shot, index, total, token}. The adapter owns
|
||||
* camera/rendering resources and must observe token.signal or token.cancelled
|
||||
* while awaiting work. The runner rechecks both after every phase; it cannot
|
||||
* interrupt an adapter that ignores cancellation. No timers, UI, storage,
|
||||
* renderer or authored content are imported here.
|
||||
*
|
||||
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
||||
* (false refuses that initial handoff). Between scenes and during final cleanup
|
||||
* it receives no cancelled token, so resource release can finish after Stop.
|
||||
* An empty/already-cancelled queue acquires and releases no resources.
|
||||
*
|
||||
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
|
||||
* @param {Object} options
|
||||
* @param {{cancelled?:boolean, signal?:AbortSignal}} options.token Run lifetime.
|
||||
* @param {Object} options.adapter selectShot/applyVisual/applyLayers/travel/
|
||||
* settle/hold/completeShot callbacks, releaseScene and optional complete.
|
||||
* @param {Object|null} [options.previousScene=null] Previously loaded scene.
|
||||
* @param {boolean} [options.releaseOnFinish=true] Release the final scene.
|
||||
* @returns {Promise<{status:'completed'|'cancelled', completedShots:number}>}
|
||||
*/
|
||||
export async function playSceneQueue(
|
||||
queue,
|
||||
{ token, adapter, previousScene = null, releaseOnFinish = true },
|
||||
) {
|
||||
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
||||
let activeScene = null;
|
||||
let completedShots = 0;
|
||||
try {
|
||||
if (!queue.length || cancelled()) {
|
||||
return {
|
||||
status: cancelled() ? 'cancelled' : 'completed',
|
||||
completedShots,
|
||||
};
|
||||
}
|
||||
if (previousScene && previousScene.id !== queue[0].scene.id) {
|
||||
const released = await adapter.releaseScene(previousScene, token);
|
||||
if (!released)
|
||||
throw new Error(`Could not leave scene: ${previousScene.title}`);
|
||||
}
|
||||
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
||||
const { scene, shot } = queue[index];
|
||||
if (activeScene && activeScene.id !== scene.id) {
|
||||
await adapter.releaseScene(activeScene);
|
||||
activeScene = null;
|
||||
if (cancelled()) break;
|
||||
}
|
||||
activeScene = scene;
|
||||
const context = { scene, shot, index, total: queue.length, token };
|
||||
for (const phase of SHOT_PHASES) {
|
||||
if (cancelled()) break;
|
||||
await adapter[phase](context);
|
||||
}
|
||||
if (!cancelled()) completedShots++;
|
||||
}
|
||||
if (!cancelled()) await adapter.complete?.();
|
||||
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
||||
} finally {
|
||||
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
|
||||
}
|
||||
}
|
||||
|
||||
const SHOT_PHASES = [
|
||||
'selectShot',
|
||||
'applyVisual',
|
||||
'applyLayers',
|
||||
'travel',
|
||||
'settle',
|
||||
'hold',
|
||||
'completeShot',
|
||||
];
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildPlaybackQueue, playSceneQueue } from './playback.js';
|
||||
|
||||
const scenes = [
|
||||
{ id: 'a', title: 'A', shots: [{ id: 'a1' }, { id: 'a2' }] },
|
||||
{ id: 'empty', shots: [] },
|
||||
{ id: 'b', title: 'B', shots: [{ id: 'b1' }] },
|
||||
];
|
||||
const phases = [
|
||||
'selectShot',
|
||||
'applyVisual',
|
||||
'applyLayers',
|
||||
'travel',
|
||||
'settle',
|
||||
'hold',
|
||||
'completeShot',
|
||||
];
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const events = [];
|
||||
const abort = new AbortController();
|
||||
const token = { cancelled: false, signal: abort.signal };
|
||||
const adapter = {
|
||||
...Object.fromEntries(
|
||||
phases.map((phase) => [
|
||||
phase,
|
||||
({ shot, token: received }) => {
|
||||
assert.equal(received, token);
|
||||
events.push(`${shot.id}:${phase}`);
|
||||
},
|
||||
]),
|
||||
),
|
||||
releaseScene(scene, received) {
|
||||
events.push(`release:${scene.id}:${received ? 'handoff' : 'cleanup'}`);
|
||||
return true;
|
||||
},
|
||||
complete() {
|
||||
events.push('complete');
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { events, abort, token, adapter };
|
||||
}
|
||||
|
||||
test('queues rotate scenes, skip empty scenes, preserve shot identity and support a single scene', () => {
|
||||
const queue = buildPlaybackQueue(scenes, 'b');
|
||||
assert.deepEqual(
|
||||
queue.map(({ shot }) => shot.id),
|
||||
['b1', 'a1', 'a2'],
|
||||
);
|
||||
assert.equal(queue[0].shot, scenes[2].shots[0]);
|
||||
assert.deepEqual(buildPlaybackQueue(scenes, 'empty', { single: true }), []);
|
||||
assert.deepEqual(buildPlaybackQueue([], 'missing'), []);
|
||||
assert.deepEqual(
|
||||
buildPlaybackQueue(scenes, 'missing').map(({ shot }) => shot.id),
|
||||
['a1', 'a2', 'b1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('playback sequences phases, releases only at scene changes, then releases the final scene', async () => {
|
||||
const f = fixture();
|
||||
const result = await playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
|
||||
assert.deepEqual(result, { status: 'completed', completedShots: 3 });
|
||||
assert.deepEqual(f.events, [
|
||||
...phases.map((phase) => `a1:${phase}`),
|
||||
...phases.map((phase) => `a2:${phase}`),
|
||||
'release:a:cleanup',
|
||||
...phases.map((phase) => `b1:${phase}`),
|
||||
'complete',
|
||||
'release:b:cleanup',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const phase of phases) {
|
||||
for (const cancellation of ['flag', 'signal']) {
|
||||
test(`${cancellation} cancellation while awaiting ${phase} stops subsequent work and releases resources`, async () => {
|
||||
let entered;
|
||||
let resume;
|
||||
const started = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const pending = new Promise((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const f = fixture({
|
||||
[phase]: async () => {
|
||||
entered();
|
||||
await pending;
|
||||
},
|
||||
});
|
||||
const run = playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
|
||||
await started;
|
||||
if (cancellation === 'flag') f.token.cancelled = true;
|
||||
else f.abort.abort();
|
||||
resume();
|
||||
assert.deepEqual(await run, { status: 'cancelled', completedShots: 0 });
|
||||
assert.deepEqual(f.events, [
|
||||
...phases.slice(0, phases.indexOf(phase)).map((name) => `a1:${name}`),
|
||||
'release:a:cleanup',
|
||||
]);
|
||||
});
|
||||
}
|
||||
test(`failure in ${phase} propagates after release, without later shots`, async () => {
|
||||
const failure = new Error('adapter failed');
|
||||
const f = fixture({
|
||||
[phase]: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
|
||||
(error) => error === failure,
|
||||
);
|
||||
assert.equal(f.events.at(-1), 'release:a:cleanup');
|
||||
assert.ok(
|
||||
!f.events.some(
|
||||
(event) => event.startsWith('a2:') || event === 'complete',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('empty and pre-aborted runs never acquire or release adapter resources', async () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await playSceneQueue([], f), {
|
||||
status: 'completed',
|
||||
completedShots: 0,
|
||||
});
|
||||
f.abort.abort();
|
||||
assert.deepEqual(
|
||||
await playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
|
||||
...f,
|
||||
previousScene: scenes[2],
|
||||
}),
|
||||
{ status: 'cancelled', completedShots: 0 },
|
||||
);
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('a prior scene must release before playback; a refused handoff starts no shot', async () => {
|
||||
const f = fixture();
|
||||
await playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), {
|
||||
...f,
|
||||
previousScene: scenes[2],
|
||||
});
|
||||
assert.equal(f.events[0], 'release:b:handoff');
|
||||
const refused = fixture({ releaseScene: () => false });
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
|
||||
...refused,
|
||||
previousScene: scenes[2],
|
||||
}),
|
||||
/Could not leave scene: B/,
|
||||
);
|
||||
assert.deepEqual(refused.events, []);
|
||||
});
|
||||
|
||||
test('cancellation during an inter-scene release never acquires the next scene', async () => {
|
||||
const f = fixture();
|
||||
const release = f.adapter.releaseScene;
|
||||
f.adapter.releaseScene = (...args) => {
|
||||
f.abort.abort();
|
||||
return release(...args);
|
||||
};
|
||||
const result = await playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
|
||||
assert.deepEqual(result, { status: 'cancelled', completedShots: 2 });
|
||||
assert.equal(f.events.at(-1), 'release:a:cleanup');
|
||||
assert.equal(
|
||||
f.events.filter((event) => event.startsWith('release')).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-preview playback retains the final scene but still releases preceding scenes', async () => {
|
||||
const f = fixture();
|
||||
await playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
|
||||
...f,
|
||||
releaseOnFinish: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
f.events.filter((event) => event.startsWith('release')),
|
||||
['release:a:cleanup'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a cleanup failure rejects for the caller to restore its own controls', async () => {
|
||||
const failure = new Error('cleanup failed');
|
||||
const f = fixture({
|
||||
releaseScene: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||
(error) => error === failure,
|
||||
);
|
||||
});
|
||||
+11
-119
@@ -14,6 +14,8 @@
|
||||
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { SceneControls } from '../ui/scenes.js';
|
||||
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
|
||||
import { createScenePlaybackAdapter } from './playbackAdapter.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
SCENE_APPEND_RECIPES,
|
||||
@@ -2064,28 +2066,7 @@ export class SceneDirector {
|
||||
* @returns {Array<{ scene: Object, shot: Object }>}
|
||||
*/
|
||||
_buildPlaybackQueue(startSceneId, { single = false } = {}) {
|
||||
if (!this._project.scenes.length) return [];
|
||||
|
||||
// Rotate the scene list so startSceneId comes first
|
||||
const startIdx = Math.max(
|
||||
0,
|
||||
this._project.scenes.findIndex((scene) => scene.id === startSceneId),
|
||||
);
|
||||
const ordered = single
|
||||
? this._project.scenes.slice(startIdx, startIdx + 1)
|
||||
: [
|
||||
...this._project.scenes.slice(startIdx),
|
||||
...this._project.scenes.slice(0, startIdx),
|
||||
];
|
||||
|
||||
// Flatten scenes into a sequential shot queue
|
||||
const queue = [];
|
||||
for (const scene of ordered) {
|
||||
for (const shot of scene.shots) {
|
||||
queue.push({ scene, shot });
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
return buildPlaybackQueue(this._project.scenes, startSceneId, { single });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2255,110 +2236,21 @@ export class SceneDirector {
|
||||
this._logEvent('scene_run_start', { count: queue.length });
|
||||
this._setPlaybackKeyboardEnabled(true);
|
||||
|
||||
let activeScene = null;
|
||||
try {
|
||||
const loadedScene = this._project.scenes.find(
|
||||
(scene) => scene.id === this._loadedSceneId,
|
||||
);
|
||||
if (loadedScene && loadedScene.id !== queue[0].scene.id) {
|
||||
const released = await this._releaseSceneLayers(loadedScene, token);
|
||||
if (!released)
|
||||
throw new Error(`Could not leave scene: ${loadedScene.title}`);
|
||||
this._loadedSceneId = null;
|
||||
}
|
||||
|
||||
// Main shot sequencing loop
|
||||
for (let idx = 0; idx < queue.length; idx++) {
|
||||
if (token.cancelled) break;
|
||||
const { scene, shot } = queue[idx];
|
||||
|
||||
if (activeScene && activeScene.id !== scene.id) {
|
||||
await this._releaseSceneLayers(activeScene);
|
||||
this._loadedSceneId = null;
|
||||
activeScene = null;
|
||||
if (token.cancelled) break;
|
||||
}
|
||||
activeScene = scene;
|
||||
|
||||
// Update UI selection to track the active shot
|
||||
this._selectedSceneId = scene.id;
|
||||
this._selectedShotId = shot.id;
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
|
||||
this._updateStatus(
|
||||
`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`,
|
||||
);
|
||||
|
||||
this._logEvent('shot_start', {
|
||||
sceneId: scene.id,
|
||||
shotId: shot.id,
|
||||
title: shot.title,
|
||||
index: idx,
|
||||
});
|
||||
|
||||
// Apply visual state (style, bloom, sharpen, HUD, detection) then layers, then fly.
|
||||
// Awaited: applyVisualState suspends on a map-stack switch, and a shot
|
||||
// captured by the operator carries one — un-awaited, its shader uniforms
|
||||
// land after the NEXT shot has already been applied.
|
||||
//
|
||||
// Every await is a place STOP/Esc can arrive. A suspended map-stack
|
||||
// switch can hold this shot for seconds; without a re-check the layer
|
||||
// pass below still runs and the operator watches layers keep toggling
|
||||
// after they hit Stop. Cancellation is re-read after each one.
|
||||
await this.styleManager.applyVisualState(
|
||||
this._visualStateForShot(shot),
|
||||
{
|
||||
isCurrent: () => !token.cancelled,
|
||||
},
|
||||
);
|
||||
if (token.cancelled) break;
|
||||
const flightDuration = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
|
||||
await this._applyLayerStates(
|
||||
this._layerStatesForShot(scene, shot),
|
||||
token,
|
||||
);
|
||||
if (token.cancelled) break;
|
||||
this._loadedSceneId = scene.id;
|
||||
const sceneTiming = this._startSceneClockTicker(scene, shot, token);
|
||||
const cameraTravel = this._beginShotTravel(scene, shot, flightDuration);
|
||||
const flight = this._flyCamera(shot.camera, flightDuration, token);
|
||||
this._publishShotTravel(scene, shot, cameraTravel);
|
||||
await flight;
|
||||
if (token.cancelled) break;
|
||||
this._settleShotLayerStates(scene, shot, token, cameraTravel);
|
||||
if (token.cancelled) break;
|
||||
// Hold on the final frame before transitioning to the next shot
|
||||
await this._holdShot(scene, shot, token);
|
||||
if (token.cancelled) break;
|
||||
clearInterval(this._sceneClockTimer);
|
||||
this._sceneClockTimer = null;
|
||||
this._publishSceneClock(scene, shot, sceneTiming.endElapsedSec, {
|
||||
running: true,
|
||||
});
|
||||
|
||||
this._logEvent('shot_end', {
|
||||
sceneId: scene.id,
|
||||
shotId: shot.id,
|
||||
index: idx,
|
||||
});
|
||||
}
|
||||
|
||||
if (!token.cancelled) {
|
||||
this._setProgress(1);
|
||||
this._updateStatus('Scene run complete');
|
||||
this._logEvent('scene_run_complete', {});
|
||||
}
|
||||
await playSceneQueue(queue, {
|
||||
token,
|
||||
adapter: createScenePlaybackAdapter(this, DEFAULT_SHOT_DURATION_SEC),
|
||||
previousScene: this._project.scenes.find(
|
||||
(scene) => scene.id === this._loadedSceneId,
|
||||
),
|
||||
releaseOnFinish: preview,
|
||||
});
|
||||
} catch (error) {
|
||||
this._updateStatus(`Error: ${error.message || 'run failed'}`);
|
||||
this._logEvent('scene_run_error', {
|
||||
message: error.message || 'unknown error',
|
||||
});
|
||||
} finally {
|
||||
if (preview) {
|
||||
await this._releaseSceneLayers(activeScene);
|
||||
if (activeScene?.id === this._loadedSceneId) this._loadedSceneId = null;
|
||||
}
|
||||
this._finishRun();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Connect the portable Director runner to the existing scene presentation. */
|
||||
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
let timing;
|
||||
let travel;
|
||||
return {
|
||||
async releaseScene(scene, token) {
|
||||
const released = await director._releaseSceneLayers(scene, token);
|
||||
if (!token || released) director._loadedSceneId = null;
|
||||
return released;
|
||||
},
|
||||
selectShot({ scene, shot, index, total }) {
|
||||
director._selectedSceneId = scene.id;
|
||||
director._selectedShotId = shot.id;
|
||||
director._renderSceneSelect();
|
||||
director._renderShotList();
|
||||
director._updateStatus(
|
||||
`Running ${index + 1}/${total}: ${scene.title} / ${shot.title}`,
|
||||
);
|
||||
director._logEvent('shot_start', {
|
||||
sceneId: scene.id,
|
||||
shotId: shot.id,
|
||||
title: shot.title,
|
||||
index,
|
||||
});
|
||||
},
|
||||
applyVisual({ shot, token }) {
|
||||
return director.styleManager.applyVisualState(
|
||||
director._visualStateForShot(shot),
|
||||
{ isCurrent: () => !token.cancelled && !token.signal?.aborted },
|
||||
);
|
||||
},
|
||||
applyLayers({ scene, shot, token }) {
|
||||
return director._applyLayerStates(
|
||||
director._layerStatesForShot(scene, shot),
|
||||
token,
|
||||
);
|
||||
},
|
||||
travel({ scene, shot, token }) {
|
||||
director._loadedSceneId = scene.id;
|
||||
const duration = shot.durationSec || defaultShotDurationSec;
|
||||
timing = director._startSceneClockTicker(scene, shot, token);
|
||||
travel = director._beginShotTravel(scene, shot, duration);
|
||||
const flight = director._flyCamera(shot.camera, duration, token);
|
||||
director._publishShotTravel(scene, shot, travel);
|
||||
return flight;
|
||||
},
|
||||
settle({ scene, shot, token }) {
|
||||
director._settleShotLayerStates(scene, shot, token, travel);
|
||||
},
|
||||
hold({ scene, shot, token }) {
|
||||
return director._holdShot(scene, shot, token);
|
||||
},
|
||||
completeShot({ scene, shot, index }) {
|
||||
clearInterval(director._sceneClockTimer);
|
||||
director._sceneClockTimer = null;
|
||||
director._publishSceneClock(scene, shot, timing.endElapsedSec, {
|
||||
running: true,
|
||||
});
|
||||
director._logEvent('shot_end', {
|
||||
sceneId: scene.id,
|
||||
shotId: shot.id,
|
||||
index,
|
||||
});
|
||||
},
|
||||
complete() {
|
||||
director._setProgress(1);
|
||||
director._updateStatus('Scene run complete');
|
||||
director._logEvent('scene_run_complete', {});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user