refactor: extract reusable Director playback

This commit is contained in:
Sameh Khamis committed 2026-09-15 10:15:12 -07:00
1 parent 2746ec753c
commit 9f7adf71f6
11 files changed
+498 -122

No files matched your search

+4
View File
@@ -1,5 +1,9 @@
# Changelog
- Extract a portable Director shot runner and connect existing scene playback to
it. Preserve authored content, project files, camera/layer behavior and source
attribution; document the planned timeline, scene-file and data-pack boundaries.
- Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports.
- Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution.
+7
View File
@@ -1,5 +1,12 @@
# God's Eye View Current State
Director's ordered shot runner is exported independently of rendering, UI,
storage and scene content. The existing scene controller supplies an adapter for
visual/layer state, camera travel, media holds and release. Playback cancellation,
preview cleanup, saved projects and all scene assets/attribution are preserved.
Timeline extraction and a shareable scene/data-pack format are planned in
[Director](DIRECTOR.md); those follow-up capabilities are not implemented yet.
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged.
Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease.
+90
View File
@@ -0,0 +1,90 @@
# Director: reusable scene playback
Director is the scene playback system. The first extraction separates ordered
shot execution from the scene editor and application rendering. This document
also records the proposed next steps; the sharing format and interactions below
are a design plan, not features already available.
## Implemented boundary
`src/director/playback.js`, exported as `gods-eye-view/director`, provides:
- `buildPlaybackQueue(scenes, startSceneId, { single })`: ordered scenes/shots,
retaining the existing round-robin behavior.
- `playSceneQueue(queue, { token, adapter, previousScene, releaseOnFinish })`:
sequential execution, cancellation checks and scene release.
The runner imports no renderer, UI, storage, recipe or dataset. An adapter supplies
these phases, in order: `selectShot`, `applyVisual`, `applyLayers`, `travel`,
`settle`, `hold`, `completeShot`. Each receives `{ scene, shot, index, total,
token }`. It also supplies `releaseScene(scene, token?)` and optionally `complete()`.
Callbacks may return promises. Each playback invocation requires its own adapter
state and token; the caller owns camera arbitration and concurrent-run refusal.
The runner checks cancellation after each phase. Adapters must also pass the
abort signal or liveness predicate into pending work: a check between phases
cannot stop a camera flight or network request already in progress. Cleanup
between scenes and on preview exit runs without an aborted token. A refused
initial handoff starts no shot. Final cleanup runs on completion, Stop or failure;
`releaseOnFinish: false` retains the final scene for ordinary playback. Exceptions
propagate so the caller can restore its controls. Empty/already-cancelled queues
acquire no resources. This low-level API expects normalized scene objects;
untrusted file validation belongs at the import boundary.
`src/scenes/playbackAdapter.js` connects this runner to the existing
`SceneDirector`. The latter still owns editor state, saved-project import/export,
camera ownership, seeking, clocks and presentation. Pack-specific layer states,
media holds and map preferences retain their current implementation. This first
slice does not make the entire scene controller renderer-independent.
All authored scenes, assets, IDs, source links, attribution and existing JSON
projects are preserved. The Nepal sequence remains the contribution introduced
by @manjunath22466 in #590. Its clips use source links/embeds; comparison images
and river coordinates are bundled. See the
[event pack provenance](../public/events/bhote-koshi-2026/README.md).
## Next changes, in order
1. **Timeline and content separation.** Extract pure duration/seek calculations
and a clock with an explicit lifetime. Move content-specific hold, layer and
presentation rules behind registered scene-pack adapters. Keep existing
recipes and content intact. Test direct load, forward/backward seek, replay,
media timeout and Stop at every pending transition.
2. **Versioned scene document.** Build on the existing version-3 project export
and its importer. Specify a schema and migrations before changing writes.
Keep old imports and local-storage projects readable; reject unsupported
versions without replacing the current project. Include stable scene, shot,
anchor, pack and action IDs. Separate portable authored state from transient
loading state and run diagnostics. Bound document size, collection lengths,
numeric values and nesting; show errors at the affected field.
3. **Camera directions.** Define named geographic anchors and explicit
move/hold segments: start and destination, duration, easing, heading, pitch,
roll and stop duration. State coordinate units, altitude reference and
terrain-relative behavior. Support the current camera-pose shots first;
paths, look-at targets and pauses follow with deterministic seek behavior.
4. **Data packs and placement.** Define a pack manifest with format/version,
source, attribution/license and optional integrity/size metadata. Distinguish
where bytes live (relative files, a URL, or an eventual bundle) from where
features appear (geographic bounds, anchors, transforms and elevation rules).
Start with existing GeoJSON/image/media adapters. Resolve relative paths
against an explicit document base; define CORS, caching, missing-asset and
offline behavior. Fetch only through registered source adapters with bounded,
cancellable loading. Scene files carry no credentials or executable modules.
5. **Declarative interactions.** Give pickable features stable IDs and connect
clicks to a small action registry: show a text/source card, focus an anchor,
seek a shot, or change an allowed layer state. Define selection feedback,
keyboard equivalents and precedence with drawing/tracking controls. Validate
target references and cancel pending actions on Stop, replacement or teardown.
No JavaScript evaluation or arbitrary HTML in scene files. Branching must
have explicit loop/transition limits and reproducible reset/seek semantics.
6. **Sharing and authoring.** Add validated preview/import/export, missing-pack
diagnostics and an optional self-contained bundle. Preserve attribution with
each pack; a share link does not change an asset's license. A future node graph
could author this same document, but playback and the format will not require
a graph editor.
Each step must preserve existing scene editing, keyboard playback, camera
arbitration, layer cleanup, media timing and provider fallback. Acceptance includes
portable runner tests, existing Director regressions and browser playback with
Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures
for failures and cancellation, alongside live visual checks for actual content.
+1
View File
@@ -142,6 +142,7 @@
"./ui/scenes": "./src/ui/scenes.js",
"./ui/shell": "./src/ui/applicationShell.js",
"./ui/styles": "./src/ui/styles.js",
"./director": "./src/director/playback.js",
"./scenes": "./src/scenes/director.js",
"./sources/live": "./src/sources/live/index.js",
"./layers/flights": "./src/layers/flights/index.js",
+6 -3
View File
@@ -35,9 +35,12 @@ const portableExport = (key) =>
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
key,
) ||
['./voice/action-schemas', './voice/session', './data/lifecycle'].includes(
key,
);
[
'./director',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
].includes(key);
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
export function checkImportDirections(root) {
+9
View File
@@ -606,6 +606,11 @@
],
"external": []
},
"director": {
"exports": ["./director"],
"modules": ["src/director/playback.js"],
"external": []
},
"scenes": {
"exports": ["./scenes"],
"modules": [
@@ -615,6 +620,8 @@
"src/data/bhoteKoshiFloodPath.js",
"src/maps/catalog.js",
"src/scenes/director.js",
"src/scenes/playbackAdapter.js",
"src/director/playback.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
"src/bloom.js",
@@ -1518,6 +1525,8 @@
"src/renderGovernor.js",
"src/rightRailPolicy.js",
"src/scenes/director.js",
"src/scenes/playbackAdapter.js",
"src/director/playback.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
"src/scopeMask.js",
+3
View File
@@ -85,6 +85,9 @@ try {
);
promptValue = '<b>QA shot</b>';
await page.click('.scene-shot-label', { count: 2 });
await page.waitForSelector('.scene-shot-rename', { visible: true });
await page.type('.scene-shot-rename', promptValue);
await page.keyboard.press('Enter');
await page.waitForFunction(
() =>
document.querySelector('.scene-shot-label')?.textContent ===
+98
View File
@@ -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',
];
+198
View File
@@ -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
View File
@@ -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();
}
}
+71
View File
@@ -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', {});
},
};
}