From 8a3c4868b0a51d39678e5921df7cb7cb5cd405b6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 15:41:42 -0700 Subject: [PATCH 1/5] refactor(director): separate timing clocks and scene pack presentation --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 8 +- docs/DIRECTOR.md | 36 +- scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 22 +- scripts/qa-director-timing.mjs | 137 ++++++++ src/data/bhoteKoshiLocator.test.mjs | 4 + src/director/clock.js | 301 ++++++++++++++++ src/director/clock.test.mjs | 173 +++++++++ src/director/playback.js | 7 + src/director/timeline.js | 97 ++++++ src/director/timeline.test.mjs | 51 +++ src/scenes/director.js | 523 ++++------------------------ src/scenes/packs/defaults.js | 11 + src/scenes/packs/nepal.js | 26 ++ src/scenes/packs/registry.js | 24 ++ src/scenes/packs/registry.test.mjs | 56 +++ src/scenes/playbackAdapter.js | 3 +- src/scenes/shotPresentation.js | 171 +++++++++ 19 files changed, 1190 insertions(+), 471 deletions(-) create mode 100644 scripts/qa-director-timing.mjs create mode 100644 src/director/clock.js create mode 100644 src/director/clock.test.mjs create mode 100644 src/director/timeline.js create mode 100644 src/director/timeline.test.mjs create mode 100644 src/scenes/packs/defaults.js create mode 100644 src/scenes/packs/nepal.js create mode 100644 src/scenes/packs/registry.js create mode 100644 src/scenes/packs/registry.test.mjs create mode 100644 src/scenes/shotPresentation.js 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) + ); +} From 18300e7e0027290c8f448345b444541948e86462 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 16:20:36 -0700 Subject: [PATCH 2/5] feat(director): validate scene documents and preserve saved edits --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 7 +- docs/DIRECTOR.md | 13 +- docs/SCENE-DOCUMENT.md | 102 +++++++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 11 +- scripts/qa-scene-controls.mjs | 34 +++ src/director/document.js | 268 ++++++++++++++++++ src/director/document.test.mjs | 161 +++++++++++ src/director/playback.js | 9 + src/scenes/director.js | 463 ++++++-------------------------- src/scenes/director.test.mjs | 100 ++++++- src/scenes/project.js | 383 ++++++++++++++++++++++++++ 13 files changed, 1155 insertions(+), 404 deletions(-) create mode 100644 docs/SCENE-DOCUMENT.md create mode 100644 src/director/document.js create mode 100644 src/director/document.test.mjs create mode 100644 src/scenes/project.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1c77983..f58c06b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Director validates bounded version-3 scene files before replacing a project, + preserves unreadable browser saves, migrates legacy bloom once and preserves + zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner. + + - 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9b9af2ca8..48ab18fe8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -25,8 +25,11 @@ 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). +shots or content. Scene-document validation and legacy migration now have separate +owners. Invalid imports preserve the current project; unreadable saved projects +are protected from fallback writes. Version 3 remains the export format, with +zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); +data packs and interactions remain 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 4d268acf8..164ad7f2f 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -66,13 +66,12 @@ and river coordinates are bundled. See the 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 - 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. +2. **Versioned scene document — implemented for existing camera-pose shots.** + [Version 3](SCENE-DOCUMENT.md) remains the write format. Bounded validation + rejects malformed files before replacing current state; legacy migration + preserves IDs, pack bindings and visual edits. Unreadable saved files are + protected from fallback writes. Camera anchors, pack manifests and actions + receive stable IDs with their respective later format extensions. 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 diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md new file mode 100644 index 000000000..985742d1b --- /dev/null +++ b/docs/SCENE-DOCUMENT.md @@ -0,0 +1,102 @@ +# Scene document version 3 + +Scenes exports a UTF-8 JSON project. Version 3 remains the write format; existing +version-3 exports need no conversion. Versions 1, 2 and unversioned legacy files +are migrated on import. Unknown versions or unsupported fields are rejected with +a field path before the current project, selection or saved bytes change. + +```json +{ + "version": 3, + "scenes": [{ + "id": "my-scene", + "title": "My scene", + "shots": [{ + "id": "my-shot", + "title": "Arrival", + "durationSec": 4, + "holdSec": 1, + "camera": { "lat": 51.5, "lon": -0.12, "alt": 800, + "heading": 0, "pitch": -35, "roll": 0 }, + "visual": { "style": "normal" }, + "layers": { "traffic": { "enabled": true } } + }] + }] +} +``` + +## Fields and migration + +The root requires `scenes` (an array, which may be empty). Optional root fields +are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs). +Each scene requires a `shots` array; optional fields are `id`, `title`, +`releaseLayerIds` and `appliedShotPacks`. A pack marker has `id`, optional numeric +`version` and `shotBindings` (original shot title to saved shot ID). These markers +retain the editor's existing pack-update and rename behavior. + +Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`, +`sourcePackId` and `sourcePackVersion`. Scene IDs are unique within the project; +shot IDs are unique within their scene. Existing IDs and pack bindings survive +migration. Missing IDs are generated once and persisted by the next save/export. +Missing titles and optional camera/visual fields receive the editor's defaults. +Empty scenes and intentionally empty projects remain empty. + +Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses the +existing camera-pose height in meters. Import preserves finite heights below +100 meters and zero pitch, which previously fell through to defaults. Explicit +altitude-reference and terrain-relative directions are a subsequent format step. +Flight durations retain the editor's minimum of 0.2 seconds and its 4-second +fallback for zero; holds may be zero. + +Visual fields are `style`, `mapStack`, `styleParams`, `bloom`, `sharpen`, `hud`, and +`detection`, and `scope`. Bloom has `enabled`, `intensity`, `version`; sharpen has `enabled`, +`intensity`; HUD has `visible`, `variant`; detection has `mode`, `density`, +`allocation`, `fadePct` and `outsideOpacityPct`. Scope has `enabled`, `featherPct`. +The latter settings were written by Update Shot but previously lost during +normalization; they now survive import and capture. +Version-1/2 bloom without an explicit scale version uses the original inverted +scale and migrates once to scale 2. The scene document and bloom scale versions +are separate. Legacy numeric strings are accepted for numeric fields. + +Layer entries accept a boolean shorthand or `{ "enabled": true, "params": {} }`. +Parameters and style parameters retain nested JSON data. They are interpreted by +already registered components; importing a file does not fetch its URLs, register +modules, evaluate code or apply its layers. Unsupported structural fields are +rejected instead of silently dropping potential edits. Map-stack selection +retains the existing registered-stack behavior. + +## Validation limits + +`gods-eye-view/director` exports `parseSceneDocument(text)`, +`validateSceneDocument(project)`, `stringifySceneDocument(project)`, +`SceneDocumentError`, `SCENE_DOCUMENT_VERSION` and `SCENE_DOCUMENT_LIMITS`. +Validation is independent of the renderer, storage and recipes. Parsing checks +UTF-8 size before JSON decoding; the file importer also checks file size before +reading it. Migration/defaults live separately in `src/scenes/project.js`. + +Limits are 5 MiB, 256 scenes, 10,000 total shots, 10,000 entries per collection, +24 levels of nesting, 200,000 visited values, 65,536 characters per JSON string, +256 characters per field/ID, and 4,096 characters per title. Numeric values must +be finite. Camera limits are latitude ±90, longitude ±180, altitude −12,000 to +1 billion meters, heading/roll ±360 and pitch ±90; durations/holds are at most +86,400 seconds. Post-processing values are bounded and normalized to the existing +slider ranges. Object prototype keys are refused at every depth. + +An invalid import leaves the current project and playback untouched. A valid +import cancels and settles pending playback before replacing the project. If +multiple files are read concurrently, only the newest request can replace it. +Disposal prevents a pending import from publishing. + +The browser storage key remains `godsEyeView.sceneProject.v2` for compatibility; +that key's suffix is not the document version. An unreadable saved project stays +in storage. The app supplies temporary defaults and a visible warning, but blocks +saving over the original bytes until a valid file is explicitly imported. Export +can preserve temporary edits. Storage writes use the same validation before replacing saved bytes; invalid +edits and storage write failures surface an unsaved toast. + +Only authored project fields belong in this file. Runtime clocks, camera flights, +loading progress and run diagnostics remain separate. Camera anchors, asset-pack +manifests and declarative interactions are planned extensions, not accepted fields +in version 3. They will receive stable IDs and their own validated migration when +introduced. No scene assets, source links, licenses or contributor attribution +change with this document boundary. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eab826105..f334c4e46 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -732,5 +732,6 @@ "src/director/clock.test.mjs", "src/director/timeline.test.mjs", "src/scenes/packs/registry.test.mjs", - "scripts/qa-director-timing.mjs" + "scripts/qa-director-timing.mjs", + "src/director/document.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dfb217391..9df4d9135 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -617,7 +617,8 @@ "modules": [ "src/director/playback.js", "src/director/clock.js", - "src/director/timeline.js" + "src/director/timeline.js", + "src/director/document.js" ], "external": [] }, @@ -645,7 +646,9 @@ "src/scenes/shotPresentation.js", "src/scenes/packs/registry.js", "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js" + "src/scenes/packs/nepal.js", + "src/director/document.js", + "src/scenes/project.js" ], "external": ["cesium"] }, @@ -1750,7 +1753,9 @@ "src/scenes/shotPresentation.js", "src/scenes/packs/registry.js", "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js" + "src/scenes/packs/nepal.js", + "src/director/document.js", + "src/scenes/project.js" ], "external": [ "@mapbox/vector-tile", diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index c4980f314..f56a69239 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -274,6 +274,40 @@ try { 'QA Scene', ), ); + const savedBefore = await page.evaluate(() => + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + const futureFile = path.join(shots, 'future.json'); + fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] })); + await input.uploadFile(futureFile); + await page.waitForFunction(() => + document.getElementById('scene-status').textContent.includes('$.version'), + ); + check( + 'Unsupported versions leave authored state and saved bytes unchanged', + await page.evaluate( + (saved) => + localStorage.getItem('godsEyeView.sceneProject.v2') === saved && + window.__godsEyeView.sceneDirector._getSelectedScene().title === + 'QA Scene', + savedBefore, + ), + ); + const malformed = structuredClone(fixture); + malformed.scenes[0].shots[0].camera.lat = 91; + const malformedFile = path.join(shots, 'malformed.json'); + fs.writeFileSync(malformedFile, JSON.stringify(malformed)); + await input.uploadFile(malformedFile); + await page.waitForFunction(() => + document.getElementById('scene-status').textContent.includes('camera.lat'), + ); + check( + 'Invalid camera field identifies its path without replacing the project', + await page.evaluate( + (saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved, + savedBefore, + ), + ); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); await page.screenshot({ path: path.join(shots, 'narrow.png') }); diff --git a/src/director/document.js b/src/director/document.js new file mode 100644 index 000000000..d0ecdebb4 --- /dev/null +++ b/src/director/document.js @@ -0,0 +1,268 @@ +/** Bounded, renderer-independent validation for authored scene documents. */ +export const SCENE_DOCUMENT_VERSION = 3; +export const SCENE_DOCUMENT_LIMITS = Object.freeze({ + bytes: 5 * 1024 * 1024, + scenes: 256, + shots: 10000, + collection: 10000, + depth: 24, + nodes: 200000, + string: 65536, +}); + +/** A project error identifies a field without echoing its supplied value. */ +export class SceneDocumentError extends Error { + constructor(path, reason) { + super(`${path}: ${reason}`); + this.name = 'SceneDocumentError'; + this.path = path; + } +} +const fail = (path, reason) => { + throw new SceneDocumentError(path, reason); +}; +const object = (value, path) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(path, 'expected an object'); +}; +function fields(value, path, allowed) { + object(value, path); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field'); + } +} +function string(value, path, max = 256) { + if (typeof value !== 'string' || !value.trim() || value.length > max) + fail(path, `expected nonempty text, at most ${max} characters`); +} +function number(value, path, min, max, legacy) { + const numeric = + legacy && typeof value === 'string' && value.trim() ? Number(value) : value; + if ( + typeof numeric !== 'number' || + !Number.isFinite(numeric) || + numeric < min || + numeric > max + ) + fail(path, `expected a number from ${min} to ${max}`); +} +function optional(value, key, path, check) { + if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`); +} +function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) { + if (!Array.isArray(value) || value.length > max) + fail(path, `expected an array of at most ${max} entries`); +} +function ids(value, path) { + array(value, path); + value.forEach((id, index) => string(id, `${path}[${index}]`)); +} +function uniqueId(value, path, seen) { + if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration. + string(value.id, `${path}.id`); + if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID'); + seen.add(value.id); +} +function jsonTree(value, path, budget, depth = 0) { + if ( + ++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes || + depth > SCENE_DOCUMENT_LIMITS.depth + ) + fail(path, 'project complexity limit exceeded'); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.length > SCENE_DOCUMENT_LIMITS.string) + fail(path, 'text is too long'); + return; + } + if (typeof value === 'number' && Number.isFinite(value)) return; + if (!value || typeof value !== 'object') fail(path, 'expected a JSON value'); + if ( + Object.getPrototypeOf(value) !== Object.prototype && + !Array.isArray(value) && + Object.getPrototypeOf(value) !== null + ) + fail(path, 'expected a JSON object'); + const entries = Object.entries(value); + if (entries.length > SCENE_DOCUMENT_LIMITS.collection) + fail(path, 'too many entries'); + for (const [key, child] of entries) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) + fail(path, 'unsafe object key'); + if (key.length > 256) fail(path, 'field name is too long'); + jsonTree(child, `${path}.${key}`, budget, depth + 1); + } +} +function visual(value, path, legacy) { + fields(value, path, [ + 'style', + 'bloom', + 'sharpen', + 'hud', + 'detection', + 'scope', + 'mapStack', + 'styleParams', + ]); + for (const key of ['style', 'mapStack']) optional(value, key, path, string); + optional(value, 'styleParams', path, object); + const specs = { + bloom: { enabled: 'boolean', intensity: [-100, 10000], version: [1, 100] }, + sharpen: { enabled: 'boolean', intensity: [0, 100] }, + hud: { visible: 'boolean', variant: 'string' }, + detection: { + mode: 'string', + density: [0, 100], + allocation: 'string', + fadePct: [0, 100], + outsideOpacityPct: [0, 100], + }, + scope: { enabled: 'boolean', featherPct: [0, 100] }, + }; + for (const [key, spec] of Object.entries(specs)) + optional(value, key, path, (entry, field) => { + fields(entry, field, Object.keys(spec)); + for (const [name, type] of Object.entries(spec)) + optional(entry, name, field, (item, at) => { + if (Array.isArray(type)) number(item, at, ...type, legacy); + else if (type === 'string') string(item, at); + else if (typeof item !== 'boolean') fail(at, 'expected a boolean'); + }); + }); +} + +/** Validate a parsed scene project without mutating it or applying any state. */ +export function validateSceneDocument(project) { + jsonTree(project, '$', { nodes: 0 }); + fields(project, '$', [ + 'version', + 'createdAt', + 'updatedAt', + 'installedBuiltInSceneIds', + 'scenes', + ]); + const version = project.version ?? 1; + if (![1, 2, 3].includes(version)) + fail('$.version', 'unsupported scene project version'); + const legacy = version < 3; + for (const key of ['createdAt', 'updatedAt']) + optional(project, key, '$', string); + optional(project, 'installedBuiltInSceneIds', '$', ids); + array(project.scenes, '$.scenes', SCENE_DOCUMENT_LIMITS.scenes); + const sceneIds = new Set(); + let shotCount = 0; + project.scenes.forEach((scene, index) => { + const path = `$.scenes[${index}]`; + fields(scene, path, [ + 'id', + 'title', + 'releaseLayerIds', + 'appliedShotPacks', + 'shots', + ]); + uniqueId(scene, path, sceneIds); + optional(scene, 'title', path, (v, p) => string(v, p, 4096)); + optional(scene, 'releaseLayerIds', path, ids); + optional(scene, 'appliedShotPacks', path, (packs, field) => { + array(packs, field); + const packIds = new Set(); + packs.forEach((pack, i) => { + const at = `${field}[${i}]`; + fields(pack, at, ['id', 'version', 'shotBindings']); + string(pack.id, `${at}.id`); + uniqueId(pack, at, packIds); + optional(pack, 'version', at, (v, p) => + number(v, p, 1, 1000000, legacy), + ); + optional(pack, 'shotBindings', at, (bindings, p) => { + object(bindings, p); + for (const [title, id] of Object.entries(bindings)) + string(id, `${p}.${title}`); + }); + }); + }); + array(scene.shots, `${path}.shots`); + shotCount += scene.shots.length; + if (shotCount > SCENE_DOCUMENT_LIMITS.shots) + fail(`${path}.shots`, 'too many shots in project'); + const shotIds = new Set(); + scene.shots.forEach((shot, i) => { + const at = `${path}.shots[${i}]`; + fields(shot, at, [ + 'id', + 'title', + 'durationSec', + 'holdSec', + 'camera', + 'visual', + 'layers', + 'sourcePackId', + 'sourcePackVersion', + ]); + uniqueId(shot, at, shotIds); + optional(shot, 'title', at, (v, p) => string(v, p, 4096)); + optional(shot, 'sourcePackId', at, string); + optional(shot, 'sourcePackVersion', at, (v, p) => + number(v, p, 1, 1000000, legacy), + ); + optional(shot, 'durationSec', at, (v, p) => + number(v, p, 0, 86400, legacy), + ); + optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy)); + optional(shot, 'camera', at, (camera, field) => { + const bounds = { + lat: [-90, 90], + lon: [-180, 180], + alt: [-12000, 1e9], + heading: [-360, 360], + pitch: [-90, 90], + roll: [-360, 360], + }; + fields(camera, field, Object.keys(bounds)); + for (const [key, range] of Object.entries(bounds)) + optional(camera, key, field, (v, p) => + number(v, p, ...range, legacy), + ); + }); + optional(shot, 'visual', at, (v, p) => visual(v, p, legacy)); + optional(shot, 'layers', at, (layers, field) => { + object(layers, field); + for (const [id, entry] of Object.entries(layers)) { + string(id, field); + if (typeof entry === 'boolean') continue; + const p = `${field}.${id}`; + fields(entry, p, ['enabled', 'params']); + if (typeof entry.enabled !== 'boolean') + fail(`${p}.enabled`, 'expected a boolean'); + optional(entry, 'params', p, object); + } + }); + }); + }); + return project; +} + +/** Read bounded JSON. No URLs, modules or actions are executed during import. */ +export function parseSceneDocument(text) { + if ( + typeof text !== 'string' || + text.length > SCENE_DOCUMENT_LIMITS.bytes || + new TextEncoder().encode(text).byteLength > SCENE_DOCUMENT_LIMITS.bytes + ) + fail('$', 'file exceeds 5 MiB'); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + fail('$', 'invalid JSON'); + } + return validateSceneDocument(parsed); +} + +/** Export authored project fields only; callers pass the project, never run state. */ +export function stringifySceneDocument(project) { + // Round-trip drops optional undefined fields created by the editor. + const text = JSON.stringify(project, null, 2); + parseSceneDocument(text); + return text; +} diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs new file mode 100644 index 000000000..d9a2f9ab4 --- /dev/null +++ b/src/director/document.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseSceneDocument, + stringifySceneDocument, + validateSceneDocument, + SCENE_DOCUMENT_LIMITS, +} from './document.js'; +import { createDefaultProject, normalizeProject } from '../scenes/project.js'; + +const fixture = () => ({ + version: 3, + scenes: [ + { + id: 'scene', + title: 'My scene', + shots: [ + { + id: 'shot', + title: 'My shot', + durationSec: 3, + holdSec: 0, + camera: { lat: 1, lon: 2, alt: 42, pitch: 0 }, + layers: { + traffic: { enabled: true, params: { nested: [1, 'two', null] } }, + }, + }, + ], + }, + ], +}); +test('all built-in authored content exports and normalizes without edit loss', () => { + const project = createDefaultProject(); + const migrated = normalizeProject( + parseSceneDocument(stringifySceneDocument(project)), + ); + assert.deepEqual( + JSON.parse(stringifySceneDocument(migrated)), + JSON.parse(stringifySceneDocument(project)), + ); + const edited = normalizeProject(fixture()); + edited.scenes[0].appliedShotPacks = [ + { id: 'pack', version: 3, shotBindings: { Original: 'shot' } }, + ]; + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(edited))), + edited, + ); + assert.equal(edited.scenes[0].shots[0].camera.pitch, 0); + assert.equal(edited.scenes[0].shots[0].camera.alt, 42); +}); +test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds survive', () => { + for (const version of [undefined, 1, 2]) { + const project = fixture(); + project.version = version; + project.scenes[0].shots[0].visual = { + bloom: { enabled: true, intensity: 25 }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.equal(migrated.version, 3); + assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150); + assert.equal(migrated.scenes[0].shots[0].id, 'shot'); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); + } +}); +test('an intentionally empty project stays empty', () => { + assert.deepEqual( + normalizeProject(parseSceneDocument('{"version":3,"scenes":[]}')).scenes, + [], + ); +}); +test('missing legacy IDs become stable after the first saved migration', () => { + const project = { scenes: [{ shots: [{}] }] }; + const migrated = normalizeProject(validateSceneDocument(project)); + assert.ok(migrated.scenes[0].id); + assert.ok(migrated.scenes[0].shots[0].id); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); +}); +test('invalid shapes, versions, unknown fields and unsafe keys fail with field paths', () => { + for (const text of [ + 'null', + '[]', + '{}', + '{"version":99,"scenes":[]}', + '{"version":3,"scenes":[],"run":{}}', + '{"scenes":[],"__proto__":{}}', + ]) { + assert.throws(() => parseSceneDocument(text), { + name: 'SceneDocumentError', + }); + } + const project = fixture(); + project.scenes[0].shots[0].camera.lat = 91; + assert.throws( + () => validateSceneDocument(project), + /\$\.scenes\[0\]\.shots\[0\]\.camera.lat/, + ); + project.scenes[0].shots[0].camera.lat = 1; + project.scenes[0].shots.push(structuredClone(project.scenes[0].shots[0])); + assert.throws(() => validateSceneDocument(project), /duplicate ID/); +}); +test('document byte, nesting, collection, finite-number and string bounds are enforced', () => { + assert.throws( + () => parseSceneDocument(' '.repeat(SCENE_DOCUMENT_LIMITS.bytes + 1)), + /5 MiB/, + ); + assert.throws( + () => + parseSceneDocument( + JSON.stringify({ + scenes: [], + createdAt: 'é'.repeat(SCENE_DOCUMENT_LIMITS.bytes / 2), + }), + ), + /5 MiB/, + ); + const project = fixture(); + project.scenes[0].shots[0].camera.lat = Infinity; + assert.throws(() => validateSceneDocument(project), /JSON value/); + project.scenes[0].shots[0].camera.lat = 1; + let nested = project.scenes[0].shots[0].layers.traffic.params; + for (let i = 0; i < 30; i++) nested = nested.child = {}; + assert.throws(() => validateSceneDocument(project), /complexity/); + assert.throws( + () => validateSceneDocument({ scenes: Array(257).fill({ shots: [] }) }), + /256/, + ); +}); + +test('captured scope and extended detection edits survive migration', () => { + const project = fixture(); + project.scenes[0].shots[0].visual = { + scope: { enabled: true, featherPct: 11 }, + detection: { + mode: 'DENSE', + density: 75, + allocation: 'ELASTIC', + fadePct: 7, + outsideOpacityPct: 1, + }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.scope, + project.scenes[0].shots[0].visual.scope, + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.detection, + project.scenes[0].shots[0].visual.detection, + ); +}); diff --git a/src/director/playback.js b/src/director/playback.js index 9279dfcf8..ff5620ed5 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -103,3 +103,12 @@ export { cameraAtProgress, } from './timeline.js'; export { createPlaybackClock } from './clock.js'; + +export { + parseSceneDocument, + validateSceneDocument, + stringifySceneDocument, + SceneDocumentError, + SCENE_DOCUMENT_VERSION, + SCENE_DOCUMENT_LIMITS, +} from './document.js'; diff --git a/src/scenes/director.js b/src/scenes/director.js index 345566b97..5c3c44a92 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -23,7 +23,6 @@ import { getSceneAppendRecipeById, } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; -import { MAP_STACKS } from '../maps/catalog.js'; import { createDefaultScenePacks } from './packs/defaults.js'; import { layerStatesForShot, @@ -34,380 +33,27 @@ import { import { sceneTimingForShot, sceneSeekState } from '../director/timeline.js'; import { createPlaybackClock } from '../director/clock.js'; import { - BLOOM_INTENSITY_DEFAULT, - BLOOM_SCALE_VERSION, - decodeBloomIntensity, -} from '../bloom.js'; + normalizeLayerEntry, + PROJECT_VERSION, + DEFAULT_SHOT_DURATION_SEC, + DEFAULT_HOLD_SEC, + uid, + deepClone, + recipeToScene, + createDefaultProject, + normalizeShot, + normalizeProject, +} from './project.js'; +import { + parseSceneDocument, + stringifySceneDocument, + SCENE_DOCUMENT_LIMITS, + SceneDocumentError, +} from '../director/document.js'; /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1'; -/** @constant {number} Current schema version for project migration */ -const PROJECT_VERSION = 3; -/** @constant {number} Fallback camera flight duration per shot (seconds) */ -const DEFAULT_SHOT_DURATION_SEC = 4; -/** @constant {number} Default hold/pause after a shot completes (seconds) */ -const DEFAULT_HOLD_SEC = 0.9; -const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id)); - -/** - * Generate a short random identifier with the given prefix. - * @param {string} prefix - e.g. 'shot', 'scene' - * @returns {string} Identifier like "shot-a1b2c3d4" - */ -function uid(prefix) { - return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; -} - -/** - * Deep-clone a JSON-serializable value via round-trip stringify/parse. - * @param {*} value - * @returns {*} - */ -function deepClone(value) { - return JSON.parse(JSON.stringify(value)); -} - -/** - * Normalize a raw layer state entry into a canonical { enabled, params? } shape. - * Accepts both boolean shorthand and full object forms. - * @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params }) - * @returns {{ enabled: boolean, params?: Object }} - */ -function normalizeLayerEntry(entry) { - if (entry && typeof entry === 'object') { - return { - enabled: !!entry.enabled, - params: - entry.params && typeof entry.params === 'object' - ? deepClone(entry.params) - : undefined, - }; - } - return { enabled: !!entry }; -} - -/** - * Normalize a raw bloom post-processing state, migrating intensity values - * across bloom scale versions so older saved projects render correctly. - * @param {Object} rawBloom - Raw bloom state from storage or recipe - * @param {Object} [options] - * @param {number} [options.projectVersion] - Schema version of the source project - * @param {number} [options.fallbackIntensity] - Default intensity if not stored - * @returns {{ enabled: boolean, intensity: number, version: number }} - */ -function normalizeBloomState( - rawBloom = {}, - { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}, -) { - // Determine which bloom scale the stored value was encoded under. - // Older projects (version < PROJECT_VERSION) used scale version 1. - const explicitVersion = Number(rawBloom.version); - const bloomVersion = Number.isFinite(explicitVersion) - ? explicitVersion - : projectVersion >= PROJECT_VERSION - ? BLOOM_SCALE_VERSION - : 1; - - const rawIntensity = Number.isFinite(Number(rawBloom.intensity)) - ? Number(rawBloom.intensity) - : fallbackIntensity; - - return { - enabled: !!rawBloom.enabled, - intensity: decodeBloomIntensity(rawIntensity, bloomVersion), - version: BLOOM_SCALE_VERSION, - }; -} - -/** - * Convert a static scene recipe (from recipes.js) into a mutable scene object - * with fully normalized shots. Each keyframe in the recipe's cameraPath becomes - * one shot, inheriting the recipe's style, post, and layer configuration. - * @param {Object} recipe - A SCENE_RECIPES entry - * @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }} - */ -function recipeToScene(recipe) { - const post = recipe.post || {}; - const ui = recipe.ui || {}; - const styleParams = - post.styleParams && typeof post.styleParams === 'object' - ? deepClone(post.styleParams) - : {}; - - // Normalize layer targets from the recipe into canonical form - const layers = {}; - for (const [layerId, target] of Object.entries(recipe.layers || {})) { - layers[layerId] = normalizeLayerEntry(target); - } - - // Derive HUD visibility/variant from recipe's ui.hudMode string - const hudMode = ui.hudMode || 'minimal'; - const hudVisible = hudMode !== 'off'; - const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical'; - - // Convert each cameraPath keyframe into a shot with shared visual state - const path = recipe.cameraPath || []; - const shots = path.map((keyframe, idx) => { - const keyframeLayers = deepClone(layers); - for (const [layerId, target] of Object.entries(keyframe.layers || {})) { - keyframeLayers[layerId] = normalizeLayerEntry(target); - } - const mapStack = keyframe.mapStack || post.mapStack; - return { - id: uid('shot'), - title: keyframe.title || `Shot ${idx + 1}`, - durationSec: Math.max( - 0.2, - keyframe.duration || DEFAULT_SHOT_DURATION_SEC, - ), - holdSec: Math.max(0, keyframe.hold || 0), - camera: { - lat: keyframe.lat, - lon: keyframe.lon, - alt: keyframe.alt, - heading: keyframe.heading || 0, - pitch: keyframe.pitch || -40, - roll: keyframe.roll || 0, - }, - visual: { - style: recipe.style || 'normal', - bloom: { - enabled: - typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, - intensity: - typeof post.bloom === 'number' - ? decodeBloomIntensity(post.bloom, 1) - : BLOOM_INTENSITY_DEFAULT, - version: BLOOM_SCALE_VERSION, - }, - sharpen: { - enabled: - typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, - intensity: 65, - }, - hud: { - visible: hudVisible, - variant: hudVariant, - }, - detection: { - mode: post.detectionMode || 'OFF', - density: 35, - }, - ...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}), - styleParams, - }, - layers: keyframeLayers, - ...(recipe.id ? { sourcePackId: recipe.id } : {}), - ...(recipe.version ? { sourcePackVersion: recipe.version } : {}), - }; - }); - - return { - id: recipe.id || uid('scene'), - title: recipe.title || 'Untitled Scene', - releaseLayerIds: [ - ...new Set( - (Array.isArray(recipe.releaseLayerIds) - ? recipe.releaseLayerIds - : [] - ).filter((layerId) => typeof layerId === 'string' && layerId.trim()), - ), - ], - appliedShotPacks: [], - shots, - }; -} - -/** - * Create a fresh default project by converting all built-in SCENE_RECIPES. - * @returns {Object} A new project object with version, timestamps, and scenes - */ -function createDefaultProject() { - return { - version: PROJECT_VERSION, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - installedBuiltInSceneIds: SCENE_RECIPES.filter( - (recipe) => typeof recipe.installAlongsideSceneId === 'string', - ).map((recipe) => recipe.id), - scenes: SCENE_RECIPES.map(recipeToScene), - }; -} - -/** - * Normalize a raw shot object from storage or capture into a fully validated - * shape with safe defaults for every field. Handles version migration for - * bloom intensity and coerces all numeric fields. - * @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema) - * @param {number} [index=0] - Positional index used for fallback title - * @param {Object} [options] - * @param {number} [options.projectVersion] - Schema version of the enclosing project - * @returns {Object} Fully normalized shot - */ -function normalizeShot( - rawShot, - index = 0, - { projectVersion = PROJECT_VERSION } = {}, -) { - const camera = rawShot?.camera || {}; - const visual = rawShot?.visual || {}; - const bloom = visual.bloom || {}; - const sharpen = visual.sharpen || {}; - const hud = visual.hud || {}; - const detection = visual.detection || {}; - - return { - id: rawShot?.id || uid('shot'), - title: rawShot?.title || `Shot ${index + 1}`, - durationSec: Math.max( - 0.2, - Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC, - ), - holdSec: Math.max(0, Number(rawShot?.holdSec) || 0), - camera: { - lat: Number(camera.lat) || 0, - lon: Number(camera.lon) || 0, - alt: Math.max(100, Number(camera.alt) || 800), - heading: Number(camera.heading) || 0, - pitch: Number(camera.pitch) || -35, - roll: Number(camera.roll) || 0, - }, - visual: { - style: visual.style || 'normal', - bloom: normalizeBloomState(bloom, { - projectVersion, - fallbackIntensity: 50, - }), - sharpen: { - enabled: !!sharpen.enabled, - intensity: Math.max( - 0, - Math.min( - 100, - Number.isFinite(Number(sharpen.intensity)) - ? Number(sharpen.intensity) - : 65, - ), - ), - }, - hud: { - visible: typeof hud.visible === 'boolean' ? hud.visible : true, - variant: typeof hud.variant === 'string' ? hud.variant : 'tactical', - }, - detection: { - mode: typeof detection.mode === 'string' ? detection.mode : 'OFF', - density: Math.max( - 0, - Math.min( - 100, - Number.isFinite(Number(detection.density)) - ? Number(detection.density) - : 35, - ), - ), - }, - ...(SCENE_MAP_STACK_IDS.has(visual.mapStack) - ? { mapStack: visual.mapStack } - : {}), - styleParams: - visual.styleParams && typeof visual.styleParams === 'object' - ? deepClone(visual.styleParams) - : {}, - }, - layers: Object.fromEntries( - Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [ - layerId, - normalizeLayerEntry(value), - ]), - ), - ...(typeof rawShot?.sourcePackId === 'string' - ? { sourcePackId: rawShot.sourcePackId } - : {}), - ...(Number.isFinite(Number(rawShot?.sourcePackVersion)) - ? { sourcePackVersion: Number(rawShot.sourcePackVersion) } - : {}), - }; -} - -/** - * Normalize and migrate an entire project object loaded from storage or import. - * Falls back to the default recipe-based project when input is invalid or empty. - * @param {Object|null} rawProject - Raw project data (potentially from an older schema) - * @returns {Object} Fully normalized project at the current PROJECT_VERSION - */ -function normalizeProject(rawProject) { - if (!rawProject || typeof rawProject !== 'object') - return createDefaultProject(); - const projectVersion = Number.isFinite(Number(rawProject.version)) - ? Number(rawProject.version) - : 1; - - const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : []; - const scenes = scenesRaw - .map((scene, sceneIdx) => { - const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : []; - const shots = shotsRaw.map((shot, shotIdx) => - normalizeShot(shot, shotIdx, { projectVersion }), - ); - return { - id: scene?.id || uid('scene'), - title: scene?.title || `Scene ${sceneIdx + 1}`, - releaseLayerIds: [ - ...new Set( - (Array.isArray(scene?.releaseLayerIds) - ? scene.releaseLayerIds - : [] - ).filter( - (layerId) => typeof layerId === 'string' && layerId.trim(), - ), - ), - ], - appliedShotPacks: (Array.isArray(scene?.appliedShotPacks) - ? scene.appliedShotPacks - : [] - ) - .filter((entry) => typeof entry?.id === 'string') - .map((entry) => ({ - id: entry.id, - version: Number(entry.version) || 1, - ...(entry.shotBindings && typeof entry.shotBindings === 'object' - ? { - shotBindings: Object.fromEntries( - Object.entries(entry.shotBindings).filter( - ([title, shotId]) => - typeof title === 'string' && typeof shotId === 'string', - ), - ), - } - : {}), - })), - shots, - }; - }) - // Keep scenes that have shots or at least a title - .filter((scene) => scene.shots.length > 0 || scene.title); - - if (!scenes.length) { - return createDefaultProject(); - } - - return { - version: PROJECT_VERSION, - createdAt: rawProject.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - installedBuiltInSceneIds: [ - ...new Set( - (Array.isArray(rawProject.installedBuiltInSceneIds) - ? rawProject.installedBuiltInSceneIds - : [] - ).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()), - ), - ], - scenes, - }; -} - /** * Orchestrates deterministic cinematic scene playback. * @@ -475,7 +121,9 @@ export class SceneDirector { this._loadedSceneId = null; this._presentation = { - status: 'Ready', + status: this._storageReadError + ? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.' + : 'Ready', progress: 0, runtime: '', playbackActive: false, @@ -555,7 +203,7 @@ export class SceneDirector { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return createDefaultProject(); - const project = normalizeProject(JSON.parse(raw)); + const project = normalizeProject(parseSceneDocument(raw)); const installed = new Set(project.installedBuiltInSceneIds || []); let migrated = false; for (const recipe of SCENE_RECIPES) { @@ -586,7 +234,9 @@ export class SceneDirector { if (migrated) { project.installedBuiltInSceneIds = [...installed]; try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); + const payload = JSON.stringify(project); + parseSceneDocument(payload); + localStorage.setItem(STORAGE_KEY, payload); } catch (e) { // Keep the migrated in-memory project usable even if this origin // refuses persistence; the normal UI save path will surface errors. @@ -597,16 +247,26 @@ export class SceneDirector { } } return project; - } catch { + } catch (error) { + // Never overwrite an unreadable or newer saved document with defaults. + this._storageReadError = error; return createDefaultProject(); } } /** Persist the current project state to localStorage with an updated timestamp. */ _saveProject() { + if (this._storageReadError) { + this._toastStorageError( + 'Scene not saved — existing saved project could not be read. Export edits or import a valid file.', + ); + return; + } this._project.updatedAt = new Date().toISOString(); try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this._project)); + const payload = JSON.stringify(this._project); + parseSceneDocument(payload); + localStorage.setItem(STORAGE_KEY, payload); } catch (e) { // Private browsing / block-all-cookies / quota-exceeded throws here. The // in-memory project stays usable this session, but persistence failed — @@ -615,13 +275,18 @@ export class SceneDirector { '[Scenes] Could not persist project (storage unavailable):', e, ); - this._toastStorageError(); + this._toastStorageError( + e instanceof SceneDocumentError + ? `Scene not saved — ${e.message}` + : undefined, + ); } } /** Surface a "scene not saved" notice via the global toast + scene status line. */ - _toastStorageError() { - const message = 'Scene not saved — browser storage unavailable'; + _toastStorageError( + message = 'Scene not saved — browser storage unavailable', + ) { this._updateStatus(message); try { const toast = document.getElementById('toast'); @@ -2038,7 +1703,13 @@ export class SceneDirector { exportProject() { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const fileName = `scene-presets-${stamp}.json`; - const payload = JSON.stringify(this._project, null, 2); + let payload; + try { + payload = stringifySceneDocument(this._project); + } catch (error) { + this._updateStatus(`Export failed: ${error.message}`); + return; + } // Trigger a browser download via a temporary anchor element const blob = new Blob([payload], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -2059,18 +1730,34 @@ export class SceneDirector { * @param {File} file - Browser File object from an */ async importProjectFile(file) { + const generation = (this._importGeneration = + (this._importGeneration || 0) + 1); try { + if (file.size > SCENE_DOCUMENT_LIMITS.bytes) { + throw new SceneDocumentError('$', 'file exceeds 5 MiB'); + } const text = await file.text(); - if (this._destroyed) return; - const parsed = JSON.parse(text); - this._project = normalizeProject(parsed); - this._selectedSceneId = this._project.scenes[0]?.id || null; - this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; + if (this._destroyed || generation !== this._importGeneration) return; + const project = normalizeProject(parseSceneDocument(text)); + // Validate before touching playback, selection or the saved project. + this.stopScene('Importing project'); + await Promise.allSettled([...(this._pendingWork || [])]); + if (this._destroyed || generation !== this._importGeneration) return; + this._project = project; + this._storageReadError = null; + this._selectedSceneId = project.scenes[0]?.id || null; + this._selectedShotId = project.scenes[0]?.shots[0]?.id || null; + this._loadedSceneId = null; this._saveProject(); - this._publish({ type: 'project-imported', project: this._project }); + this._publish({ type: 'project-imported', project }); this._updateStatus(`Imported ${file.name}`); - } catch { - this._updateStatus('Import failed (invalid JSON)'); + } catch (error) { + if (this._destroyed || generation !== this._importGeneration) return; + this._updateStatus( + error instanceof SceneDocumentError + ? `Import failed: ${error.message}` + : 'Import failed (could not read JSON file)', + ); } } diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index def1b4969..b1be372f4 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -614,10 +614,11 @@ function installSceneRuntime(project = PROJECT_FIXTURE) { removeEventListener() {}, body: { classList: noopClassList, appendChild() {} }, }; + const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]); globalThis.localStorage = { - getItem: () => JSON.stringify(project), - setItem() {}, - removeItem() {}, + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value), + removeItem: (key) => stored.delete(key), }; return () => { @@ -1397,3 +1398,96 @@ test('Scene load outcomes exclude superseded and disposed completions', async () assert.equal(seen.length, count); } finally { restore(); } }); + +test('invalid and unsupported imports retain the current project, selection and saved bytes', async () => { + const { director, restore } = makeDirector(); + try { + const project = director._project; + const selected = director._selectedShotId; + const saved = localStorage.getItem('godsEyeView.sceneProject.v2'); + for (const value of ['{}', '{"version":99,"scenes":[]}', '{"scenes":[{"shots":"bad"}]}']) { + await director.importProjectFile({ name: 'bad.json', text: async () => value }); + assert.equal(director._project, project); + assert.equal(director._selectedShotId, selected); + assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved); + assert.match(director._presentation.status, /Import failed: \$/); + } + let read = false; + await director.importProjectFile({ size: 6 * 1024 * 1024, text: async () => { read = true; } }); + assert.equal(read, false); + assert.equal(director._project, project); + } finally { restore(); } +}); + +test('newer imports win delayed file reads, and an empty project is preserved', async () => { + const { director, restore } = makeDirector(); + try { + let release; + const older = director.importProjectFile({ name: 'old.json', text: () => new Promise((resolve) => { release = resolve; }) }); + await director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' }); + release(JSON.stringify(PROJECT_FIXTURE)); + await older; + assert.deepEqual(director._project.scenes, []); + assert.equal(director._selectedSceneId, null); + assert.deepEqual(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes, []); + } finally { restore(); } +}); + +test('unsupported stored documents cannot be overwritten by fallback edits', async () => { + const { director, restore } = makeDirector({ project: { version: 99, scenes: [] } }); + try { + const saved = localStorage.getItem('godsEyeView.sceneProject.v2'); + assert.ok(director._storageReadError); + director._project.scenes[0].title = 'Fallback edit'; + director._saveProject(); + assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved); + await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) }); + assert.equal(director._storageReadError, null); + assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 3); + } finally { restore(); } +}); + +test('valid import settles a cancelled load before replacing the project', async () => { + const { director, styleManager, restore } = makeDirector(); + let release; + styleManager.applyVisualState = () => new Promise((resolve) => { release = resolve; }); + try { + const before = director._project; + const load = director.loadShot('scene-1', 'shot-a'); + await settle(); + const importing = director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' }); + await settle(); + assert.equal(director._project, before, 'old work still owns cleanup'); + release(true); + await Promise.all([load, importing]); + assert.deepEqual(director._project.scenes, []); + assert.equal(director._pendingWork.size, 0); + assert.equal(director.getPlaybackTimingState().activeTimers, 0); + } finally { restore(); } +}); + +test('a delayed import cannot publish after disposal', async () => { + const { director, restore } = makeDirector(); + let release; + try { + const before = director._project; + const reading = director.importProjectFile({ name: 'empty.json', text: () => new Promise((resolve) => { release = resolve; }) }); + await director.destroy(); + release('{"version":3,"scenes":[]}'); + await reading; + assert.equal(director._project, before); + } finally { restore(); } +}); + +test('invalid authored edits cannot persist an unreadable project over a good save', () => { + const { director, restore } = makeDirector(); + try { + const before = localStorage.getItem('godsEyeView.sceneProject.v2'); + let notice; + director._toastStorageError = (message) => { notice = message; }; + director._project.scenes[0].shots[0].camera.lat = 91; + director._saveProject(); + assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before); + assert.match(notice, /camera.lat/); + } finally { restore(); } +}); diff --git a/src/scenes/project.js b/src/scenes/project.js new file mode 100644 index 000000000..c71517ae4 --- /dev/null +++ b/src/scenes/project.js @@ -0,0 +1,383 @@ +/** Authored project defaults and legacy migrations; no editor or playback state. */ +import { SCENE_DOCUMENT_VERSION } from '../director/document.js'; +import { SCENE_RECIPES } from './recipes.js'; +import { MAP_STACKS } from '../maps/catalog.js'; +import { + BLOOM_INTENSITY_DEFAULT, + BLOOM_SCALE_VERSION, + decodeBloomIntensity, +} from '../bloom.js'; + +/** @constant {number} Current schema version for project migration */ +export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION; +/** @constant {number} Fallback camera flight duration per shot (seconds) */ +export const DEFAULT_SHOT_DURATION_SEC = 4; +/** @constant {number} Default hold/pause after a shot completes (seconds) */ +export const DEFAULT_HOLD_SEC = 0.9; +const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id)); + +/** + * Generate a short random identifier with the given prefix. + * @param {string} prefix - e.g. 'shot', 'scene' + * @returns {string} Identifier like "shot-a1b2c3d4" + */ +export function uid(prefix) { + return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Deep-clone a JSON-serializable value via round-trip stringify/parse. + * @param {*} value + * @returns {*} + */ +export function deepClone(value) { + return JSON.parse(JSON.stringify(value)); +} + +/** + * Normalize a raw layer state entry into a canonical { enabled, params? } shape. + * Accepts both boolean shorthand and full object forms. + * @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params }) + * @returns {{ enabled: boolean, params?: Object }} + */ +export function normalizeLayerEntry(entry) { + if (entry && typeof entry === 'object') { + return { + enabled: !!entry.enabled, + params: + entry.params && typeof entry.params === 'object' + ? deepClone(entry.params) + : undefined, + }; + } + return { enabled: !!entry }; +} + +/** + * Normalize a raw bloom post-processing state, migrating intensity values + * across bloom scale versions so older saved projects render correctly. + * @param {Object} rawBloom - Raw bloom state from storage or recipe + * @param {Object} [options] + * @param {number} [options.projectVersion] - Schema version of the source project + * @param {number} [options.fallbackIntensity] - Default intensity if not stored + * @returns {{ enabled: boolean, intensity: number, version: number }} + */ +function normalizeBloomState( + rawBloom = {}, + { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}, +) { + // Determine which bloom scale the stored value was encoded under. + // Older projects (version < PROJECT_VERSION) used scale version 1. + const explicitVersion = Number(rawBloom.version); + const bloomVersion = Number.isFinite(explicitVersion) + ? explicitVersion + : projectVersion >= PROJECT_VERSION + ? BLOOM_SCALE_VERSION + : 1; + + const rawIntensity = Number.isFinite(Number(rawBloom.intensity)) + ? Number(rawBloom.intensity) + : fallbackIntensity; + + return { + enabled: !!rawBloom.enabled, + intensity: decodeBloomIntensity(rawIntensity, bloomVersion), + version: BLOOM_SCALE_VERSION, + }; +} + +/** + * Convert a static scene recipe (from recipes.js) into a mutable scene object + * with fully normalized shots. Each keyframe in the recipe's cameraPath becomes + * one shot, inheriting the recipe's style, post, and layer configuration. + * @param {Object} recipe - A SCENE_RECIPES entry + * @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }} + */ +export function recipeToScene(recipe) { + const post = recipe.post || {}; + const ui = recipe.ui || {}; + const styleParams = + post.styleParams && typeof post.styleParams === 'object' + ? deepClone(post.styleParams) + : {}; + + // Normalize layer targets from the recipe into canonical form + const layers = {}; + for (const [layerId, target] of Object.entries(recipe.layers || {})) { + layers[layerId] = normalizeLayerEntry(target); + } + + // Derive HUD visibility/variant from recipe's ui.hudMode string + const hudMode = ui.hudMode || 'minimal'; + const hudVisible = hudMode !== 'off'; + const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical'; + + // Convert each cameraPath keyframe into a shot with shared visual state + const path = recipe.cameraPath || []; + const shots = path.map((keyframe, idx) => { + const keyframeLayers = deepClone(layers); + for (const [layerId, target] of Object.entries(keyframe.layers || {})) { + keyframeLayers[layerId] = normalizeLayerEntry(target); + } + const mapStack = keyframe.mapStack || post.mapStack; + return { + id: uid('shot'), + title: keyframe.title || `Shot ${idx + 1}`, + durationSec: Math.max( + 0.2, + keyframe.duration || DEFAULT_SHOT_DURATION_SEC, + ), + holdSec: Math.max(0, keyframe.hold || 0), + camera: { + lat: keyframe.lat, + lon: keyframe.lon, + alt: keyframe.alt, + heading: keyframe.heading || 0, + pitch: keyframe.pitch || -40, + roll: keyframe.roll || 0, + }, + visual: { + style: recipe.style || 'normal', + bloom: { + enabled: + typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, + intensity: + typeof post.bloom === 'number' + ? decodeBloomIntensity(post.bloom, 1) + : BLOOM_INTENSITY_DEFAULT, + version: BLOOM_SCALE_VERSION, + }, + sharpen: { + enabled: + typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, + intensity: 65, + }, + hud: { + visible: hudVisible, + variant: hudVariant, + }, + detection: { + mode: post.detectionMode || 'OFF', + density: 35, + }, + ...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}), + styleParams, + }, + layers: keyframeLayers, + ...(recipe.id ? { sourcePackId: recipe.id } : {}), + ...(recipe.version ? { sourcePackVersion: recipe.version } : {}), + }; + }); + + return { + id: recipe.id || uid('scene'), + title: recipe.title || 'Untitled Scene', + releaseLayerIds: [ + ...new Set( + (Array.isArray(recipe.releaseLayerIds) + ? recipe.releaseLayerIds + : [] + ).filter((layerId) => typeof layerId === 'string' && layerId.trim()), + ), + ], + appliedShotPacks: [], + shots, + }; +} + +/** + * Create a fresh default project by converting all built-in SCENE_RECIPES. + * @returns {Object} A new project object with version, timestamps, and scenes + */ +export function createDefaultProject() { + return { + version: PROJECT_VERSION, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + installedBuiltInSceneIds: SCENE_RECIPES.filter( + (recipe) => typeof recipe.installAlongsideSceneId === 'string', + ).map((recipe) => recipe.id), + scenes: SCENE_RECIPES.map(recipeToScene), + }; +} + +/** + * Normalize a raw shot object from storage or capture into a fully validated + * shape with safe defaults for every field. Handles version migration for + * bloom intensity and coerces all numeric fields. + * @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema) + * @param {number} [index=0] - Positional index used for fallback title + * @param {Object} [options] + * @param {number} [options.projectVersion] - Schema version of the enclosing project + * @returns {Object} Fully normalized shot + */ +export function normalizeShot( + rawShot, + index = 0, + { projectVersion = PROJECT_VERSION } = {}, +) { + const camera = rawShot?.camera || {}; + const visual = rawShot?.visual || {}; + const bloom = visual.bloom || {}; + const sharpen = visual.sharpen || {}; + const hud = visual.hud || {}; + const detection = visual.detection || {}; + + return { + id: rawShot?.id || uid('shot'), + title: rawShot?.title || `Shot ${index + 1}`, + durationSec: Math.max( + 0.2, + Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC, + ), + holdSec: Math.max(0, Number(rawShot?.holdSec) || 0), + camera: { + lat: Number(camera.lat) || 0, + lon: Number(camera.lon) || 0, + alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800, + heading: Number(camera.heading) || 0, + pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35, + roll: Number(camera.roll) || 0, + }, + visual: { + style: visual.style || 'normal', + bloom: normalizeBloomState(bloom, { + projectVersion, + fallbackIntensity: 50, + }), + sharpen: { + enabled: !!sharpen.enabled, + intensity: Math.max( + 0, + Math.min( + 100, + Number.isFinite(Number(sharpen.intensity)) + ? Number(sharpen.intensity) + : 65, + ), + ), + }, + hud: { + visible: typeof hud.visible === 'boolean' ? hud.visible : true, + variant: typeof hud.variant === 'string' ? hud.variant : 'tactical', + }, + detection: { + ...(typeof detection.allocation === 'string' + ? { allocation: detection.allocation } + : {}), + ...(Number.isFinite(Number(detection.fadePct)) + ? { fadePct: Number(detection.fadePct) } + : {}), + ...(Number.isFinite(Number(detection.outsideOpacityPct)) + ? { outsideOpacityPct: Number(detection.outsideOpacityPct) } + : {}), + mode: typeof detection.mode === 'string' ? detection.mode : 'OFF', + density: Math.max( + 0, + Math.min( + 100, + Number.isFinite(Number(detection.density)) + ? Number(detection.density) + : 35, + ), + ), + }, + ...(visual.scope && typeof visual.scope === 'object' + ? { scope: deepClone(visual.scope) } + : {}), + ...(SCENE_MAP_STACK_IDS.has(visual.mapStack) + ? { mapStack: visual.mapStack } + : {}), + styleParams: + visual.styleParams && typeof visual.styleParams === 'object' + ? deepClone(visual.styleParams) + : {}, + }, + layers: Object.fromEntries( + Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [ + layerId, + normalizeLayerEntry(value), + ]), + ), + ...(typeof rawShot?.sourcePackId === 'string' + ? { sourcePackId: rawShot.sourcePackId } + : {}), + ...(Number.isFinite(Number(rawShot?.sourcePackVersion)) + ? { sourcePackVersion: Number(rawShot.sourcePackVersion) } + : {}), + }; +} + +/** + * Normalize and migrate an entire project object loaded from storage or import. + * Defaults and migrates validated documents; empty projects remain empty. + * @param {Object|null} rawProject - Raw project data (potentially from an older schema) + * @returns {Object} Fully normalized project at the current PROJECT_VERSION + */ +export function normalizeProject(rawProject) { + if (!rawProject || typeof rawProject !== 'object') + return createDefaultProject(); + const projectVersion = Number.isFinite(Number(rawProject.version)) + ? Number(rawProject.version) + : 1; + + const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : []; + const scenes = scenesRaw + .map((scene, sceneIdx) => { + const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : []; + const shots = shotsRaw.map((shot, shotIdx) => + normalizeShot(shot, shotIdx, { projectVersion }), + ); + return { + id: scene?.id || uid('scene'), + title: scene?.title || `Scene ${sceneIdx + 1}`, + releaseLayerIds: [ + ...new Set( + (Array.isArray(scene?.releaseLayerIds) + ? scene.releaseLayerIds + : [] + ).filter( + (layerId) => typeof layerId === 'string' && layerId.trim(), + ), + ), + ], + appliedShotPacks: (Array.isArray(scene?.appliedShotPacks) + ? scene.appliedShotPacks + : [] + ) + .filter((entry) => typeof entry?.id === 'string') + .map((entry) => ({ + id: entry.id, + version: Number(entry.version) || 1, + ...(entry.shotBindings && typeof entry.shotBindings === 'object' + ? { + shotBindings: Object.fromEntries( + Object.entries(entry.shotBindings).filter( + ([title, shotId]) => + typeof title === 'string' && typeof shotId === 'string', + ), + ), + } + : {}), + })), + shots, + }; + }) + // Keep scenes that have shots or at least a title + .filter((scene) => scene.shots.length > 0 || scene.title); + + return { + version: PROJECT_VERSION, + createdAt: rawProject.createdAt || new Date().toISOString(), + updatedAt: rawProject.updatedAt || new Date().toISOString(), + installedBuiltInSceneIds: [ + ...new Set( + (Array.isArray(rawProject.installedBuiltInSceneIds) + ? rawProject.installedBuiltInSceneIds + : [] + ).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()), + ), + ], + scenes, + }; +} From 13c0b8d1bc80f03ad8f18d8b754c4535624917c6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 16:53:26 -0700 Subject: [PATCH 3/5] feat(director): add authored camera directions and anchors --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 9 +- docs/DIRECTOR-CAMERA.md | 109 ++++++++++++++++ docs/DIRECTOR.md | 13 +- docs/SCENE-DOCUMENT.md | 29 +++-- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 23 +++- scripts/qa-director-camera.mjs | 210 +++++++++++++++++++++++++++++++ src/director/camera.js | 54 ++++++++ src/director/camera.test.mjs | 153 ++++++++++++++++++++++ src/director/cameraDocument.js | 88 +++++++++++++ src/director/document.js | 131 ++++--------------- src/director/document.test.mjs | 2 +- src/director/documentFields.js | 94 ++++++++++++++ src/director/playback.js | 6 + src/director/timeline.js | 17 ++- src/scenes/cameraMotion.js | 70 +++++++++++ src/scenes/cameraMotion.test.mjs | 85 +++++++++++++ src/scenes/director.js | 86 ++++++++++--- src/scenes/director.test.mjs | 28 ++++- src/scenes/playbackAdapter.js | 2 +- src/scenes/project.js | 36 ++++-- src/ui/navigationController.js | 11 ++ src/ui/shellFacade.js | 5 + 24 files changed, 1104 insertions(+), 167 deletions(-) create mode 100644 docs/DIRECTOR-CAMERA.md create mode 100644 scripts/qa-director-camera.mjs create mode 100644 src/director/camera.js create mode 100644 src/director/camera.test.mjs create mode 100644 src/director/cameraDocument.js create mode 100644 src/director/documentFields.js create mode 100644 src/scenes/cameraMotion.js create mode 100644 src/scenes/cameraMotion.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f58c06b5b..c878c42c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Director version 4 adds named camera anchors and explicit pose-to-pose moves + with shared playback/seek interpolation, easing and holds. Navigation and + manual input cancel authored motion; older scene files retain existing flights. + + - Director validates bounded version-3 scene files before replacing a project, preserves unreadable browser saves, migrates legacy bloom once and preserves zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 48ab18fe8..9dcbd379d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Director version-4 documents add named geographic anchors and optional explicit +camera moves with easing, duration and holds. Playback and seek share one +coordinate sampler; navigation/manual input revokes authored motion and pending +holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid +reference. Legacy projects retain their ordinary flights, content and edits. +See [camera directions](DIRECTOR-CAMERA.md). + Realtime voice composes separate connection, response/tool, Radio handoff, input and audio-meter, cost, viewport-context and diagnostic owners. The existing controller exports and session/backend contracts remain available. Tool protocol, @@ -27,7 +34,7 @@ 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. Scene-document validation and legacy migration now have separate owners. Invalid imports preserve the current project; unreadable saved projects -are protected from fallback writes. Version 3 remains the export format, with +are protected from fallback writes. Version 4 is the export format, with zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); data packs and interactions remain planned in [Director](DIRECTOR.md). diff --git a/docs/DIRECTOR-CAMERA.md b/docs/DIRECTOR-CAMERA.md new file mode 100644 index 000000000..2145c699d --- /dev/null +++ b/docs/DIRECTOR-CAMERA.md @@ -0,0 +1,109 @@ +# Director camera directions + +Version 4 scene documents can name geographic anchors and describe an explicit +camera move followed by a hold. Existing camera-pose shots still use the ordinary +camera flight. Import, playback, Load, Replay and seek accept the new directions; +a visual anchor/path editor is a later authoring step. + +```json +{ + "version": 4, + "scenes": [{ + "id": "austin", + "title": "Across Austin", + "anchors": [ + { "id": "west", "title": "West viewpoint", "lat": 30.2672, + "lon": -97.76, "alt": 6000, "altitudeReference": "ellipsoid" }, + { "id": "east", "title": "East viewpoint", "lat": 30.2672, + "lon": -97.73, "alt": 7000, "altitudeReference": "ellipsoid" } + ], + "shots": [{ + "id": "crossing", + "title": "Cross the city", + "durationSec": 4, + "holdSec": 2, + "camera": { "anchorId": "east", "heading": 10, "pitch": -45, "roll": 0 }, + "move": { + "from": { "anchorId": "west", "heading": 350, "pitch": -45, "roll": 0 }, + "easing": "cubic-in-out" + }, + "visual": { "style": "normal" }, + "layers": {} + }] + }] +} +``` + +## One owner for each authored value + +An anchor is a scene-local geographic position, with a stable `id`, optional +`title`, latitude, longitude, height and explicit altitude reference. Each scene +accepts at most 1,024 anchors. IDs must be unique within that scene. References to +missing anchors fail validation before replacing a project. + +The shot's `camera` is its destination. It can be an inline pose or an +`anchorId` plus orientation. The optional `move.from` uses the same pose/reference +shape. References cannot also carry inline coordinates. They do not form chains. +Heading, pitch and roll belong to each camera pose, so two shots can look in +different directions from the same geographic anchor. Omitted orientation uses +heading 0, pitch −35 and roll 0 degrees. + +An explicit move requires `move.from`, `move.easing`, `durationSec` and `holdSec`. +Its duration is 0.2–86,400 seconds; the hold is 0–86,400 seconds. The destination +is not copied into another field. `linear` and `cubic-in-out` are the supported +easings. A shot without `move` retains the existing ordinary flight, even in a +version-4 file. Its destination may also reference an anchor. + +Capture continues to create ordinary camera-pose shots. Update Shot stores the +current viewport as an inline destination, preserving an explicit move's start +and easing; it does not change a shared anchor and thereby move other shots. +Editing imported directions and anchors currently means editing the scene JSON. + +## Coordinates and altitude + +Latitude/longitude use WGS84 degrees. Orientation uses the application's Cesium +heading/pitch/roll convention in degrees: heading rotates from local north, +negative pitch looks down, and zero pitch is horizontal. Camera positions use +meters above the WGS84 ellipsoid, matching `Cartesian3.fromDegrees` and captured +camera poses. These heights are not mean-sea-level elevations or heights above +the visible surface. + +Every anchor and every inline endpoint of an explicit move must say +`"altitudeReference": "ellipsoid"`. Ordinary legacy poses without that field +already use the same reference. Terrain-relative and mean-sea-level references +are rejected; no terrain request, default ground estimate or implicit conversion +is made. Authors must provide ellipsoidal heights with enough clearance for their +scene. The format does not promise collision avoidance or terrain following. + +During an explicit move, latitude and altitude interpolate between endpoints. +Longitude takes the shortest signed arc across the dateline; heading and roll +also take their shortest signed arcs. An exact 180-degree tie takes the negative +arc. This is an authored coordinate interpolation, not a geodesic or a camera +flight with an automatically raised arc. Endpoints are exact, including zero +pitch. Curved paths and look-at targets are later extensions. + +## Time, seeking and cancellation + +Load and Replay of an explicit move start at its authored `from` pose, regardless +of the current viewport. Play uses that same start. Both live playback and seek +use `sampleCameraMove`; a seek into the hold uses the exact destination. Backward +seeks do not depend on a prior camera position. Existing scene-pack media/reveal +rules can extend holds as before. + +Each active explicit move owns one animation-frame callback. Stop, replacement, +import, teardown, a navigation handoff or manual pointer/wheel input revokes that +callback and settles the move. Navigation also cancels a run during an authored +hold, so the next shot cannot retake the camera. Camera claims still respect +Cockpit's refusal. Already queued callbacks cannot move a replacement scene. +The timing diagnostic includes the active authored camera animation. + +The renderer-independent `gods-eye-view/director` export provides +`resolveCameraPose(scene, camera)`, `resolveCameraMove(scene, shot)` and +`sampleCameraMove(move, progress)`. These expect validated/normalized data. +Rendering and frame ownership live in `src/scenes/cameraMotion.js`; the existing +scene controller supplies camera application and lifecycle cancellation. + +Version-1/2/3 projects migrate to version 4 without converting their ordinary +shots into explicit moves. IDs, positions, durations, visual settings, packs and +source attribution survive. The bloom scale remains independent of the document +version; version-3 bloom is never interpreted as the older inverted scale. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 164ad7f2f..0aefbf0e3 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -67,16 +67,17 @@ and river coordinates are bundled. See the existing recipes and content. Tests cover direct load, forward/backward seek, replay, bounded media waits and cancellation at pending transitions. 2. **Versioned scene document — implemented for existing camera-pose shots.** - [Version 3](SCENE-DOCUMENT.md) remains the write format. Bounded validation + [The scene document](SCENE-DOCUMENT.md) preserves version-1/2/3 imports. Bounded validation rejects malformed files before replacing current state; legacy migration preserves IDs, pack bindings and visual edits. Unreadable saved files are protected from fallback writes. Camera anchors, pack manifests and actions receive stable IDs with their respective later format extensions. -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. +3. **Camera directions — implemented for pose-to-pose moves.** + [Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit + start/destination poses, easing, duration and holds. Playback and seek share + the same sampler. Ellipsoidal heights are explicit; terrain-relative input + is rejected. Existing shots retain ordinary flights. Curved paths, look-at + targets and visual authoring are later extensions. 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 diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md index 985742d1b..35737156e 100644 --- a/docs/SCENE-DOCUMENT.md +++ b/docs/SCENE-DOCUMENT.md @@ -1,13 +1,13 @@ -# Scene document version 3 +# Scene document format -Scenes exports a UTF-8 JSON project. Version 3 remains the write format; existing -version-3 exports need no conversion. Versions 1, 2 and unversioned legacy files -are migrated on import. Unknown versions or unsupported fields are rejected with +Scenes exports a UTF-8 JSON project. Version 4 adds [camera directions](DIRECTOR-CAMERA.md) +and is the write format. Versions 1, 2, 3 and unversioned legacy files migrate on +import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with a field path before the current project, selection or saved bytes change. ```json { - "version": 3, + "version": 4, "scenes": [{ "id": "my-scene", "title": "My scene", @@ -30,21 +30,21 @@ a field path before the current project, selection or saved bytes change. The root requires `scenes` (an array, which may be empty). Optional root fields are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs). Each scene requires a `shots` array; optional fields are `id`, `title`, -`releaseLayerIds` and `appliedShotPacks`. A pack marker has `id`, optional numeric +`releaseLayerIds`, `appliedShotPacks` and version-4 `anchors`. A pack marker has `id`, optional numeric `version` and `shotBindings` (original shot title to saved shot ID). These markers retain the editor's existing pack-update and rename behavior. Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`, -`sourcePackId` and `sourcePackVersion`. Scene IDs are unique within the project; +`sourcePackId`, `sourcePackVersion` and version-4 `move`. Scene IDs are unique within the project; shot IDs are unique within their scene. Existing IDs and pack bindings survive migration. Missing IDs are generated once and persisted by the next save/export. Missing titles and optional camera/visual fields receive the editor's defaults. Empty scenes and intentionally empty projects remain empty. -Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses the -existing camera-pose height in meters. Import preserves finite heights below -100 meters and zero pitch, which previously fell through to defaults. Explicit -altitude-reference and terrain-relative directions are a subsequent format step. +Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses +meters above the WGS84 ellipsoid. Import preserves finite heights below +100 meters and zero pitch, which previously fell through to defaults. Anchors and explicit moves require the `ellipsoid` altitude reference; +terrain-relative inputs are rejected. See the camera contract for exact semantics. Flight durations retain the editor's minimum of 0.2 seconds and its 4-second fallback for zero; holds may be zero. @@ -95,8 +95,7 @@ can preserve temporary edits. Storage writes use the same validation before repl edits and storage write failures surface an unsaved toast. Only authored project fields belong in this file. Runtime clocks, camera flights, -loading progress and run diagnostics remain separate. Camera anchors, asset-pack -manifests and declarative interactions are planned extensions, not accepted fields -in version 3. They will receive stable IDs and their own validated migration when -introduced. No scene assets, source links, licenses or contributor attribution +loading progress and run diagnostics remain separate. Version 4 supports scene-local camera anchors and authored moves. Asset-pack +manifests and declarative interactions remain planned extensions. They will +receive stable IDs and their own validated migration when introduced. No scene assets, source links, licenses or contributor attribution change with this document boundary. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f334c4e46..b84602e3f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -733,5 +733,8 @@ "src/director/timeline.test.mjs", "src/scenes/packs/registry.test.mjs", "scripts/qa-director-timing.mjs", - "src/director/document.test.mjs" + "src/director/document.test.mjs", + "src/director/camera.test.mjs", + "src/scenes/cameraMotion.test.mjs", + "scripts/qa-director-camera.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9df4d9135..736800daf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -618,7 +618,10 @@ "src/director/playback.js", "src/director/clock.js", "src/director/timeline.js", - "src/director/document.js" + "src/director/document.js", + "src/director/documentFields.js", + "src/director/cameraDocument.js", + "src/director/camera.js" ], "external": [] }, @@ -648,7 +651,11 @@ "src/scenes/packs/defaults.js", "src/scenes/packs/nepal.js", "src/director/document.js", - "src/scenes/project.js" + "src/scenes/project.js", + "src/director/documentFields.js", + "src/director/cameraDocument.js", + "src/director/camera.js", + "src/scenes/cameraMotion.js" ], "external": ["cesium"] }, @@ -1755,7 +1762,11 @@ "src/scenes/packs/defaults.js", "src/scenes/packs/nepal.js", "src/director/document.js", - "src/scenes/project.js" + "src/scenes/project.js", + "src/director/documentFields.js", + "src/director/cameraDocument.js", + "src/director/camera.js", + "src/scenes/cameraMotion.js" ], "external": [ "@mapbox/vector-tile", @@ -2220,7 +2231,11 @@ }, "ui-navigation": { "exports": ["./ui/navigation"], - "modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"], + "modules": [ + "src/navigationPolicy.js", + "src/ui/navigationController.js", + "src/app/stateChannel.js" + ], "external": ["cesium"] }, "ui-share-restoration": { diff --git a/scripts/qa-director-camera.mjs b/scripts/qa-director-camera.mjs new file mode 100644 index 000000000..62f0af5ff --- /dev/null +++ b/scripts/qa-director-camera.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** Verify imported camera directions through real rendering and camera authority handoffs. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const output = path.resolve('qa-shots/director-camera'); +fs.mkdirSync(output, { 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 failed = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failed++; +} +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 { sceneDirector: d, styleManager: style } = window.__godsEyeView; + const file = { + version: 4, + scenes: [ + { + id: 'directions', + title: 'Camera directions', + anchors: [ + { + id: 'start', + title: 'Austin west', + lat: 30.2672, + lon: -97.76, + alt: 6000, + altitudeReference: 'ellipsoid', + }, + { + id: 'end', + title: 'Austin east', + lat: 30.2672, + lon: -97.73, + alt: 7000, + altitudeReference: 'ellipsoid', + }, + ], + shots: [ + { + id: 'move', + title: 'Across Austin', + durationSec: 1, + holdSec: 0.2, + camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 }, + move: { + from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 }, + easing: 'linear', + }, + layers: {}, + visual: { style: 'normal' }, + }, + ], + }, + ], + }; + await d.importProjectFile( + new File([JSON.stringify(file)], 'directions.json'), + ); + const scene = d._project.scenes[0]; + const shot = scene.shots[0]; + const imported = + scene.anchors.length === 2 && shot.camera.anchorId === 'end'; + await d.seekScene(scene.id, 0.5 / 1.2); + const middle = style.getCameraState(); + const seekMidpoint = + Math.abs(middle.lon - -97.745) < 1e-6 && + Math.abs(middle.alt - 6500) < 0.01; + await d.seekScene(scene.id, 0); + const back = style.getCameraState(); + const backward = Math.abs(back.lon - -97.76) < 1e-6; + const replay = await d.replayShot(scene.id, shot.id); + const end = style.getCameraState(); + const replayEnd = + replay?.started && + Math.abs(end.lon - -97.73) < 1e-6 && + Math.abs(end.alt - 7000) < 0.01; + d.stopScene(); + await d.startScene(scene.id, { single: true, preview: false }); + const completed = + !d.running && d.getPlaybackTimingState().activeTimers === 0; + const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6; + // Updating the target creates an explicit inline pose while retaining the authored start. + d.updateSelectedShot(); + const saved = JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + const updated = + saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' && + saved.scenes[0].shots[0].move.from.anchorId === 'start'; + shot.durationSec = 30; + const run = d.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while (!d._cameraMotion.active && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 20)); + const started = d._cameraMotion.active; + style.runImmediateNavigation('location', () => true); + await run; + const revoked = + started && + !d.running && + !d._cameraMotion.active && + d.getPlaybackTimingState().activeTimers === 0; + const stoppedPose = style.getCameraState(); + await new Promise((r) => setTimeout(r, 150)); + const stable = + Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7; + return { + imported, + seekMidpoint, + backward, + replayEnd, + completed, + settled, + updated, + revoked, + stable, + }; + }); + for (const [name, passed] of Object.entries(result)) check(name, passed); + await new Promise((r) => setTimeout(r, 6000)); + await page.screenshot({ path: path.join(output, 'austin.png') }); + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector; + const pose = d.styleManager.getCameraState(); + d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 }); + d.viewer.scene.requestRender(); + }); + await new Promise((r) => setTimeout(r, 3000)); + await page.screenshot({ path: path.join(output, 'angle.png') }); + const inputStarted = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes[0]; + window.__qaCameraRun = d.startScene(scene.id, { + single: true, + preview: false, + }); + const deadline = Date.now() + 10000; + while (!d._cameraMotion.active && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 20)); + return d._cameraMotion.active; + }); + const canvas = + (await page.$('canvas.cesium-widget-canvas')) || + (await page.$('.cesium-widget canvas')); + const rect = await canvas.boundingBox(); + await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); + await page.mouse.down(); + await page.mouse.up(); + const input = + inputStarted && + (await page.evaluate(async () => { + await window.__qaCameraRun; + const d = window.__godsEyeView.sceneDirector; + return !d.running && d.getPlaybackTimingState().activeTimers === 0; + })); + check('manual canvas input revokes the move and releases clocks', input); + const hold = await page.evaluate(async () => { + const { sceneDirector: d, styleManager: style } = window.__godsEyeView; + const scene = d._project.scenes[0]; + const shot = scene.shots[0]; + shot.durationSec = 0.2; + shot.holdSec = 30; + const run = d.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while ( + (!d._activeSceneTravel || !d.running || d._cameraMotion.active) && + Date.now() < deadline + ) + await new Promise((r) => setTimeout(r, 20)); + const holding = d.running && !d._cameraMotion.active; + const began = performance.now(); + style.runImmediateNavigation('location', () => true); + await run; + return ( + holding && + performance.now() - began < 1000 && + d.getPlaybackTimingState().activeTimers === 0 + ); + }); + check('navigation during an authored hold settles immediately', hold); + + if (errors.length) + console.error( + errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +if (failed) process.exitCode = 1; diff --git a/src/director/camera.js b/src/director/camera.js new file mode 100644 index 000000000..059c7a873 --- /dev/null +++ b/src/director/camera.js @@ -0,0 +1,54 @@ +/** Portable camera poses and authored motion. Angles are degrees; heights are ellipsoidal meters. */ + +/** Resolve an inline pose or a scene-local geographic anchor without modifying authored state. */ +export function resolveCameraPose(scene, camera) { + if (!camera) return null; + const position = camera.anchorId + ? scene?.anchors?.find(({ id }) => id === camera.anchorId) + : camera; + if (!position) throw new Error('Camera anchor is unavailable'); + return { + lat: position.lat, + lon: position.lon, + alt: position.alt, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? -35, + roll: camera.roll ?? 0, + }; +} + +/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */ +export function resolveCameraMove(scene, shot) { + if (!shot?.move) return null; + return { + from: resolveCameraPose(scene, shot.move.from), + to: resolveCameraPose(scene, shot.camera), + easing: shot.move.easing, + durationSec: shot.durationSec, + }; +} + +/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ +export function sampleCameraMove(move, progress) { + const t = Math.max(0, Math.min(1, Number(progress) || 0)); + if (t === 0) return { ...move.from }; + if (t === 1) return { ...move.to }; + const eased = + move.easing === 'linear' + ? t + : t < 0.5 + ? 4 * t ** 3 + : 1 - (-2 * t + 2) ** 3 / 2; + const lerp = (a, b) => a + (b - a) * eased; + const angle = (a, b) => + a + (((((b - a + 540) % 360) + 360) % 360) - 180) * eased; + const { from, to } = move; + return { + lat: lerp(from.lat, to.lat), + lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + alt: lerp(from.alt, to.alt), + heading: angle(from.heading, to.heading), + pitch: lerp(from.pitch, to.pitch), + roll: angle(from.roll, to.roll), + }; +} diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs new file mode 100644 index 000000000..54a613d0f --- /dev/null +++ b/src/director/camera.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSceneDocument, stringifySceneDocument } from './document.js'; +import { normalizeProject } from '../scenes/project.js'; +import { resolveCameraMove, sampleCameraMove } from './camera.js'; +import { sceneSeekState } from './timeline.js'; + +const fixture = () => ({ + version: 4, + scenes: [ + { + id: 'scene', + title: 'Cross the dateline', + anchors: [ + { + id: 'west', + title: 'Start', + lat: 10, + lon: 179, + alt: 400, + altitudeReference: 'ellipsoid', + }, + { + id: 'east', + title: 'End', + lat: 12, + lon: -179, + alt: 800, + altitudeReference: 'ellipsoid', + }, + ], + shots: [ + { + id: 'move', + title: 'Cross', + durationSec: 4, + holdSec: 2, + camera: { anchorId: 'east', heading: 10, pitch: 0, roll: 0 }, + move: { + from: { anchorId: 'west', heading: 350, pitch: -40, roll: 0 }, + easing: 'linear', + }, + }, + ], + }, + ], +}); +test('version 4 retains anchor identity, references and explicit move edits through normalization', () => { + const raw = fixture(); + const project = normalizeProject(parseSceneDocument(JSON.stringify(raw))); + assert.deepEqual(project.scenes[0].anchors, raw.scenes[0].anchors); + assert.deepEqual( + project.scenes[0].shots[0].move, + raw.scenes[0].shots[0].move, + ); + assert.deepEqual( + project.scenes[0].shots[0].camera, + raw.scenes[0].shots[0].camera, + ); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(project))), + project, + ); +}); +test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4', () => { + const project = normalizeProject( + parseSceneDocument( + JSON.stringify({ + version: 3, + scenes: [ + { shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] }, + ], + }), + ), + ); + assert.equal(project.version, 4); + assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50); +}); +test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => { + const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture()))) + .scenes[0]; + const move = resolveCameraMove(scene, scene.shots[0]); + const midpoint = sampleCameraMove(move, 0.5); + assert.equal(midpoint.lon, -180); + assert.equal(midpoint.heading, 360); + assert.equal(midpoint.lat, 11); + assert.equal(midpoint.alt, 600); + assert.equal(midpoint.pitch, -20); + const seek = (p) => + sceneSeekState( + scene, + p, + (_, shot) => shot.durationSec + shot.holdSec, + (_, shot) => shot.holdSec, + ); + assert.deepEqual(seek(1 / 3).camera, midpoint); + assert.deepEqual(seek(0).camera, move.from); + assert.deepEqual(seek(5 / 6).camera, move.to); + assert.equal(seek(5 / 6).holdElapsedSec, 1); + assert.deepEqual( + seek(1 / 3).camera, + midpoint, + 'backward seek is independent of previous viewport state', + ); + assert.equal( + sampleCameraMove({ ...move, easing: 'cubic-in-out' }, 0.25).lat, + 10.125, + ); + assert.deepEqual(sampleCameraMove(move, 1), move.to); +}); +test('unknown anchors, duplicate IDs, mixed references, bad easing and unspecified altitude are rejected', () => { + for (const mutate of [ + (s) => { + s.shots[0].camera.anchorId = 'missing'; + }, + (s) => { + s.anchors[1].id = 'west'; + }, + (s) => { + s.anchors[0].altitudeReference = 'terrain'; + }, + (s) => { + delete s.anchors[0].altitudeReference; + }, + (s) => { + s.shots[0].camera.lon = 1; + }, + (s) => { + s.shots[0].move.easing = 'bounce'; + }, + (s) => { + s.shots[0].move.from = { lat: 1, lon: 2, alt: 3 }; + }, + (s) => { + s.shots[0].durationSec = 0; + }, + (s) => { + delete s.shots[0].holdSec; + }, + ]) { + const project = fixture(); + mutate(project.scenes[0]); + assert.throws(() => parseSceneDocument(JSON.stringify(project)), { + name: 'SceneDocumentError', + }); + } + const old = fixture(); + old.version = 3; + assert.throws( + () => parseSceneDocument(JSON.stringify(old)), + /unsupported field/, + ); +}); diff --git a/src/director/cameraDocument.js b/src/director/cameraDocument.js new file mode 100644 index 000000000..1579d54a8 --- /dev/null +++ b/src/director/cameraDocument.js @@ -0,0 +1,88 @@ +import { + fields, + string, + number, + optional, + array, + fail, + uniqueId, +} from './documentFields.js'; + +const POSITION = { lat: [-90, 90], lon: [-180, 180], alt: [-12000, 1e9] }; +const ORIENTATION = { + heading: [-360, 360], + pitch: [-90, 90], + roll: [-360, 360], +}; +function coordinates(value, path, bounds, required, legacy) { + for (const [key, range] of Object.entries(bounds)) { + if (required || Object.hasOwn(value, key)) + number(value[key], `${path}.${key}`, ...range, legacy); + } +} +function reference(value, path) { + if (value !== 'ellipsoid') + fail( + path, + 'expected ellipsoid height in meters; terrain-relative heights are not supported', + ); +} +function pose(value, path, version, anchorIds, explicit = false) { + const anchored = version >= 4 && Object.hasOwn(value || {}, 'anchorId'); + fields(value, path, [ + ...Object.keys(ORIENTATION), + ...(anchored + ? ['anchorId'] + : [ + ...Object.keys(POSITION), + ...(version >= 4 ? ['altitudeReference'] : []), + ]), + ]); + coordinates(value, path, ORIENTATION, false, version < 3); + if (anchored) { + string(value.anchorId, `${path}.anchorId`); + if (!anchorIds.has(value.anchorId)) + fail(`${path}.anchorId`, 'unknown scene anchor'); + } else { + coordinates(value, path, POSITION, explicit, version < 3); + if (explicit || Object.hasOwn(value, 'altitudeReference')) + reference(value.altitudeReference, `${path}.altitudeReference`); + } +} + +/** Validate scene-local anchor IDs, camera references and explicit motion without reading terrain or applying state. */ +export function validateSceneCameras(scene, path, version) { + const anchorIds = new Set(); + optional(scene, 'anchors', path, (anchors, field) => { + array(anchors, field, 1024); + anchors.forEach((anchor, i) => { + const at = `${field}[${i}]`; + fields(anchor, at, [ + 'id', + 'title', + ...Object.keys(POSITION), + 'altitudeReference', + ]); + string(anchor.id, `${at}.id`); + uniqueId(anchor, at, anchorIds); + optional(anchor, 'title', at, (v, p) => string(v, p, 4096)); + coordinates(anchor, at, POSITION, true, false); + reference(anchor.altitudeReference, `${at}.altitudeReference`); + }); + }); + scene.shots.forEach((shot, index) => { + const at = `${path}.shots[${index}]`; + optional(shot, 'move', at, (move, field) => { + fields(move, field, ['from', 'easing']); + if (!['linear', 'cubic-in-out'].includes(move.easing)) + fail(`${field}.easing`, 'expected linear or cubic-in-out'); + pose(move.from, `${field}.from`, version, anchorIds, true); + pose(shot.camera, `${at}.camera`, version, anchorIds, true); + number(shot.durationSec, `${at}.durationSec`, 0.2, 86400, false); + number(shot.holdSec, `${at}.holdSec`, 0, 86400, false); + }); + optional(shot, 'camera', at, (camera, field) => + pose(camera, field, version, anchorIds), + ); + }); +} diff --git a/src/director/document.js b/src/director/document.js index d0ecdebb4..efa806ed8 100644 --- a/src/director/document.js +++ b/src/director/document.js @@ -1,98 +1,21 @@ /** Bounded, renderer-independent validation for authored scene documents. */ -export const SCENE_DOCUMENT_VERSION = 3; -export const SCENE_DOCUMENT_LIMITS = Object.freeze({ - bytes: 5 * 1024 * 1024, - scenes: 256, - shots: 10000, - collection: 10000, - depth: 24, - nodes: 200000, - string: 65536, -}); +import { + SCENE_DOCUMENT_LIMITS, + fail, + object, + fields, + string, + number, + optional, + array, + ids, + uniqueId, + jsonTree, +} from './documentFields.js'; +import { validateSceneCameras } from './cameraDocument.js'; +export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js'; +export const SCENE_DOCUMENT_VERSION = 4; -/** A project error identifies a field without echoing its supplied value. */ -export class SceneDocumentError extends Error { - constructor(path, reason) { - super(`${path}: ${reason}`); - this.name = 'SceneDocumentError'; - this.path = path; - } -} -const fail = (path, reason) => { - throw new SceneDocumentError(path, reason); -}; -const object = (value, path) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) - fail(path, 'expected an object'); -}; -function fields(value, path, allowed) { - object(value, path); - for (const key of Object.keys(value)) { - if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field'); - } -} -function string(value, path, max = 256) { - if (typeof value !== 'string' || !value.trim() || value.length > max) - fail(path, `expected nonempty text, at most ${max} characters`); -} -function number(value, path, min, max, legacy) { - const numeric = - legacy && typeof value === 'string' && value.trim() ? Number(value) : value; - if ( - typeof numeric !== 'number' || - !Number.isFinite(numeric) || - numeric < min || - numeric > max - ) - fail(path, `expected a number from ${min} to ${max}`); -} -function optional(value, key, path, check) { - if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`); -} -function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) { - if (!Array.isArray(value) || value.length > max) - fail(path, `expected an array of at most ${max} entries`); -} -function ids(value, path) { - array(value, path); - value.forEach((id, index) => string(id, `${path}[${index}]`)); -} -function uniqueId(value, path, seen) { - if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration. - string(value.id, `${path}.id`); - if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID'); - seen.add(value.id); -} -function jsonTree(value, path, budget, depth = 0) { - if ( - ++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes || - depth > SCENE_DOCUMENT_LIMITS.depth - ) - fail(path, 'project complexity limit exceeded'); - if (value === null || typeof value === 'boolean') return; - if (typeof value === 'string') { - if (value.length > SCENE_DOCUMENT_LIMITS.string) - fail(path, 'text is too long'); - return; - } - if (typeof value === 'number' && Number.isFinite(value)) return; - if (!value || typeof value !== 'object') fail(path, 'expected a JSON value'); - if ( - Object.getPrototypeOf(value) !== Object.prototype && - !Array.isArray(value) && - Object.getPrototypeOf(value) !== null - ) - fail(path, 'expected a JSON object'); - const entries = Object.entries(value); - if (entries.length > SCENE_DOCUMENT_LIMITS.collection) - fail(path, 'too many entries'); - for (const [key, child] of entries) { - if (['__proto__', 'constructor', 'prototype'].includes(key)) - fail(path, 'unsafe object key'); - if (key.length > 256) fail(path, 'field name is too long'); - jsonTree(child, `${path}.${key}`, budget, depth + 1); - } -} function visual(value, path, legacy) { fields(value, path, [ 'style', @@ -141,8 +64,8 @@ export function validateSceneDocument(project) { 'installedBuiltInSceneIds', 'scenes', ]); - const version = project.version ?? 1; - if (![1, 2, 3].includes(version)) + const version = Object.hasOwn(project, 'version') ? project.version : 1; + if (![1, 2, 3, 4].includes(version)) fail('$.version', 'unsupported scene project version'); const legacy = version < 3; for (const key of ['createdAt', 'updatedAt']) @@ -159,6 +82,7 @@ export function validateSceneDocument(project) { 'releaseLayerIds', 'appliedShotPacks', 'shots', + ...(version >= 4 ? ['anchors'] : []), ]); uniqueId(scene, path, sceneIds); optional(scene, 'title', path, (v, p) => string(v, p, 4096)); @@ -198,6 +122,7 @@ export function validateSceneDocument(project) { 'layers', 'sourcePackId', 'sourcePackVersion', + ...(version >= 4 ? ['move'] : []), ]); uniqueId(shot, at, shotIds); optional(shot, 'title', at, (v, p) => string(v, p, 4096)); @@ -209,21 +134,6 @@ export function validateSceneDocument(project) { number(v, p, 0, 86400, legacy), ); optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy)); - optional(shot, 'camera', at, (camera, field) => { - const bounds = { - lat: [-90, 90], - lon: [-180, 180], - alt: [-12000, 1e9], - heading: [-360, 360], - pitch: [-90, 90], - roll: [-360, 360], - }; - fields(camera, field, Object.keys(bounds)); - for (const [key, range] of Object.entries(bounds)) - optional(camera, key, field, (v, p) => - number(v, p, ...range, legacy), - ); - }); optional(shot, 'visual', at, (v, p) => visual(v, p, legacy)); optional(shot, 'layers', at, (layers, field) => { object(layers, field); @@ -238,6 +148,7 @@ export function validateSceneDocument(project) { } }); }); + validateSceneCameras(scene, path, version); }); return project; } diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs index d9a2f9ab4..692796921 100644 --- a/src/director/document.test.mjs +++ b/src/director/document.test.mjs @@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv const migrated = normalizeProject( parseSceneDocument(JSON.stringify(project)), ); - assert.equal(migrated.version, 3); + assert.equal(migrated.version, 4); assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150); assert.equal(migrated.scenes[0].shots[0].id, 'shot'); assert.deepEqual( diff --git a/src/director/documentFields.js b/src/director/documentFields.js new file mode 100644 index 000000000..681cc6482 --- /dev/null +++ b/src/director/documentFields.js @@ -0,0 +1,94 @@ +/** Shared bounded JSON and field checks for scene formats. */ +export const SCENE_DOCUMENT_LIMITS = Object.freeze({ + bytes: 5 * 1024 * 1024, + scenes: 256, + shots: 10000, + collection: 10000, + depth: 24, + nodes: 200000, + string: 65536, +}); + +/** A project error identifies a field without echoing its supplied value. */ +export class SceneDocumentError extends Error { + constructor(path, reason) { + super(`${path}: ${reason}`); + this.name = 'SceneDocumentError'; + this.path = path; + } +} +export const fail = (path, reason) => { + throw new SceneDocumentError(path, reason); +}; +export const object = (value, path) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(path, 'expected an object'); +}; +export function fields(value, path, allowed) { + object(value, path); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field'); + } +} +export function string(value, path, max = 256) { + if (typeof value !== 'string' || !value.trim() || value.length > max) + fail(path, `expected nonempty text, at most ${max} characters`); +} +export function number(value, path, min, max, legacy) { + const numeric = + legacy && typeof value === 'string' && value.trim() ? Number(value) : value; + if ( + typeof numeric !== 'number' || + !Number.isFinite(numeric) || + numeric < min || + numeric > max + ) + fail(path, `expected a number from ${min} to ${max}`); +} +export function optional(value, key, path, check) { + if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`); +} +export function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) { + if (!Array.isArray(value) || value.length > max) + fail(path, `expected an array of at most ${max} entries`); +} +export function ids(value, path) { + array(value, path); + value.forEach((id, index) => string(id, `${path}[${index}]`)); +} +export function uniqueId(value, path, seen) { + if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration. + string(value.id, `${path}.id`); + if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID'); + seen.add(value.id); +} +export function jsonTree(value, path, budget, depth = 0) { + if ( + ++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes || + depth > SCENE_DOCUMENT_LIMITS.depth + ) + fail(path, 'project complexity limit exceeded'); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.length > SCENE_DOCUMENT_LIMITS.string) + fail(path, 'text is too long'); + return; + } + if (typeof value === 'number' && Number.isFinite(value)) return; + if (!value || typeof value !== 'object') fail(path, 'expected a JSON value'); + if ( + Object.getPrototypeOf(value) !== Object.prototype && + !Array.isArray(value) && + Object.getPrototypeOf(value) !== null + ) + fail(path, 'expected a JSON object'); + const entries = Object.entries(value); + if (entries.length > SCENE_DOCUMENT_LIMITS.collection) + fail(path, 'too many entries'); + for (const [key, child] of entries) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) + fail(path, 'unsafe object key'); + if (key.length > 256) fail(path, 'field name is too long'); + jsonTree(child, `${path}.${key}`, budget, depth + 1); + } +} diff --git a/src/director/playback.js b/src/director/playback.js index ff5620ed5..5c0354fa4 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -112,3 +112,9 @@ export { SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_LIMITS, } from './document.js'; + +export { + resolveCameraPose, + resolveCameraMove, + sampleCameraMove, +} from './camera.js'; diff --git a/src/director/timeline.js b/src/director/timeline.js index 782b073f7..7fd4cbbae 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -1,3 +1,9 @@ +import { + resolveCameraPose, + resolveCameraMove, + sampleCameraMove, +} from './camera.js'; + const DEFAULT_SHOT_DURATION_SEC = 4; const clamp01 = (value) => Math.max(0, Math.min(1, value)); @@ -78,7 +84,12 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) { const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec); const holdProgress = holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1; - const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera; + const targetCamera = resolveCameraPose(scene, shot.camera); + const previousCamera = resolveCameraPose( + scene, + scene.shots[shotIndex - 1]?.camera || shot.camera, + ); + const move = resolveCameraMove(scene, shot); return { sceneProgress: totalSec > 0 ? targetSec / totalSec : 0, sceneElapsedSec: targetSec, @@ -92,6 +103,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) { holdElapsedSec, cameraProgress, holdProgress, - camera: cameraAtProgress(previousCamera, shot.camera, cameraProgress), + camera: move + ? sampleCameraMove(move, cameraProgress) + : cameraAtProgress(previousCamera, targetCamera, cameraProgress), }; } diff --git a/src/scenes/cameraMotion.js b/src/scenes/cameraMotion.js new file mode 100644 index 000000000..fd40e7bf3 --- /dev/null +++ b/src/scenes/cameraMotion.js @@ -0,0 +1,70 @@ +import { sampleCameraMove } from '../director/camera.js'; + +/** Own one authored camera animation and settle it immediately on cancellation or replacement. */ +export function createCameraMotion({ + applyPose, + now = () => performance.now(), + requestFrame = (callback) => requestAnimationFrame(callback), + cancelFrame = (handle) => cancelAnimationFrame(handle), +} = {}) { + let active = null; + let disposed = false; + const cancel = () => active?.finish(false); + return { + get active() { + return active !== null; + }, + cancel, + destroy() { + disposed = true; + cancel(); + }, + play(move, token) { + cancel(); + if (disposed || token?.cancelled || token?.signal?.aborted) + return Promise.resolve(false); + if (!Number.isFinite(move.durationSec) || move.durationSec <= 0) + throw new RangeError('Camera move duration must be positive'); + return new Promise((resolve, reject) => { + const run = { handle: null, finish: null }; + const startedAt = now(); + const onAbort = () => run.finish(false); + run.finish = (completed, error) => { + if (active !== run) return; + active = null; + if (run.handle !== null) cancelFrame(run.handle); + token?.signal?.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(completed); + }; + active = run; + token?.signal?.addEventListener('abort', onAbort, { once: true }); + const tick = () => { + if (active !== run) return; + run.handle = null; + if (disposed || token?.cancelled || token?.signal?.aborted) { + run.finish(false); + return; + } + try { + const progress = Math.min( + 1, + Math.max(0, (now() - startedAt) / (move.durationSec * 1000)), + ); + if (applyPose(sampleCameraMove(move, progress)) === false) { + run.finish(false); + return; + } + // Applying a pose can synchronously transfer camera ownership. + if (active !== run) return; + if (progress >= 1) run.finish(true); + else run.handle = requestFrame(tick); + } catch (error) { + run.finish(false, error); + } + }; + tick(); + }); + }, + }; +} diff --git a/src/scenes/cameraMotion.test.mjs b/src/scenes/cameraMotion.test.mjs new file mode 100644 index 000000000..ecc16a3e3 --- /dev/null +++ b/src/scenes/cameraMotion.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCameraMotion } from './cameraMotion.js'; +const move = { + durationSec: 2, + easing: 'linear', + from: { lat: 0, lon: 0, alt: 100, heading: 0, pitch: 0, roll: 0 }, + to: { lat: 2, lon: 2, alt: 200, heading: 0, pitch: 0, roll: 0 }, +}; +function harness(apply) { + let time = 0; + let next = 0; + const frames = new Map(); + const poses = []; + const owner = createCameraMotion({ + now: () => time, + requestFrame: (cb) => { + const id = ++next; + frames.set(id, cb); + return id; + }, + cancelFrame: (id) => frames.delete(id), + applyPose: (pose) => { + poses.push(pose); + return apply?.(pose); + }, + }); + return { + owner, + frames, + poses, + tick(ms) { + time = ms; + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((cb) => cb()); + }, + }; +} +test('motion uses the authored curve and releases the frame on completion', async () => { + const h = harness(); + const done = h.owner.play(move, { cancelled: false }); + assert.deepEqual(h.poses[0], move.from); + h.tick(1000); + assert.equal(h.poses.at(-1).alt, 150); + h.tick(2000); + assert.equal(await done, true); + assert.deepEqual(h.poses.at(-1), move.to); + assert.equal(h.frames.size, 0); + assert.equal(h.owner.active, false); +}); +test('abort and replacement settle immediately and revoke already queued callbacks', async () => { + const h = harness(); + const abort = new AbortController(); + const first = h.owner.play(move, { signal: abort.signal }); + const stale = [...h.frames.values()][0]; + abort.abort(); + assert.equal(await first, false); + const second = h.owner.play(move, {}); + const count = h.poses.length; + stale(); + assert.equal(h.poses.length, count); + h.owner.destroy(); + assert.equal(await second, false); + assert.equal(h.frames.size, 0); + assert.equal(await h.owner.play(move, {}), false); +}); +test('synchronous cancellation from pose application schedules no abandoned frame', async () => { + let owner; + const h = harness(() => owner.cancel()); + owner = h.owner; + assert.equal(await owner.play(move, {}), false); + assert.equal(h.frames.size, 0); +}); +test('failed pose application settles and invalid durations cannot schedule a loop', async () => { + const h = harness(() => { + throw new Error('camera unavailable'); + }); + await assert.rejects(h.owner.play(move, {}), /camera unavailable/); + assert.equal(h.frames.size, 0); + assert.throws( + () => h.owner.play({ ...move, durationSec: 0 }, {}), + /duration/, + ); +}); diff --git a/src/scenes/director.js b/src/scenes/director.js index 5c3c44a92..01a04f56a 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -12,6 +12,8 @@ * State is persisted to localStorage and can be exported/imported as JSON. */ +import { resolveCameraPose, resolveCameraMove } from '../director/camera.js'; +import { createCameraMotion } from './cameraMotion.js'; import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from '../ui/scenes.js'; import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js'; @@ -84,6 +86,22 @@ export class SceneDirector { this.dataManager = dataManager; this._isMapStackAvailable = isMapStackAvailable; this._scenePacks = scenePacks; + this._cameraMotion = createCameraMotion({ + applyPose: (pose) => this._setCameraView(pose), + }); + const yieldCamera = () => { + if (this._usesAuthoredCamera && !this._claimingCamera) + this.stopScene('Camera ownership changed'); + }; + this._cameraHandoffUnsubscribe = + styleManager.subscribeCameraHandoff?.(yieldCamera); + const canvas = viewer.scene?.canvas; + for (const event of ['pointerdown', 'wheel']) + canvas?.addEventListener(event, yieldCamera, { passive: true }); + this._removeCameraInput = () => { + for (const event of ['pointerdown', 'wheel']) + canvas?.removeEventListener(event, yieldCamera); + }; /** @type {boolean} True while a scene run is in progress */ this._running = false; @@ -179,6 +197,9 @@ export class SceneDirector { this._destroyed = true; this._sceneSeekGeneration++; this._visibilityUnsubscribe?.(); + this._cameraHandoffUnsubscribe?.(); + this._removeCameraInput?.(); + this._cameraMotion?.destroy(); this._controls?.destroy(); this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { @@ -903,7 +924,9 @@ export class SceneDirector { const camera = this.styleManager.getCameraState(); if (!camera) return; - shot.camera = camera; + shot.camera = shot.move + ? { ...camera, altitudeReference: 'ellipsoid' } + : camera; shot.visual = this.styleManager.getVisualState(); shot.layers = this._captureLayerStates(); @@ -955,11 +978,12 @@ export class SceneDirector { async _loadShot( sceneId, shotId, - { flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {}, + { flyDuration = null, fromCamera = null, sceneSeek = null } = {}, ) { if (this._running) return { started: false, reason: 'already-running' }; const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return { started: false, reason: 'shot-not-found' }; + flyDuration ??= shot.move ? shot.durationSec : 2.2; const previousScene = this._project.scenes.find((item) => item.id === this._loadedSceneId) || null; @@ -972,6 +996,7 @@ export class SceneDirector { // means an in-flight layer transition is cancelled (and rolled back by the // manager) rather than merely ignored once it has already committed. this._cancelActiveSceneTravel(); + this._usesAuthoredCamera = !!shot.move; this._loadAbort?.abort(); const controller = new AbortController(); this._loadAbort = controller; @@ -1017,7 +1042,9 @@ export class SceneDirector { return { started: false, reason: 'layers-refused' }; } if (seekState) { - this._setCameraView(seekState.camera || shot.camera); + this._setCameraView( + seekState.camera || resolveCameraPose(scene, shot.camera), + ); this._setProgress(seekState.sceneProgress); this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, { running: false, @@ -1044,10 +1071,10 @@ export class SceneDirector { ); const cameraTravel = this._beginShotTravel(scene, shot, flyDuration); try { - // _flyCamera calls Cesium's flyTo synchronously before yielding. Publish - // the trail phase immediately afterward so its clock overlaps actual + // Camera motion starts synchronously before yielding. Publish the + // trail phase immediately afterward so its clock overlaps actual // camera motion, never an earlier asynchronous layer-reconcile wait. - const flight = this._flyCamera(shot.camera, flyDuration, token); + const flight = this._flyShotCamera(scene, shot, flyDuration, token); this._publishShotTravel(scene, shot, cameraTravel); await flight; } catch (error) { @@ -1171,6 +1198,8 @@ export class SceneDirector { /** Revoke layer-owned travel motion before a newer load, STOP, or teardown. */ _cancelActiveSceneTravel() { + this._cameraMotion?.cancel(); + this._usesAuthoredCamera = false; // 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. @@ -1242,7 +1271,9 @@ export class SceneDirector { scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length]; const result = await this.loadShot(sceneId, shotId, { flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC, - fromCamera: previousShot?.camera || null, + fromCamera: shot.move + ? null + : resolveCameraPose(scene, previousShot?.camera), }); return result || { started: false, reason: 'cancelled' }; } @@ -1291,7 +1322,8 @@ export class SceneDirector { /** Read timing diagnostics without exposing timer handles or mutable clock state. */ getPlaybackTimingState() { return { - activeTimers: this._clock.activeTimers, + activeTimers: + this._clock.activeTimers + (this._cameraMotion?.active ? 1 : 0), snapshot: this._clock.snapshot, }; } @@ -1345,7 +1377,9 @@ export class SceneDirector { } } if (!applied) return false; - this._setCameraView(seekState.camera || shot.camera); + this._setCameraView( + seekState.camera || resolveCameraPose(scene, shot.camera), + ); this._setProgress(seekState.sceneProgress); this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, { running: false, @@ -1419,10 +1453,13 @@ export class SceneDirector { // Older/headless style managers may predate the facade — proceed then. if (typeof this.styleManager?.runImmediateNavigation !== 'function') return true; - const claimed = this.styleManager.runImmediateNavigation( - 'scene', - () => true, - ); + let claimed; + this._claimingCamera = true; + try { + claimed = this.styleManager.runImmediateNavigation('scene', () => true); + } finally { + this._claimingCamera = false; + } if (claimed === false) { this._updateStatus('Camera unavailable — exit cockpit first'); return false; @@ -1444,7 +1481,7 @@ export class SceneDirector { ), orientation: { heading: Cesium.Math.toRadians(cameraState.heading || 0), - pitch: Cesium.Math.toRadians(cameraState.pitch || -35), + pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35), roll: Cesium.Math.toRadians(cameraState.roll || 0), }, }); @@ -1581,6 +1618,8 @@ export class SceneDirector { this._loadGeneration++; this._clock.stopShot(); + this._usesAuthoredCamera = queue.some(({ shot }) => !!shot.move); + // Transition to running state this._running = true; this._previewRun = preview; @@ -1957,6 +1996,23 @@ export class SceneDirector { return true; } + /** Use the authored sampler only for explicit moves; ordinary shots keep their existing flights. */ + async _flyShotCamera(scene, shot, durationSec, token) { + const move = resolveCameraMove(scene, shot); + if (!move) + return this._flyCamera( + resolveCameraPose(scene, shot.camera), + durationSec, + token, + ); + const completed = await this._cameraMotion.play( + { ...move, durationSec }, + token, + ); + if (!completed && !token.cancelled && !this._destroyed) + this.stopScene('Camera move interrupted'); + } + /** * Fly the Cesium camera to the given position over the specified duration * using cubic ease-in-out. Resolves when the flight completes, is cancelled, @@ -1998,7 +2054,7 @@ export class SceneDirector { destination, orientation: { heading: Cesium.Math.toRadians(cameraState.heading || 0), - pitch: Cesium.Math.toRadians(cameraState.pitch || -35), + pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35), roll: Cesium.Math.toRadians(cameraState.roll || 0), }, duration, diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b1be372f4..1a27161d0 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved); await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) }); assert.equal(director._storageReadError, null); - assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 3); + assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 4); } finally { restore(); } }); @@ -1491,3 +1491,29 @@ test('invalid authored edits cannot persist an unreadable project over a good sa assert.match(notice, /camera.lat/); } finally { restore(); } }); + +test('zero camera pitch is preserved by both immediate placement and ordinary flight', async () => { + const { director, viewer, restore } = makeDirector(); + try { + let placed; + viewer.camera.setView = (options) => { placed = options; }; + const pose = { lat: 10, lon: 20, alt: 500, heading: 0, pitch: 0, roll: 0 }; + director._setCameraView(pose); + assert.equal(placed.orientation.pitch, 0); + await director._flyCamera(pose, 0.2, { cancelled: false }); + assert.equal(viewer.flights.at(-1).orientation.pitch, 0); + } finally { restore(); } +}); + +test('camera refusal starts no authored frame or playback clock', async () => { + const { director, styleManager, viewer, restore } = makeDirector(); + try { + const shot = director._project.scenes[0].shots[0]; + shot.move = { from: { ...shot.camera, altitudeReference: 'ellipsoid' }, easing: 'linear' }; + styleManager.runImmediateNavigation = () => false; + assert.equal((await director.startScene('scene-1', { single: true })).reason, 'camera-unavailable'); + assert.equal(director._cameraMotion.active, false); + assert.equal(director.getPlaybackTimingState().activeTimers, 0); + assert.deepEqual(viewer.flights, []); + } finally { restore(); } +}); diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index c3870c5d6..763ef1e96 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -40,7 +40,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) { 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); + const flight = director._flyShotCamera(scene, shot, duration, token); director._publishShotTravel(scene, shot, travel); return flight; }, diff --git a/src/scenes/project.js b/src/scenes/project.js index c71517ae4..d47a49ad7 100644 --- a/src/scenes/project.js +++ b/src/scenes/project.js @@ -67,11 +67,11 @@ function normalizeBloomState( { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}, ) { // Determine which bloom scale the stored value was encoded under. - // Older projects (version < PROJECT_VERSION) used scale version 1. + // Older projects (version < 3) used scale version 1. const explicitVersion = Number(rawBloom.version); const bloomVersion = Number.isFinite(explicitVersion) ? explicitVersion - : projectVersion >= PROJECT_VERSION + : projectVersion >= 3 ? BLOOM_SCALE_VERSION : 1; @@ -231,14 +231,29 @@ export function normalizeShot( Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC, ), holdSec: Math.max(0, Number(rawShot?.holdSec) || 0), - camera: { - lat: Number(camera.lat) || 0, - lon: Number(camera.lon) || 0, - alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800, - heading: Number(camera.heading) || 0, - pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35, - roll: Number(camera.roll) || 0, - }, + camera: camera.anchorId + ? { + anchorId: camera.anchorId, + heading: Number(camera.heading) || 0, + pitch: Number.isFinite(Number(camera.pitch)) + ? Number(camera.pitch) + : -35, + roll: Number(camera.roll) || 0, + } + : { + lat: Number(camera.lat) || 0, + lon: Number(camera.lon) || 0, + alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800, + heading: Number(camera.heading) || 0, + pitch: Number.isFinite(Number(camera.pitch)) + ? Number(camera.pitch) + : -35, + roll: Number(camera.roll) || 0, + ...(camera.altitudeReference || rawShot?.move + ? { altitudeReference: camera.altitudeReference || 'ellipsoid' } + : {}), + }, + ...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}), visual: { style: visual.style || 'normal', bloom: normalizeBloomState(bloom, { @@ -331,6 +346,7 @@ export function normalizeProject(rawProject) { return { id: scene?.id || uid('scene'), title: scene?.title || `Scene ${sceneIdx + 1}`, + ...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}), releaseLayerIds: [ ...new Set( (Array.isArray(scene?.releaseLayerIds) diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 0cf05135b..b47dbfefe 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -1,3 +1,4 @@ +import { createStateChannel } from '../app/stateChannel.js'; import * as Cesium from 'cesium'; import { beginDeferredNavigation, @@ -34,6 +35,9 @@ export class NavigationController { cancelOrientation, }); this._navigationGeneration = 0; + this._cameraHandoffs = createStateChannel(() => ({ + generation: this._navigationGeneration, + })); this._activeLocationSearchGeneration = null; this._disposed = false; } @@ -45,6 +49,7 @@ export class NavigationController { const { flightsLayer, militaryFlightsLayer, satellitesLayer } = this.tracking; this._navigationGeneration += 1; + this._cameraHandoffs?.publish(); // A newer destination owns the camera, so the last free-text search is no // longer where we are. DEFERRED navigation opts out here and clears at the // reassert seam instead: a geocode that never resolves moves no camera, and @@ -247,8 +252,14 @@ export class NavigationController { }, }); } + /** Subscribe to ownership changes without claiming the camera or exposing mutable state. */ + subscribeCameraHandoff(listener) { + return this._cameraHandoffs.subscribe(listener, { emitCurrent: false }); + } stop() { this._disposed = true; + this._cameraHandoffs?.publish(); + this._cameraHandoffs?.destroy(); } destroy() { this.stop(); diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 56d4347f0..dc65e4fb9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -4,6 +4,11 @@ * New logic belongs with an owner, not in this forwarding surface. */ export class ShellFacade { + /** Observe camera ownership transfers through the navigation owner. */ + subscribeCameraHandoff(listener) { + return this._navigation.subscribeCameraHandoff(listener); + } + get _models3dModeBtns() { return this._aircraftDisplay._models3dModeBtns; } From 92526e9a922ab23fa05c0468705b9216bbb7b0a6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 17:59:53 -0700 Subject: [PATCH 4/5] feat(director): add bounded scene data packs and placement --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 11 +- docs/DIRECTOR-DATA-PACKS.md | 114 +++++++ docs/DIRECTOR.md | 17 +- docs/SCENE-DOCUMENT.md | 16 +- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 24 +- scripts/qa-director-packs.mjs | 359 +++++++++++++++++++++ src/app/tools.js | 2 + src/director/camera.test.mjs | 4 +- src/director/document.js | 8 +- src/director/document.test.mjs | 2 +- src/director/packs/geojson.js | 62 ++++ src/director/packs/manifest.js | 138 ++++++++ src/director/packs/packs.test.mjs | 340 +++++++++++++++++++ src/director/packs/session.js | 159 +++++++++ src/director/packs/source.js | 63 ++++ src/director/playback.js | 4 + src/scenes/dataPacks/controller.js | 20 ++ src/scenes/dataPacks/geometry.js | 73 +++++ src/scenes/dataPacks/presentation.js | 217 +++++++++++++ src/scenes/dataPacks/presentation.test.mjs | 57 ++++ src/scenes/director.js | 26 ++ src/scenes/director.test.mjs | 2 +- src/scenes/playbackAdapter.js | 11 +- src/scenes/project.js | 2 + src/standalone/tools.js | 8 + 27 files changed, 1720 insertions(+), 28 deletions(-) create mode 100644 docs/DIRECTOR-DATA-PACKS.md create mode 100644 scripts/qa-director-packs.mjs create mode 100644 src/director/packs/geojson.js create mode 100644 src/director/packs/manifest.js create mode 100644 src/director/packs/packs.test.mjs create mode 100644 src/director/packs/session.js create mode 100644 src/director/packs/source.js create mode 100644 src/scenes/dataPacks/controller.js create mode 100644 src/scenes/dataPacks/geometry.js create mode 100644 src/scenes/dataPacks/presentation.js create mode 100644 src/scenes/dataPacks/presentation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c878c42c8..5d29775e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Director scene documents now support bounded data-pack manifests, per-shot + selection and registered GeoJSON/PNG/media loaders with explicit placement, + visible attribution and cancellation/disposal on Stop or replacement. + - Director version 4 adds named camera anchors and explicit pose-to-pose moves with shared playback/seek interpolation, easing and holds. Navigation and manual input cancel authored motion; older scene files retain existing flights. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9dcbd379d..91a1c1871 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Director version-5 documents add scene-local data-pack manifests and per-shot +selection. Registered sources acquire bounded, cancellable assets separately from +GeoJSON, PNG and manually played media presentation. Storage paths, attribution +and geographic placement remain distinct; Stop/replacement releases resources. +Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene +content, assets and credit remain unchanged. + Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending @@ -34,9 +41,9 @@ 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. Scene-document validation and legacy migration now have separate owners. Invalid imports preserve the current project; unreadable saved projects -are protected from fallback writes. Version 4 is the export format, with +are protected from fallback writes. Version 5 is the export format, with zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); -data packs and interactions remain planned in [Director](DIRECTOR.md). +interactions remain 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-DATA-PACKS.md b/docs/DIRECTOR-DATA-PACKS.md new file mode 100644 index 000000000..eb32c2250 --- /dev/null +++ b/docs/DIRECTOR-DATA-PACKS.md @@ -0,0 +1,114 @@ +# Director data packs + +Version-5 scene documents can declare `dataPacks` on a scene and select them by +`dataPackIds` on each shot. Import validates declarations without loading assets. +Loading, seeking or playing a shot acquires only its selected packs. A shot with +no pack IDs clears the preceding shot's packs. Existing scene recipes and assets +keep their current paths, notices and behavior. + +A pack manifest is version 1 and requires an ID, format, source, attribution and +placement. Sources describe storage; placement describes geography. Source names +refer to trusted application registrations, never scripts to import. A document +cannot supply request headers, keys, cookies, endpoints or executable handlers. + +```json +{ + "version": 5, + "scenes": [{ + "id": "example", + "title": "My geographic notes", + "dataPacks": [{ + "id": "notes", + "version": 1, + "format": "geojson", + "source": { "adapter": "assets", "path": "my-scene/notes.geojson" }, + "attribution": { "text": "Your name", "license": "Your asset's actual license" }, + "placement": { "altitudeReference": "ellipsoid" } + }], + "shots": [{ + "id": "overview", + "camera": { "lat": 30.2672, "lon": -97.742, "alt": 2600, "pitch": -70 }, + "durationSec": 4, + "holdSec": 2, + "dataPackIds": ["notes"] + }] + }] +} +``` + +For the standalone application, put that asset at +`public/scene-assets/my-scene/notes.geojson`. The registered `assets` source has +an explicit `/scene-assets/` base on the application's origin. Importing a file +from disk does not grant access to adjacent local files or infer a remote base. +Relative paths cannot contain traversal, query strings, fragments, URL syntax, +percent escapes or empty segments. Asset directories are served as static files; +this is not a new arbitrary-URL proxy. + +## Formats and placement + +- **`geojson`:** a UTF-8 `FeatureCollection`, served as `application/geo+json` or + `application/json`. Each feature needs a unique string `id`. Point, LineString + and Polygon (including holes) are supported. Coordinates are WGS84 + `[longitude, latitude, ellipsoidHeightMeters]`; missing height means zero, + which can be underground. Supply appropriate heights. Geometry properties + are ignored: they cannot select external icons, HTML, styling or scripts. + Placement is `{ "altitudeReference": "ellipsoid" }`. +- **`image`:** PNG served as `image/png`, at most 4096×4096 pixels, checked before + decoding. Placement is `{ "bounds": [west, south, east, north], "height": 300, + "altitudeReference": "ellipsoid" }`. Bounds must increase and cannot cross + the dateline. The image is a horizontal geographic rectangle at that height. +- **`media`:** MP4/WebM video or MPEG/Ogg/WAV/WebM audio, with the matching standard + MIME type. Placement is `{ "anchorId": "media-location" }`, referring to a + scene-local anchor with explicit ellipsoid height. A labelled marker locates + the media; an attribution card provides native, user-operated playback + controls. Media never autoplays. Codec support depends on the browser. D5 does + not synchronize media time with scene time or embed third-party players. + +Pack attribution always includes nonempty `text` and `license`; an optional +HTTPS `url` opens as a source link with no opener/referrer. Importing or sharing +an asset never changes its license. D5 adds no third-party content and preserves +all existing Nepal content and contributor notices. + +Optional `byteLength` specifies the exact expected byte count; optional `sha256` +is a lowercase hexadecimal SHA-256 digest checked before rendering. There are at +most 8 packs per scene, 8 MiB per asset, and 32 MiB of selected assets per shot. +GeoJSON additionally allows at most 2,000 features and 50,000 positions. +Transforms, terrain-relative placement, terrain collision avoidance, arbitrary +GeoJSON styles, archives and local filesystem bundles are not implemented. + +## Loading and resource ownership + +The `director` package export provides `createAssetDirectorySource` and +`createDataPackSession`. The former receives an explicit trusted HTTP(S) +directory `baseUrl` and optional `fetchImpl`; it confines every relative asset +path to that directory. Cross-origin directories require CORS. Requests omit +credentials and referrers, reject redirects, and use `cache: 'no-store'`. +There is no persistent pack cache, offline fallback or silent substitute. A +missing, invalid, oversized or integrity-mismatched asset fails the shot's pack +load, removes partial results and reports a stable error without exposing a URL. +Successful loading leaves the attribution cards visible with the assets. + +`createApplicationTools({ sceneDataPacks: { sources } })` and +`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a +registry of source functions. Each receives `{ path, signal, maxBytes }` and +returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the +signal, bound reads and return the real MIME type. They remain application code, +not scene document fields. Without registrations, referenced sources fail closed. + +The portable session receives format adapters separately. Each adapter receives +`{ pack, asset, anchors, signal }` and returns a resource with an idempotent +`dispose()`. Core's scene composition supplies Cesium geometry/image rendering +and text-only attribution/native media controls. The session applies a 15-second +load deadline and rejects late results, even when an adapter ignores abort. + +Stop, replacement, next-shot selection, scene release and destruction cancel +pending work, remove owned entities/cards, stop media and revoke object URLs. +Run completion releases packs with its run signal. Direct LOAD and seek retain +packs until the next operation or Stop. Resources are reacquired per shot; +there are no disabled pack refresh loops. `getDataPackState()` returns copied +status and resource counts for lifecycle diagnostics. + +Run `node scripts/qa-director-packs.mjs` against a credentialed local server to +exercise the real import/load/seek/Stop path with self-authored synthetic assets. +This stage provides the manifest/loading boundary; interactive actions and +visual authoring follow separately. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 0aefbf0e3..2ba452b7d 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -78,14 +78,15 @@ and river coordinates are bundled. See the the same sampler. Ellipsoidal heights are explicit; terrain-relative input is rejected. Existing shots retain ordinary flights. Curved paths, look-at targets and visual authoring are later extensions. -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. +4. **Data packs and placement — implemented for bounded assets.** + [Version 5](DIRECTOR-DATA-PACKS.md) defines a pack manifest with format/version, + source, attribution/license and optional integrity/size metadata. It distinguishes + registered asset directories from geographic coordinates, image bounds and + media anchors. GeoJSON, PNG and native media adapters load with byte/time + limits and explicit ellipsoid heights. Stop and replacement release resources. + Paths resolve against a trusted directory base; missing/offline assets fail + explicitly. Bundles, transforms and terrain-relative placement remain future + extensions. 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, diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md index 35737156e..f6dc66636 100644 --- a/docs/SCENE-DOCUMENT.md +++ b/docs/SCENE-DOCUMENT.md @@ -1,13 +1,13 @@ # Scene document format -Scenes exports a UTF-8 JSON project. Version 4 adds [camera directions](DIRECTOR-CAMERA.md) -and is the write format. Versions 1, 2, 3 and unversioned legacy files migrate on +Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md) +and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with a field path before the current project, selection or saved bytes change. ```json { - "version": 4, + "version": 5, "scenes": [{ "id": "my-scene", "title": "My scene", @@ -95,7 +95,11 @@ can preserve temporary edits. Storage writes use the same validation before repl edits and storage write failures surface an unsaved toast. Only authored project fields belong in this file. Runtime clocks, camera flights, -loading progress and run diagnostics remain separate. Version 4 supports scene-local camera anchors and authored moves. Asset-pack -manifests and declarative interactions remain planned extensions. They will -receive stable IDs and their own validated migration when introduced. No scene assets, source links, licenses or contributor attribution +loading progress and run diagnostics remain separate. Camera anchors and authored +moves arrived in version 4; data packs follow in version 5. Declarative +interactions remain a planned extension. No scene assets, source links, licenses or contributor attribution change with this document boundary. + +Version 5 additionally accepts scene `dataPacks` and shot `dataPackIds`, as +defined in [data packs](DIRECTOR-DATA-PACKS.md). Version-4 camera documents +continue to import without adding any packs. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b84602e3f..3374b88fc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -736,5 +736,8 @@ "src/director/document.test.mjs", "src/director/camera.test.mjs", "src/scenes/cameraMotion.test.mjs", - "scripts/qa-director-camera.mjs" + "scripts/qa-director-camera.mjs", + "src/director/packs/packs.test.mjs", + "scripts/qa-director-packs.mjs", + "src/scenes/dataPacks/presentation.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 736800daf..8147f85e4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -621,7 +621,11 @@ "src/director/document.js", "src/director/documentFields.js", "src/director/cameraDocument.js", - "src/director/camera.js" + "src/director/camera.js", + "src/director/packs/source.js", + "src/director/packs/session.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js" ], "external": [] }, @@ -655,7 +659,14 @@ "src/director/documentFields.js", "src/director/cameraDocument.js", "src/director/camera.js", - "src/scenes/cameraMotion.js" + "src/scenes/cameraMotion.js", + "src/director/packs/source.js", + "src/director/packs/session.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js", + "src/scenes/dataPacks/controller.js", + "src/scenes/dataPacks/presentation.js", + "src/scenes/dataPacks/geometry.js" ], "external": ["cesium"] }, @@ -1766,7 +1777,14 @@ "src/director/documentFields.js", "src/director/cameraDocument.js", "src/director/camera.js", - "src/scenes/cameraMotion.js" + "src/scenes/cameraMotion.js", + "src/director/packs/source.js", + "src/director/packs/session.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js", + "src/scenes/dataPacks/controller.js", + "src/scenes/dataPacks/presentation.js", + "src/scenes/dataPacks/geometry.js" ], "external": [ "@mapbox/vector-tile", diff --git a/scripts/qa-director-packs.mjs b/scripts/qa-director-packs.mjs new file mode 100644 index 000000000..9ef68d420 --- /dev/null +++ b/scripts/qa-director-packs.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node +/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const output = path.resolve('qa-shots/director-packs'); +fs.mkdirSync(output, { recursive: true }); +const png = await sharp({ + create: { + width: 64, + height: 64, + channels: 4, + background: { r: 255, g: 80, b: 30, alpha: 0.65 }, + }, +}) + .png() + .toBuffer(); +const wav = Buffer.alloc(44 + 16000); +wav.write('RIFF'); +wav.writeUInt32LE(wav.length - 8, 4); +wav.write('WAVEfmt ', 8); +wav.writeUInt32LE(16, 16); +wav.writeUInt16LE(1, 20); +wav.writeUInt16LE(1, 22); +wav.writeUInt32LE(8000, 24); +wav.writeUInt32LE(16000, 28); +wav.writeUInt16LE(2, 32); +wav.writeUInt16LE(16, 34); +wav.write('data', 36); +wav.writeUInt32LE(16000, 40); +const geojson = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'point', + geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] }, + }, + { + type: 'Feature', + id: 'line', + geometry: { + type: 'LineString', + coordinates: [ + [-97.745, 30.268, 400], + [-97.735, 30.268, 400], + ], + }, + }, + { + type: 'Feature', + id: 'polygon', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-97.746, 30.265, 300], + [-97.74, 30.265, 300], + [-97.74, 30.267, 300], + [-97.746, 30.265, 300], + ], + ], + }, + }, + ], +}; +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = [], + requests = []; +let pending, + failures = 0; +const check = (name, passed) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +}; +page.on('pageerror', (e) => errors.push(e.message)); + +await page.setRequestInterception(true); +page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.startsWith('/scene-assets/')) + return void request.continue(); + requests.push(url.pathname); + if (url.pathname.endsWith('/slow.json')) { + pending = request; + return; + } + const reply = url.pathname.endsWith('/outline.json') + ? { contentType: 'application/geo+json', body: JSON.stringify(geojson) } + : url.pathname.endsWith('/image.png') + ? { contentType: 'image/png', body: png } + : url.pathname.endsWith('/audio.wav') + ? { contentType: 'audio/wav', body: wav } + : { status: 404, body: 'missing' }; + void request.respond(reply).catch(() => {}); +}); +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 }, + ); + await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const manifest = (id, format, file, placement) => ({ + id, + version: 1, + format, + source: { adapter: 'assets', path: `qa/${file}` }, + attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' }, + placement, + }); + const camera = { + lat: 30.2672, + lon: -97.742, + alt: 4000, + heading: 0, + pitch: -90, + roll: 0, + }; + const project = { + version: 5, + scenes: [ + { + id: 'packs', + title: 'Data packs QA', + anchors: [ + { + id: 'media', + lat: 30.269, + lon: -97.737, + alt: 450, + altitudeReference: 'ellipsoid', + }, + ], + dataPacks: [ + manifest('outline', 'geojson', 'outline.json', { + altitudeReference: 'ellipsoid', + }), + manifest('image', 'image', 'image.png', { + bounds: [-97.738, 30.264, -97.732, 30.267], + height: 300, + altitudeReference: 'ellipsoid', + }), + manifest('media', 'media', 'audio.wav', { anchorId: 'media' }), + manifest('slow', 'geojson', 'slow.json', { + altitudeReference: 'ellipsoid', + }), + manifest('missing', 'geojson', 'missing.json', { + altitudeReference: 'ellipsoid', + }), + ], + shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({ + id, + title: id, + camera, + durationSec: 0.3, + holdSec: 0.2, + visual: { style: 'normal', mapStack: 'photoreal' }, + layers: {}, + dataPackIds: + id === 'all' + ? ['outline', 'image', 'media'] + : id === 'empty' + ? [] + : [id], + })), + }, + ], + }; + await d.importProjectFile( + new File([JSON.stringify(project)], 'packs.json'), + ); + window.__packBaseline = d.viewer.entities.values.length; + window.__packPrimitiveBaseline = d.viewer.scene.primitives.length; + window.__packRevoked = []; + const revoke = URL.revokeObjectURL.bind(URL); + URL.revokeObjectURL = (url) => { + window.__packRevoked.push(url); + revoke(url); + }; + }); + check('import does not acquire unselected assets', requests.length === 0); + const loaded = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 }); + window.__packMaterials = Array.from( + { + length: + d.viewer.scene.primitives.length - window.__packPrimitiveBaseline, + }, + (_, i) => + d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i) + .appearance?.material, + ).filter(Boolean); + return { + started: result?.started, + state: d.getDataPackState(), + count: d.viewer.entities.values.length - window.__packBaseline, + primitives: + d.viewer.scene.primitives.length - window.__packPrimitiveBaseline, + cards: document.querySelectorAll('[data-director-pack]').length, + paused: document.querySelector('[data-director-pack] audio')?.paused, + }; + }); + check( + 'GeoJSON, PNG and media load only through registered sources', + loaded.started && loaded.state.count === 3 && requests.length === 3, + ); + check( + 'geometry and image batches plus anchored markers render separately', + loaded.count === 2 && loaded.primitives === 3, + ); + check( + 'attribution is visible and media does not autoplay', + loaded.cards === 3 && loaded.paused === true, + ); + await page.waitForFunction( + () => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal', + { timeout: 15000 }, + ); + await new Promise((r) => setTimeout(r, 5000)); + await page.waitForFunction( + () => window.__godsEyeView.tileset?.tilesLoaded === true, + { timeout: 60000 }, + ); + const evidence = await page.evaluate(() => ({ + mapStack: window.__godsEyeView.mapStackController.getActiveId(), + tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + })); + fs.writeFileSync( + path.join(output, 'report.json'), + JSON.stringify(evidence, null, 2), + ); + check( + 'static geometry is ready before visual acceptance', + await page.evaluate(() => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + return Array.from( + { length: primitives.length - window.__packPrimitiveBaseline }, + (_, i) => primitives.get(window.__packPrimitiveBaseline + i), + ).every((p) => p.ready !== false); + }), + ); + await page.screenshot({ path: path.join(output, 'packs.png') }); + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector; + d._setCameraView({ + ...d.styleManager.getCameraState(), + heading: 30, + pitch: -85, + }); + }); + await page.waitForFunction( + () => window.__godsEyeView.tileset?.tilesLoaded === true, + { timeout: 60000 }, + ); + await new Promise((r) => setTimeout(r, 2000)); + await page.screenshot({ path: path.join(output, 'angle.png') }); + const life = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const media = document.querySelector('[data-director-pack] audio'); + await media.play(); + const played = !media.paused; + await d.loadShot('packs', 'empty', { flyDuration: 0.3 }); + const cleared = + d.getDataPackState().count === 0 && + !document.querySelector('[data-director-packs]') && + d.viewer.entities.values.length === window.__packBaseline && + d.viewer.scene.primitives.length === window.__packPrimitiveBaseline && + media.paused && + !media.getAttribute('src') && + window.__packRevoked.length === 2 && + window.__packMaterials.length === 1 && + window.__packMaterials.every((material) => material.isDestroyed()); + await d.seekScene('packs', 0.1); + const seek = d.getDataPackState().count === 3; + d.stopScene(); + const stopped = + d.getDataPackState().count === 0 && + d.viewer.entities.values.length === window.__packBaseline; + const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 }); + return { + played, + cleared, + seek, + stopped, + missing: missing?.started === false && d.getDataPackState().count === 0, + }; + }); + for (const [name, passed] of Object.entries(life)) check(name, passed); + await page.evaluate(() => { + window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot( + 'packs', + 'slow', + ); + }); + const deadline = Date.now() + 15000; + while (!pending && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 50)); + check('pending transport reached', !!pending); + const cancelled = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const start = performance.now(); + d.stopScene(); + await window.__pendingPack; + return performance.now() - start < 1000 && d.getDataPackState().count === 0; + }); + check('Stop promptly settles a pending transport', cancelled); + if (pending) + await pending + .respond({ + contentType: 'application/json', + body: JSON.stringify(geojson), + }) + .catch(() => {}); + check( + 'late response cannot reintroduce entities', + await page.evaluate( + () => + window.__godsEyeView.viewer.entities.values.length === + window.__packBaseline && + window.__godsEyeView.viewer.scene.primitives.length === + window.__packPrimitiveBaseline, + ), + ); + await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + await d.loadShot('packs', 'all', { flyDuration: 0.3 }); + await d.importProjectFile( + new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'), + ); + }); + check( + 'replacement import clears every pack resource', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getDataPackState().count === 0 && + !document.querySelector('[data-director-packs]'), + ), + ); + check('no uncaught browser errors', errors.length === 0); + if (errors.length) + console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]'))); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/src/app/tools.js b/src/app/tools.js index 87b51a5f3..27d0cc2d1 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -21,6 +21,7 @@ export function createApplicationTools({ voice = {}, startChrome, onSceneDirector, + sceneDataPacks, signal, defer, }) { @@ -28,6 +29,7 @@ export function createApplicationTools({ const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { + dataPacks: sceneDataPacks, isMapStackAvailable: (id) => mapStackController?.isStackAvailable(id) === true, }); diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs index 54a613d0f..f3ecb0fbc 100644 --- a/src/director/camera.test.mjs +++ b/src/director/camera.test.mjs @@ -62,7 +62,7 @@ test('version 4 retains anchor identity, references and explicit move edits thro project, ); }); -test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4', () => { +test('v3 unversioned bloom already uses scale 2 and is not migrated again in later formats', () => { const project = normalizeProject( parseSceneDocument( JSON.stringify({ @@ -73,7 +73,7 @@ test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4' }), ), ); - assert.equal(project.version, 4); + assert.equal(project.version, 5); assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50); }); test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => { diff --git a/src/director/document.js b/src/director/document.js index efa806ed8..0def9ff4d 100644 --- a/src/director/document.js +++ b/src/director/document.js @@ -12,9 +12,10 @@ import { uniqueId, jsonTree, } from './documentFields.js'; +import { validateSceneDataPacks } from './packs/manifest.js'; import { validateSceneCameras } from './cameraDocument.js'; export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js'; -export const SCENE_DOCUMENT_VERSION = 4; +export const SCENE_DOCUMENT_VERSION = 5; function visual(value, path, legacy) { fields(value, path, [ @@ -65,7 +66,7 @@ export function validateSceneDocument(project) { 'scenes', ]); const version = Object.hasOwn(project, 'version') ? project.version : 1; - if (![1, 2, 3, 4].includes(version)) + if (![1, 2, 3, 4, 5].includes(version)) fail('$.version', 'unsupported scene project version'); const legacy = version < 3; for (const key of ['createdAt', 'updatedAt']) @@ -83,6 +84,7 @@ export function validateSceneDocument(project) { 'appliedShotPacks', 'shots', ...(version >= 4 ? ['anchors'] : []), + ...(version >= 5 ? ['dataPacks'] : []), ]); uniqueId(scene, path, sceneIds); optional(scene, 'title', path, (v, p) => string(v, p, 4096)); @@ -123,6 +125,7 @@ export function validateSceneDocument(project) { 'sourcePackId', 'sourcePackVersion', ...(version >= 4 ? ['move'] : []), + ...(version >= 5 ? ['dataPackIds'] : []), ]); uniqueId(shot, at, shotIds); optional(shot, 'title', at, (v, p) => string(v, p, 4096)); @@ -149,6 +152,7 @@ export function validateSceneDocument(project) { }); }); validateSceneCameras(scene, path, version); + validateSceneDataPacks(scene, path); }); return project; } diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs index 692796921..18f4f017a 100644 --- a/src/director/document.test.mjs +++ b/src/director/document.test.mjs @@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv const migrated = normalizeProject( parseSceneDocument(JSON.stringify(project)), ); - assert.equal(migrated.version, 4); + assert.equal(migrated.version, 5); assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150); assert.equal(migrated.scenes[0].shots[0].id, 'shot'); assert.deepEqual( diff --git a/src/director/packs/geojson.js b/src/director/packs/geojson.js new file mode 100644 index 000000000..49f9f0f55 --- /dev/null +++ b/src/director/packs/geojson.js @@ -0,0 +1,62 @@ +import { PACK_LIMITS } from './manifest.js'; + +/** Decode a bounded geometry-only GeoJSON collection; properties never become HTML, styles or URLs. */ +export function decodePackGeoJSON(bytes) { + const value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ); + if ( + value?.type !== 'FeatureCollection' || + !Array.isArray(value.features) || + value.features.length > PACK_LIMITS.features + ) + throw new Error('Expected a bounded FeatureCollection'); + let positions = 0; + const ids = new Set(); + function position(p) { + if ( + !Array.isArray(p) || + ![2, 3].includes(p.length) || + p.some((v) => !Number.isFinite(v)) || + Math.abs(p[0]) > 180 || + Math.abs(p[1]) > 90 || + (p.length === 3 && (p[2] < -12000 || p[2] > 1e9)) || + ++positions > PACK_LIMITS.positions + ) + throw new Error('Invalid geographic position'); + return [p[0], p[1], p[2] ?? 0]; + } + function line(points, ring = false) { + if (!Array.isArray(points) || points.length < (ring ? 4 : 2)) + throw new Error('Invalid line'); + const normalized = points.map(position); + if (ring && normalized[0].some((v, i) => v !== normalized.at(-1)[i])) + throw new Error('Unclosed ring'); + return normalized; + } + return value.features.map((feature) => { + const id = feature?.id; + if ( + feature?.type !== 'Feature' || + typeof id !== 'string' || + !id.trim() || + id.length > 256 || + ids.has(id) + ) + throw new Error('Features require distinct string IDs'); + ids.add(id); + const g = feature.geometry; + let coordinates; + if (g?.type === 'Point') coordinates = position(g.coordinates); + else if (g?.type === 'LineString') coordinates = line(g.coordinates); + else if ( + g?.type === 'Polygon' && + Array.isArray(g.coordinates) && + g.coordinates.length && + g.coordinates.length <= 128 + ) + coordinates = g.coordinates.map((ring) => line(ring, true)); + else throw new Error('Unsupported geometry'); + return { id, type: g.type, coordinates }; + }); +} diff --git a/src/director/packs/manifest.js b/src/director/packs/manifest.js new file mode 100644 index 000000000..eff00eb12 --- /dev/null +++ b/src/director/packs/manifest.js @@ -0,0 +1,138 @@ +import { + fields, + string, + number, + array, + optional, + fail, +} from '../documentFields.js'; + +export const PACK_LIMITS = Object.freeze({ + packs: 8, + bytes: 8 * 1024 * 1024, + totalBytes: 32 * 1024 * 1024, + features: 2000, + positions: 50000, +}); + +/** Asset names are relative directory paths, never requests or executable modules. */ +export function validateAssetPath(value, path = 'source.path') { + string(value, path, 1024); + if ( + !value + .split('/') + .every((part) => /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(part)) + ) + fail( + path, + 'expected a relative asset path without URL syntax or traversal', + ); +} + +/** Validate one declarative pack; loading requires separately registered application adapters. */ +export function validateDataPack(pack, path, anchorIds) { + fields(pack, path, [ + 'id', + 'version', + 'format', + 'source', + 'attribution', + 'placement', + 'byteLength', + 'sha256', + ]); + string(pack.id, `${path}.id`); + if (pack.version !== 1) fail(`${path}.version`, 'unsupported pack version'); + if (!['geojson', 'image', 'media'].includes(pack.format)) + fail(`${path}.format`, 'unsupported pack format'); + fields(pack.source, `${path}.source`, ['adapter', 'path']); + string(pack.source.adapter, `${path}.source.adapter`); + validateAssetPath(pack.source.path, `${path}.source.path`); + fields(pack.attribution, `${path}.attribution`, ['text', 'license', 'url']); + string(pack.attribution.text, `${path}.attribution.text`, 4096); + string(pack.attribution.license, `${path}.attribution.license`, 4096); + optional(pack.attribution, 'url', `${path}.attribution`, (url, at) => { + string(url, at, 2048); + let parsed; + try { + parsed = new URL(url); + } catch { + fail(at, 'expected an HTTPS source link'); + } + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) + fail( + at, + 'expected an HTTPS source link without credentials, query or fragment', + ); + }); + optional(pack, 'byteLength', path, (v, at) => { + number(v, at, 1, PACK_LIMITS.bytes, false); + if (!Number.isInteger(v)) fail(at, 'expected an integer'); + }); + optional(pack, 'sha256', path, (v, at) => { + if (typeof v !== 'string' || !/^[a-f0-9]{64}$/.test(v)) + fail(at, 'expected a lowercase SHA-256 digest'); + }); + const p = pack.placement, + at = `${path}.placement`; + fields( + p, + at, + pack.format === 'image' + ? ['bounds', 'height', 'altitudeReference'] + : pack.format === 'media' + ? ['anchorId'] + : ['altitudeReference'], + ); + if (pack.format === 'media') { + if (!anchorIds.has(p.anchorId)) + fail(`${at}.anchorId`, 'unknown scene anchor'); + } else { + if (p.altitudeReference !== 'ellipsoid') + fail(`${at}.altitudeReference`, 'expected ellipsoid height in meters'); + if (pack.format === 'image') { + array(p.bounds, `${at}.bounds`, 4); + if (p.bounds.length !== 4) + fail(`${at}.bounds`, 'expected west, south, east, north'); + p.bounds.forEach((v, i) => + number( + v, + `${at}.bounds[${i}]`, + i % 2 ? -90 : -180, + i % 2 ? 90 : 180, + false, + ), + ); + if (p.bounds[0] >= p.bounds[2] || p.bounds[1] >= p.bounds[3]) + fail(`${at}.bounds`, 'expected increasing non-dateline bounds'); + number(p.height, `${at}.height`, -12000, 1e9, false); + } + } +} + +/** Validate scene-local pack IDs and shot references without acquiring assets. */ +export function validateSceneDataPacks(scene, path) { + const packs = Object.hasOwn(scene, 'dataPacks') ? scene.dataPacks : []; + array(packs, `${path}.dataPacks`, PACK_LIMITS.packs); + const anchors = new Set((scene.anchors || []).map((a) => a.id)); + const seen = new Set(); + packs.forEach((pack, i) => { + validateDataPack(pack, `${path}.dataPacks[${i}]`, anchors); + if (seen.has(pack.id)) + fail(`${path}.dataPacks[${i}].id`, 'duplicate pack ID'); + seen.add(pack.id); + }); + scene.shots.forEach((shot, i) => { + optional(shot, 'dataPackIds', `${path}.shots[${i}]`, (ids, at) => { + array(ids, at, PACK_LIMITS.packs); + if (new Set(ids).size !== ids.length || ids.some((id) => !seen.has(id))) + fail(at, 'expected distinct scene pack IDs'); + }); + }); +} diff --git a/src/director/packs/packs.test.mjs b/src/director/packs/packs.test.mjs new file mode 100644 index 000000000..036f31c02 --- /dev/null +++ b/src/director/packs/packs.test.mjs @@ -0,0 +1,340 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSceneDocument, stringifySceneDocument } from '../document.js'; +import { normalizeProject } from '../../scenes/project.js'; +import { createDataPackSession } from './session.js'; +import { createAssetDirectorySource } from './source.js'; +import { decodePackGeoJSON } from './geojson.js'; + +const pack = () => ({ + id: 'outline', + version: 1, + format: 'geojson', + source: { adapter: 'assets', path: 'example/outline.geojson' }, + attribution: { text: 'Example author', license: 'CC0-1.0' }, + placement: { altitudeReference: 'ellipsoid' }, +}); +const project = () => ({ + version: 5, + scenes: [ + { + id: 'scene', + dataPacks: [pack()], + shots: [{ id: 'shot', dataPackIds: ['outline'] }], + }, + ], +}); +const asset = () => ({ + bytes: new Uint8Array([1, 2, 3]), + mimeType: 'application/json', +}); +const deferred = () => { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +test('v5 packs and shot references round trip through project migration without acquiring bytes', () => { + const normalized = normalizeProject( + parseSceneDocument(JSON.stringify(project())), + ); + assert.deepEqual( + JSON.parse(stringifySceneDocument(normalized)).scenes[0].dataPacks, + project().scenes[0].dataPacks, + ); + assert.deepEqual(normalized.scenes[0].shots[0].dataPackIds, ['outline']); + for (const version of [1, 2, 3, 4]) { + const old = normalizeProject( + parseSceneDocument( + JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }), + ), + ); + assert.equal(old.version, 5); + assert.equal(old.scenes[0].dataPacks, undefined); + } +}); + +test('manifest rejects duplicate/unknown IDs, unsupported placement and request or credential syntax', () => { + const mutations = [ + (s) => { + s.dataPacks = null; + }, + (s) => s.dataPacks.push(pack()), + (s) => s.shots[0].dataPackIds.push('missing'), + (s) => s.shots[0].dataPackIds.push('outline'), + (s) => (s.dataPacks[0].placement.altitudeReference = 'terrain'), + (s) => (s.dataPacks[0].source.headers = { Authorization: 'secret' }), + (s) => (s.dataPacks[0].attribution.url = 'javascript:alert(1)'), + (s) => (s.dataPacks[0].byteLength = 0), + (s) => (s.dataPacks[0].sha256 = 'wrong'), + ...[ + '../x', + '/api/status', + 'https://example.com/x', + 'x?a=1', + 'x#key', + '%2e%2e/x', + 'a\\b', + 'a//b', + ].map((path) => (s) => (s.dataPacks[0].source.path = path)), + ]; + for (const mutate of mutations) { + const p = project(); + mutate(p.scenes[0]); + assert.throws(() => parseSceneDocument(JSON.stringify(p))); + } + const old = project(); + old.version = 4; + assert.throws(() => parseSceneDocument(JSON.stringify(old))); +}); + +test('image bounds and media anchor references are explicit and validated', () => { + const p = project(), + s = p.scenes[0], + a = s.dataPacks[0]; + a.format = 'image'; + a.placement = { + bounds: [-98, 30, -97, 31], + height: 200, + altitudeReference: 'ellipsoid', + }; + assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p))); + a.placement.bounds[2] = -99; + assert.throws(() => parseSceneDocument(JSON.stringify(p))); + a.format = 'media'; + a.placement = { anchorId: 'one' }; + assert.throws(() => parseSceneDocument(JSON.stringify(p))); + s.anchors = [ + { id: 'one', lat: 30, lon: -97, alt: 200, altitudeReference: 'ellipsoid' }, + ]; + assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p))); +}); + +test('directory source confines paths, strips credentials and rejects redirects, oversized streaming bodies and missing assets', async () => { + const requests = []; + const source = createAssetDirectorySource({ + baseUrl: 'https://assets.example.org/packs/', + fetchImpl: async (...args) => { + requests.push(args); + return new Response(new Uint8Array([1, 2, 3]), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + assert.deepEqual( + (await source({ path: 'a/data.json' })).bytes, + asset().bytes, + ); + assert.equal(requests[0][0], 'https://assets.example.org/packs/a/data.json'); + assert.equal(requests[0][1].credentials, 'omit'); + assert.equal(requests[0][1].redirect, 'error'); + assert.equal(requests[0][1].referrerPolicy, 'no-referrer'); + await assert.rejects(source({ path: '../api/test' })); + assert.equal(requests.length, 1); + await assert.rejects(source({ path: 'a', maxBytes: 2 }), /byte limit/); + const missing = createAssetDirectorySource({ + baseUrl: 'https://example.org/packs/', + fetchImpl: async () => new Response('', { status: 404 }), + }); + await assert.rejects(missing({ path: 'a' }), /unavailable/); +}); + +test('GeoJSON preserves stable geometry IDs but never properties or remote style hints', () => { + const data = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'p', + properties: { title: '