diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ab7807e..cb1c77983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Separate Director timing, seek calculations, playback clocks and registered + scene-pack presentation rules. Preserve authored content and controls; Stop + releases pending hold timers and stale ticks cannot affect replacement playback. + + - Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up. - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b524a961..9b9af2ca8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -21,8 +21,12 @@ 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. +Pure authored-time/seek calculations and a playback clock now own timing, +hold deadlines and subscriptions. Stop immediately clears timers and settles +holds; stale tick callbacks cannot publish into a replacement. Registered pack +rules own presentation overrides and Nepal map fallback without changing saved +shots or content. The versioned scene/data-pack format remains planned in +[Director](DIRECTOR.md). 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. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 079920ec4..4d268acf8 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -1,7 +1,7 @@ # 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 +Director is the scene playback system. Ordered shot execution, authored timing and playback clocks are separated +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. @@ -33,9 +33,26 @@ 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. +camera ownership and asynchronous load/seek arbitration. Pure seek calculations +live in `src/director/timeline.js`; `src/director/clock.js` owns the run/load/shot +tickers, hold deadlines, snapshots and clock subscriptions. Stop settles pending +holds immediately, and revoked tick callbacks cannot affect a replacement clock. +`getPlaybackTimingState()` exposes copied snapshots and an active-timer count for +lifecycle diagnostics without exposing timer handles. + +The `director` export also provides `sceneTimingForShot`, `sceneSeekState`, +`cameraAtProgress` and `createPlaybackClock`. Timeline functions receive duration +and hold resolvers; the clock receives timing, running-state and progress callbacks. +They import no renderer, storage, recipe or dataset. + +`src/scenes/shotPresentation.js` prepares layer/seek context and resolves holds +and map preferences through a scene-pack registry. The `scenes` export provides +`createScenePackRegistry({ recipes, adapters })`; pass the result as `scenePacks` +in the SceneDirector constructor options. Trusted adapters can supply +`minimumHoldSec(states)`, `resolveVisual(shot, visual, isMapStackAvailable)` and +`cancelMotion(getLayerModule)`. Default composition registers the existing Nepal +presentation rules. These are code-level composition hooks, not executable +modules imported from scene files. Existing media-owner waits remain bounded. All authored scenes, assets, IDs, source links, attribution and existing JSON projects are preserved. The Nepal sequence remains the contribution introduced @@ -45,11 +62,10 @@ and river coordinates are bundled. See the ## 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. +1. **Timeline and content separation — implemented.** Pure duration/seek + calculations, an owned clock and registered presentation rules preserve + existing recipes and content. Tests cover direct load, forward/backward seek, + replay, bounded media waits and cancellation at pending transitions. 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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f26e1e71..eab826105 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -728,5 +728,9 @@ "scripts/qa-transit-recovery.mjs", "scripts/format-runtime.json", "src/tooling/transitService.test.mjs", - "src/sources/overpassFeatures.test.mjs" + "src/sources/overpassFeatures.test.mjs", + "src/director/clock.test.mjs", + "src/director/timeline.test.mjs", + "src/scenes/packs/registry.test.mjs", + "scripts/qa-director-timing.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a5806db67..dfb217391 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -614,7 +614,11 @@ }, "director": { "exports": ["./director"], - "modules": ["src/director/playback.js"], + "modules": [ + "src/director/playback.js", + "src/director/clock.js", + "src/director/timeline.js" + ], "external": [] }, "scenes": { @@ -635,7 +639,13 @@ "src/ui/scenes.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", - "src/contextModePolicy.js" + "src/contextModePolicy.js", + "src/director/clock.js", + "src/director/timeline.js", + "src/scenes/shotPresentation.js", + "src/scenes/packs/registry.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js" ], "external": ["cesium"] }, @@ -1734,7 +1744,13 @@ "src/voice/realtimeFacade.js", "src/voice/realtimeRadio.js", "src/voice/realtimeTurns.js", - "src/voice/realtimeConnection.js" + "src/voice/realtimeConnection.js", + "src/director/clock.js", + "src/director/timeline.js", + "src/scenes/shotPresentation.js", + "src/scenes/packs/registry.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js" ], "external": [ "@mapbox/vector-tile", diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs new file mode 100644 index 000000000..1361a8dcb --- /dev/null +++ b/scripts/qa-director-timing.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const shots = path.resolve('qa-shots/director-timing'); +fs.mkdirSync(shots, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +} +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const director = window.__godsEyeView.sceneDirector; + const camera = director.styleManager.getCameraState(); + const visual = director.styleManager.getVisualState(); + const scene = { + id: 'timing-qa', + title: 'Timing QA', + shots: [0, 1].map((index) => ({ + id: `timing-${index}`, + title: `Timing ${index}`, + durationSec: 0.4, + holdSec: 0.3, + camera: { ...camera, lon: camera.lon + index * 0.002 }, + visual, + layers: {}, + })), + }; + director._project.scenes.push(scene); + const load = await director.loadShot(scene.id, scene.shots[0].id, { + flyDuration: 0.3, + }); + const forward = await director.seekScene(scene.id, 0.9); + const forwardSnapshot = director.getPlaybackTimingState().snapshot; + const backward = await director.seekScene(scene.id, 0.1); + const backwardSnapshot = director.getPlaybackTimingState().snapshot; + const replay = await director.replayShot(scene.id, scene.shots[1].id); + director.stopScene(); + await director.startScene(scene.id, { single: true, preview: false }); + const completed = + !director.running && director.getPlaybackTimingState().activeTimers === 0; + scene.shots[0].holdSec = 60; + const run = director.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while ( + director.getPlaybackTimingState().activeTimers < 3 && + Date.now() < deadline + ) + await new Promise((resolve) => setTimeout(resolve, 25)); + const holding = director.getPlaybackTimingState().activeTimers >= 3; + const stoppedAt = performance.now(); + director.stopScene('QA Stop during hold'); + await run; + const stoppedPromptly = performance.now() - stoppedAt < 1000; + const noTimers = director.getPlaybackTimingState().activeTimers === 0; + director._project.scenes = director._project.scenes.filter( + ({ id }) => id !== scene.id, + ); + return { + loaded: load?.started, + forward: forward && forwardSnapshot.shotId === 'timing-1', + backward: backward && backwardSnapshot.shotId === 'timing-0', + replay: replay?.started, + completed, + holding, + stoppedPromptly, + noTimers, + }; + }); + for (const [name, passed] of Object.entries(result)) check(name, passed); + const content = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes.find(({ title }) => + /Nepal Flood Incident/i.test(title), + ); + if (!scene) return { exists: false }; + const shot = + scene.shots.find(({ title }) => /Regional Approach/i.test(title)) || + scene.shots[0]; + const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 }); + return { + exists: true, + shots: scene.shots.length, + loaded: loaded?.started === true, + }; + }); + check( + 'existing Nepal scene and authored shots load', + content.exists && content.shots >= 25 && content.loaded, + ); + await new Promise((resolve) => setTimeout(resolve, 2500)); + await page.screenshot({ path: path.join(shots, 'nepal-regional.png') }); + await page.evaluate(() => { + const g = window.__godsEyeView; + g.sceneDirector.stopScene(); + const pose = g.styleManager.getCameraState(); + g.sceneDirector._setCameraView({ + ...pose, + heading: pose.heading + 45, + pitch: -55, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page.screenshot({ path: path.join(shots, 'nepal-angle.png') }); + check( + 'no pending clocks after Stop', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getPlaybackTimingState() + .activeTimers === 0, + ), + ); + check('no browser exceptions', errors.length === 0); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 450668e48..42ce6b919 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -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, diff --git a/src/director/clock.js b/src/director/clock.js new file mode 100644 index 000000000..fa83047a3 --- /dev/null +++ b/src/director/clock.js @@ -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; + } +} diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs new file mode 100644 index 000000000..c259c10ec --- /dev/null +++ b/src/director/clock.test.mjs @@ -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); +}); diff --git a/src/director/playback.js b/src/director/playback.js index 596321b20..9279dfcf8 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -96,3 +96,10 @@ const SHOT_PHASES = [ 'hold', 'completeShot', ]; + +export { + sceneTimingForShot, + sceneSeekState, + cameraAtProgress, +} from './timeline.js'; +export { createPlaybackClock } from './clock.js'; diff --git a/src/director/timeline.js b/src/director/timeline.js new file mode 100644 index 000000000..782b073f7 --- /dev/null +++ b/src/director/timeline.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), + }; +} diff --git a/src/director/timeline.test.mjs b/src/director/timeline.test.mjs new file mode 100644 index 000000000..1950eeef0 --- /dev/null +++ b/src/director/timeline.test.mjs @@ -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)); +}); diff --git a/src/scenes/director.js b/src/scenes/director.js index 06a1049ee..345566b97 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -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'; diff --git a/src/scenes/packs/defaults.js b/src/scenes/packs/defaults.js new file mode 100644 index 000000000..0994e804c --- /dev/null +++ b/src/scenes/packs/defaults.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], + }); +} diff --git a/src/scenes/packs/nepal.js b/src/scenes/packs/nepal.js new file mode 100644 index 000000000..f84022889 --- /dev/null +++ b/src/scenes/packs/nepal.js @@ -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?.(); + }, +}; diff --git a/src/scenes/packs/registry.js b/src/scenes/packs/registry.js new file mode 100644 index 000000000..f10765c66 --- /dev/null +++ b/src/scenes/packs/registry.js @@ -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); + }, + }; +} diff --git a/src/scenes/packs/registry.test.mjs b/src/scenes/packs/registry.test.mjs new file mode 100644 index 000000000..4b903c2fa --- /dev/null +++ b/src/scenes/packs/registry.test.mjs @@ -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); +}); diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index ce28b0d56..c3870c5d6 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -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, }); diff --git a/src/scenes/shotPresentation.js b/src/scenes/shotPresentation.js new file mode 100644 index 000000000..921aa2709 --- /dev/null +++ b/src/scenes/shotPresentation.js @@ -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) + ); +}