/** * @module scenes/director * * Deterministic cinematic scene playback engine for social-media clip capture. * * Manages a persistent project of scenes, each containing an ordered shot list. * Each shot stores camera position, visual style, post-processing state, HUD mode, * detection overlay, and data-layer toggles. During playback the director sequences * through shots with timed camera flights, hold pauses, and visual-state transitions, * while recording telemetry events for post-run metadata export. * * State is persisted to localStorage and can be exported/imported as JSON. */ import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from '../ui/scenes.js'; import * as Cesium from 'cesium'; import { SCENE_RECIPES } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION, decodeBloomIntensity, } from '../bloom.js'; /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; /** @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; /** * 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, 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) => ({ id: uid('shot'), 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, }, styleParams, }, layers: deepClone(layers), })); return { id: recipe.id || uid('scene'), title: recipe.title || 'Untitled Scene', 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(), 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)), }, styleParams: visual.styleParams && typeof visual.styleParams === 'object' ? deepClone(visual.styleParams) : {}, }, layers: Object.fromEntries( Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [layerId, normalizeLayerEntry(value)]) ), }; } /** * 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}`, 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(), scenes, }; } /** * Orchestrates deterministic cinematic scene playback. * * The director owns a mutable project (persisted in localStorage) containing * scenes and shots. It drives camera flights via Cesium, applies visual/style * state through the styleManager, toggles data layers via the dataManager, and * records timestamped telemetry events during each run for later export. */ export class SceneDirector { /** * @param {Cesium.Viewer} viewer - The Cesium viewer instance * @param {Object} styleManager - Controls visual state (bloom, sharpen, HUD, detection, style presets) * @param {Object} dataManager - Manages data layer enable/disable and per-layer params */ constructor(viewer, styleManager, dataManager) { this._destroyed = false; this._pendingWork = new Set(); this.viewer = viewer; this.styleManager = styleManager; this.dataManager = dataManager; /** @type {boolean} True while a scene run is in progress */ this._running = false; /** @type {{ cancelled: boolean }|null} Cancellation token for the active run */ this._runToken = null; /** @type {number} Monotonic LOAD counter — only the newest LOAD may land */ this._loadGeneration = 0; /** @type {AbortController|null} Aborts the active run's layer transitions */ 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; /** @type {Object|null} Telemetry accumulator for the current run */ this._activeRun = null; /** @type {Object|null} Telemetry from the most recent completed run */ this._lastRun = null; /** @type {string} JSON string of _lastRun for download */ this._lastRunJson = ''; this._project = this._loadProject(); this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false }; this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson })); this._initUI(); } _trackWork(promise) { this._pendingWork ||= new Set(); this._pendingWork.add(promise); const release = () => this._pendingWork.delete(promise); promise.then(release, release); return promise; } /** Stop playback and pending loads before releasing their viewer. */ destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; this._controls?.destroy(); this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { this.stopScene('Stopped'); this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); clearTimeout(this._storageToastTimer); await Promise.allSettled(this._pendingWork || []); clearInterval(this._progressTimer); }); return this._destroyPromise; } /** * Load and normalize the project from localStorage. * Returns the default recipe-based project on missing or corrupt data. * @returns {Object} Normalized project */ _loadProject() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return createDefaultProject(); return normalizeProject(JSON.parse(raw)); } catch { return createDefaultProject(); } } /** Persist the current project state to localStorage with an updated timestamp. */ _saveProject() { this._project.updatedAt = new Date().toISOString(); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this._project)); } catch (e) { // Private browsing / block-all-cookies / quota-exceeded throws here. The // in-memory project stays usable this session, but persistence failed — // tell the user instead of crashing the caller (M11). console.warn('[Scenes] Could not persist project (storage unavailable):', e); this._toastStorageError(); } } /** Surface a "scene not saved" notice via the global toast + scene status line. */ _toastStorageError() { const message = 'Scene not saved — browser storage unavailable'; this._updateStatus(message); try { const toast = document.getElementById('toast'); if (toast) { toast.textContent = message; toast.classList.add('visible'); clearTimeout(this._storageToastTimer); this._storageToastTimer = setTimeout(() => { toast.classList.remove('visible'); }, 2600); } } catch { /* toast is best-effort */ } } /** Immutable playback snapshots and completed editing actions. */ subscribe(listener, options) { return this._state.subscribe(listener, options); } _publish(change) { this._state?.publish(change); } _shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) { if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`; this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index }); } /** * Wire up all scene-panel DOM event listeners and render the initial UI state. * Exits silently if the scene-select element is missing (headless/test mode). */ _initUI() { this._controls = new SceneControls({ subscribe: (listener) => this.subscribe(listener), read: () => ({ scenes: this._project.scenes, selectedSceneId: this._selectedSceneId, selectedShotId: this._selectedShotId, running: this._running, hasRun: !!this._lastRunJson, }), actions: { selectScene: (id) => { this._selectedSceneId = id; this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null; this._renderShotList(); }, selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); }, renameShot: (sceneId, shotId, title) => { const { scene, shot } = this._getShot(sceneId, shotId); if (!shot) return; shot.title = title.trim() || shot.title; this._saveProject(); this._shotOutcome('shot-renamed', scene, shot); }, create: (name) => this._createScene(name), deleteScene: () => this._deleteSelectedScene(), capture: () => this.captureShot(), update: () => this.updateSelectedShot(), start: (id) => this.startScene(id), stop: (reason) => this.stopScene(reason), next: () => this.runNextScene(), export: () => this.exportProject(), import: (file) => this.importProjectFile(file), download: () => this.downloadLastRunMetadata(), load: (sceneId, shotId) => this.loadShot(sceneId, shotId), deleteShot: (sceneId, shotId) => this.deleteShot(sceneId, shotId), }, }); } /** Rebuild the scene dropdown options and sync the selected value. */ _renderSceneSelect() { if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) { this._selectedSceneId = this._project.scenes[0]?.id || null; } this._publish({ type: 'scene-options-changed' }); } /** * Rebuild the shot list DOM for the currently selected scene. * Each shot row shows title, style/detection/duration metadata, and * LOAD/DEL action buttons. Supports click-to-select and double-click rename. */ _renderShotList() { const scene = this._getSelectedScene(); if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) { this._selectedShotId = scene.shots[0].id; } this._publish({ type: 'shots-changed' }); } /** * Look up the currently selected scene object. * @returns {Object|null} The scene, or null if no valid selection */ _getSelectedScene() { return this._project.scenes.find((scene) => scene.id === this._selectedSceneId) || null; } /** * Resolve a scene and shot by their IDs. * @param {string} sceneId * @param {string} shotId * @returns {{ scene: Object|undefined, shot: Object|undefined }} */ _getShot(sceneId, shotId) { const scene = this._project.scenes.find((item) => item.id === sceneId); const shot = scene?.shots.find((item) => item.id === shotId); return { scene, shot }; } /** Append a named empty scene after the controls accept the creation prompt. */ _createScene(sceneName) { if (!sceneName) return; const scene = { id: uid('scene'), title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`, shots: [], }; this._project.scenes.push(scene); this._selectedSceneId = scene.id; this._selectedShotId = null; this._saveProject(); this._publish({ type: 'scene-created', scene }); } /** Delete the currently selected scene after user confirmation. Resets to defaults if empty. */ _deleteSelectedScene() { const scene = this._getSelectedScene(); if (!scene) return; this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id); // Restore default recipes if the user deleted all scenes if (!this._project.scenes.length) { this._project = createDefaultProject(); } this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._saveProject(); this._publish({ type: 'scene-deleted', scene }); } /** * Snapshot the current enabled/params state of every registered data layer. * @returns {Object.} */ _captureLayerStates() { const layers = {}; for (const layer of this.dataManager.getAll()) { const params = this.dataManager.getLayerParams(layer.id); layers[layer.id] = { enabled: !!layer.enabled, ...(params ? { params } : {}), }; } return layers; } /** * Capture the current camera position, visual state, and layer states as a * new shot appended to the selected scene. No-op if no scene is selected. */ captureShot() { const scene = this._getSelectedScene(); if (!scene) return; const camera = this.styleManager.getCameraState(); if (!camera) { this._updateStatus('Cannot capture shot: camera not ready'); return; } const shot = normalizeShot({ id: uid('shot'), title: `Shot ${scene.shots.length + 1}`, durationSec: DEFAULT_SHOT_DURATION_SEC, holdSec: DEFAULT_HOLD_SEC, camera, visual: this.styleManager.getVisualState(), layers: this._captureLayerStates(), }, scene.shots.length); scene.shots.push(shot); this._selectedShotId = shot.id; this._saveProject(); this._shotOutcome('shot-captured', scene, shot); this._updateStatus(`Captured: ${scene.title} / ${shot.title}`); } /** * Overwrite the currently selected shot's camera, visual, and layer states * with the live viewport state. Useful for fine-tuning a shot in-place. */ updateSelectedShot() { const scene = this._getSelectedScene(); if (!scene) return; const shot = scene.shots.find((item) => item.id === this._selectedShotId); if (!shot) { this._updateStatus('Select a shot first'); return; } const camera = this.styleManager.getCameraState(); if (!camera) return; shot.camera = camera; shot.visual = this.styleManager.getVisualState(); shot.layers = this._captureLayerStates(); this._saveProject(); this._shotOutcome('shot-updated', scene, shot); this._updateStatus(`Updated: ${scene.title} / ${shot.title}`); } /** * Delete a specific shot from a scene after user confirmation. * @param {string} sceneId * @param {string} shotId */ deleteShot(sceneId, shotId) { const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return; const index = scene.shots.indexOf(shot); scene.shots = scene.shots.filter((item) => item.id !== shot.id); this._selectedShotId = scene.shots[0]?.id || null; this._saveProject(); this._shotOutcome('shot-deleted', scene, shot, index); } /** * Load a single shot: apply its visual state, enable/disable layers, and fly * the camera to the shot's position. Blocked while a scene run is active. * * The newest LOAD wins. Two shot rows clicked in quick succession (or a * voice load landing on top of a click) both suspend on the visual and layer * awaits below; without a generation the OLDER request can complete second * and overwrite the operator's newer intent — the camera ends on shot A * while the panel reads shot B. * * @param {string} sceneId * @param {string} shotId * @param {Object} [options] * @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds */ loadShot(sceneId, shotId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); return this._trackWork(this._loadShot(sceneId, shotId, options)); } async _loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) { if (this._running) return; const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return; if (!this._claimCameraOwnership()) return; // Supersede the previous LOAD before reserving this one: aborting first // means an in-flight layer transition is cancelled (and rolled back by the // manager) rather than merely ignored once it has already committed. this._loadAbort?.abort(); const controller = new AbortController(); this._loadAbort = controller; const token = this._loadToken(++this._loadGeneration, controller.signal); this._selectedSceneId = scene.id; this._selectedShotId = shot.id; this._renderSceneSelect(); this._renderShotList(); await this.styleManager.applyVisualState(shot.visual, { isCurrent: () => !token.cancelled }); if (token.cancelled) return; await this._applyLayerStates(shot.layers || {}, token); if (token.cancelled) return; await this._flyCamera(shot.camera, flyDuration, token); if (token.cancelled) return; if (this._loadAbort === controller) this._loadAbort = null; this._shotOutcome('shot-loaded', scene, shot); this._updateRuntime(''); } /** * Cancellation token for one LOAD, reading "a newer LOAD (or a scene run) * has since been requested". Live rather than latched, so a supersession * that happens mid-await is seen the moment the await resolves; the signal * lets the awaited work itself be cancelled instead of merely disowned. * @param {number} generation - The generation this LOAD reserved * @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls * @returns {{ cancelled: boolean, signal: AbortSignal|undefined }} */ _loadToken(generation, signal = undefined) { const director = this; return { signal, get cancelled() { return director._loadGeneration !== generation; }, }; } /** * Claim camera ownership for a scene flight through the shared navigation * policy, releasing any tracked contact, voice orbit, or in-flight tween * first. Two writers on the camera is the documented jitter failure mode * (see src/data/trackedCamera.js and the orbit refusal in src/cameraVerbs.js), * and the policy is also where Cockpit gets to refuse. * @returns {boolean} False when the camera is unavailable (Cockpit/disposed). */ _claimCameraOwnership() { // 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); if (claimed === false) { this._updateStatus('Camera unavailable — exit cockpit first'); return false; } return true; } /** * Build a flat playback queue of { scene, shot } pairs starting from the * given scene and wrapping around through all remaining scenes (round-robin). * @param {string} startSceneId - Scene to begin playback from * @returns {Array<{ scene: Object, shot: Object }>} */ _buildPlaybackQueue(startSceneId, { single = false } = {}) { if (!this._project.scenes.length) return []; // Rotate the scene list so startSceneId comes first const startIdx = Math.max(0, this._project.scenes.findIndex((scene) => scene.id === startSceneId)); const ordered = single ? this._project.scenes.slice(startIdx, startIdx + 1) : [ ...this._project.scenes.slice(startIdx), ...this._project.scenes.slice(0, startIdx), ]; // Flatten scenes into a sequential shot queue const queue = []; for (const scene of ordered) { for (const shot of scene.shots) { queue.push({ scene, shot }); } } return queue; } /** * Lists scenes for voice/scripting consumers. * @returns {Array<{id: string, title: string, shots: number}>} */ listScenes() { return this._project.scenes.map((scene) => ({ id: scene.id, title: scene.title, shots: scene.shots.length, })); } /** * Finds a scene by id, exact title, or case-insensitive title substring. * @param {string} query - Scene id or (partial) title. * @returns {{id: string, title: string, shots: number}|null} */ findSceneByQuery(query) { const q = String(query ?? '').trim().toLowerCase(); if (!q) return null; const scene = this._project.scenes.find((item) => item.id === query) || this._project.scenes.find((item) => item.title.toLowerCase() === q) || this._project.scenes.find((item) => item.title.toLowerCase().includes(q)); return scene ? { id: scene.id, title: scene.title, shots: scene.shots.length } : null; } /** * Playback status snapshot for voice read-back. * @returns {{running: boolean, selectedSceneId: string|null, sceneCount: number}} */ getPlaybackStatus() { return { running: this._running, selectedSceneId: this._selectedSceneId, selectedShotId: this._selectedShotId, elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null, estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null, sceneCount: this._project.scenes.length, }; } /** @returns {boolean} Whether a scene run is currently in progress */ get running() { return this._running; } /** * Start a full scene run beginning at the given scene. * * Enters recording mode (hides panels, enables safe frame), then iterates * through the playback queue applying each shot's visual state, toggling * layers, flying the camera, and pausing for the hold duration. Each shot * transition is logged as a telemetry event. The run can be cancelled via * Escape or the stop button (checked between shots and during sleeps). * * @param {string} [sceneId] - Scene to start from; defaults to the current selection * @param {object} [options] * @param {boolean} [options.single=false] - Play only the named scene instead of * round-robining through the whole project (voice playback uses this). * @returns {Promise<{started: boolean, reason?: string, shots?: number}>} */ startScene(sceneId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); return this._trackWork(this._startScene(sceneId, options)); } async _startScene(sceneId, { single = false } = {}) { if (this._running) return { started: false, reason: 'already-running' }; const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single }); if (!queue.length) { this._updateStatus('No shots to run'); return { started: false, reason: 'no-shots' }; } // Playback owns the camera for the whole run, so claim it the way every // other camera consumer does. Without this the follow camera keeps writing // the tracked contact's frame while each shot flies, and the run ends with // trackedEntity still set half a world from where the camera actually is. if (!this._claimCameraOwnership()) { return { started: false, reason: 'camera-unavailable' }; } // A run supersedes any LOAD still suspended on its own awaits, so that // load cannot land a stale shot's layers on top of the run's first shot. // Aborting cancels a layer transition already in flight; bumping the // generation disowns everything the load has not yet started. this._loadAbort?.abort(); this._loadAbort = null; this._loadGeneration++; // Transition to running state this._running = true; this._setPlaybackActive(true); this._setButtons(true); this._setProgress(0); // Create a cancellation token shared across async steps. Held in a local // as well: _finishRun() clears this._runToken, so the loop must not read // cancellation off the instance after cleanup has started. Its signal is // what actually stops in-flight manager work when STOP arrives — the // boolean alone only stops the NEXT step. this._runAbort = new AbortController(); this._runToken = { cancelled: false, signal: this._runAbort.signal }; const token = this._runToken; this.styleManager.setRecordingMode(true, { hidePanels: true, hudMode: 'full', safeFrame: '16:9', }); // Pre-compute total duration for the progress bar const estimatedDurationSec = queue.reduce((sum, item) => { return sum + (item.shot.durationSec || 0) + (item.shot.holdSec || 0); }, 0); // Initialize telemetry accumulator for this run this._activeRun = { recipeId: `project-${PROJECT_VERSION}`, title: 'Editable Scene Run', startedAt: new Date().toISOString(), estimatedDurationSec, scenesRun: queue.length, events: [], }; this._startProgressTicker(estimatedDurationSec || 1); this._logEvent('scene_run_start', { count: queue.length }); this._setPlaybackKeyboardEnabled(true); try { // Main shot sequencing loop for (let idx = 0; idx < queue.length; idx++) { if (token.cancelled) break; const { scene, shot } = queue[idx]; // Update UI selection to track the active shot this._selectedSceneId = scene.id; this._selectedShotId = shot.id; this._renderSceneSelect(); this._renderShotList(); this._updateStatus(`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`); this._updateRuntime(`${scene.title} · ${shot.title}`); this._logEvent('shot_start', { sceneId: scene.id, shotId: shot.id, title: shot.title, index: idx, }); // Apply visual state (style, bloom, sharpen, HUD, detection) then layers, then fly. // Awaited: applyVisualState suspends on a map-stack switch, and a shot // captured by the operator carries one — un-awaited, its shader uniforms // land after the NEXT shot has already been applied. // // Every await is a place STOP/Esc can arrive. A suspended map-stack // switch can hold this shot for seconds; without a re-check the layer // pass below still runs and the operator watches layers keep toggling // after they hit Stop. Cancellation is re-read after each one. await this.styleManager.applyVisualState(shot.visual || {}, { isCurrent: () => !token.cancelled, }); if (token.cancelled) break; await this._applyLayerStates(shot.layers || {}, token); if (token.cancelled) break; await this._flyCamera(shot.camera, shot.durationSec || DEFAULT_SHOT_DURATION_SEC, token); if (token.cancelled) break; // Hold on the final frame before transitioning to the next shot await this._sleep((shot.holdSec || 0) * 1000, token); if (token.cancelled) break; this._logEvent('shot_end', { sceneId: scene.id, shotId: shot.id, index: idx, }); } if (!token.cancelled) { this._setProgress(1); this._updateStatus('Scene run complete'); this._logEvent('scene_run_complete', {}); } } catch (error) { this._updateStatus(`Error: ${error.message || 'run failed'}`); this._logEvent('scene_run_error', { message: error.message || 'unknown error' }); } finally { this._finishRun(); } } /** * Advance to the next shot in the playback queue (wrapping around) and load * it without starting a full run. Used for manual step-through navigation. */ async runNextScene() { if (this._destroyed || this._running) return; const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id); if (!queue.length) return; // Find the shot after the current selection, wrapping to the start let next = queue[0]; if (this._selectedShotId) { const idx = queue.findIndex((item) => item.shot.id === this._selectedShotId); if (idx >= 0) next = queue[(idx + 1) % queue.length]; } await this.loadShot(next.scene.id, next.shot.id); } /** * Cancel the active scene run. Sets the cancellation token, aborts the * run's in-flight layer transitions and Cesium camera flight, and logs a * stop event. * * The abort is the part that stops work already under way: a layer * transition awaited by _applyLayerStates cannot see a boolean, and without * the signal it commits after the operator has stopped — leaving the layer * enabled while the post-await check skips its params. The manager rolls an * aborted enable back through the module's own disable(). * * @param {string} [reason='Stopped'] - Human-readable cancellation reason */ stopScene(reason = 'Stopped') { if (!this._running || !this._runToken) return; this._runToken.cancelled = true; this._runAbort?.abort(); this.viewer.camera.cancelFlight(); this._updateStatus(reason); this._logEvent('scene_stopped', { reason }); } /** Export the entire project as a timestamped JSON file download. */ exportProject() { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const fileName = `scene-presets-${stamp}.json`; const payload = JSON.stringify(this._project, null, 2); // Trigger a browser download via a temporary anchor element const blob = new Blob([payload], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); this._presentation.status = 'Project exported'; this._publish({ type: 'project-exported', project: this._project }); } /** * Import a project from a user-selected JSON file, replacing the current project. * The file is normalized/migrated on load; invalid JSON shows an error status. * @param {File} file - Browser File object from an */ async importProjectFile(file) { try { 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; this._saveProject(); this._publish({ type: 'project-imported', project: this._project }); this._updateStatus(`Imported ${file.name}`); } catch { this._updateStatus('Import failed (invalid JSON)'); } } /** Download the telemetry metadata from the most recent completed run as JSON. */ downloadLastRunMetadata() { if (!this._lastRunJson) return; const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const fileName = `scene-run-${stamp}.json`; const blob = new Blob([this._lastRunJson], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } /** * Reconcile data layer states to match the shot's target configuration. * Only layers the shot declares are touched — see src/scenes/scenePolicy.js * for why undeclared layers are left alone. * * Two things this pass owes the operator: * - An isolating Context mode is left FIRST. Space Missions refuses every * unrelated enable, so a shot applied inside it composes a scene nobody * authored (see _exitIsolatingContextMode). * - A refused layer is reported. setEnabled() answers false when a guard * vetoes the transition; swallowing that answer is how playback came to * claim success over a scene it never assembled. * * The token's signal is handed to every transition, so a stop or a newer * request cancels the layer that is CURRENTLY moving rather than only the * ones not started yet. Checking the boolean after the await is a backstop: * by then an un-aborted transition has already committed, and the pass would * return without its params — the layer left on with stale ones. * * Cancellation is read BEFORE the refusal branch on purpose: an aborted * transition also answers false, and reporting the operator's own stop as a * refused layer would be a lie. * * @param {Object.} targetStates * @param {{ cancelled: boolean, signal?: AbortSignal }|null} [token] * Cancellation token — a stop or a newer request ends the pass and aborts * the transition in flight. * @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>} */ async _applyLayerStates(targetStates, token = null) { const applied = []; const refused = []; const abort = () => ({ applied, refused, cancelled: true }); if (token?.cancelled) return abort(); // Deliberately NOT aborted: leaving an isolating mode IS the restore to // the operator's pre-mode state, which is exactly where a stopped scene // should come to rest. Tearing that transaction in half would strand // Context, so it completes and cancellation is honoured immediately after. await this._exitIsolatingContextMode(); if (token?.cancelled) return abort(); const signal = token?.signal; const registered = new Set(this.dataManager.getAll().map((layer) => layer.id)); for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) { const settled = await this.dataManager.setEnabled(id, enabled, signal ? { signal } : undefined); if (token?.cancelled) return abort(); if (settled === false) { refused.push(id); console.warn(`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`); continue; } applied.push(id); if (params) { this.dataManager.setLayerParams(id, params); } } if (refused.length) { this._updateStatus(`Layers refused: ${refused.join(', ')}`); this._logEvent('shot_layers_refused', { layerIds: [...refused] }); } return { applied, refused, cancelled: false }; } /** * Leave a Context mode that isolates the globe, before a shot's layers land. * * Space Missions is the shipped case. It is a destructive-exclusive mode: a * guard refuses every enable outside its own replay bundle, so a recipe that * declares flights/satellites/earthquakes/traffic gets all four refused — * and Orbital Watch, whose satellites the guard does permit, would still * play over the mode's rocket-launches replay it never declared. Either way * the shot is not the composition it describes. * * The old full-registry reconcile dismantled the mode by accident, as part * of forcing every undeclared layer off. Declaring the exit is the honest * version of that: the decision is read off the policy guard itself, so a * future isolating mode is covered without being named here. * * @returns {Promise} Whether a mode was exited. */ async _exitIsolatingContextMode() { // Older/headless style managers may predate the Context facade. if (typeof this.styleManager?.getContextModeState !== 'function') return false; if (typeof this.styleManager?.setContextMode !== 'function') return false; const state = this.styleManager.getContextModeState() || {}; // A mode still being entered already owns the guard, so it counts. const mode = state.entering || state.mode || null; if (!sceneRequiresContextModeExit(mode)) return false; const result = await this.styleManager.setContextMode('off'); if (result && result.ok === false) { console.warn(`[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason'); this._updateStatus(`Could not exit ${mode} — scene layers may be refused`); this._logEvent('context_mode_exit_failed', { mode, error: result.error || null }); return false; } this._logEvent('context_mode_exited', { mode }); return true; } /** * Fly the Cesium camera to the given position over the specified duration * using cubic ease-in-out. Resolves when the flight completes, is cancelled, * or a safety timeout fires (duration + 0.6s). * @param {Object} cameraState - Target { lat, lon, alt, heading, pitch, roll } * @param {number} durationSec - Flight duration in seconds * @param {{ cancelled: boolean }} token - Cancellation token checked before starting */ async _flyCamera(cameraState, durationSec, token) { if (!cameraState || token.cancelled) return; // Scene playback drives the camera itself rather than through the shared // navigation seam, so the LOCATION readout would otherwise keep reporting // a free-text search the shot has already flown away from. this.styleManager?.clearSearchedLocation?.(); const duration = Math.max(0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC); const destination = Cesium.Cartesian3.fromDegrees( cameraState.lon, cameraState.lat, cameraState.alt ); await new Promise((resolve) => { // Guard against double-resolve from both callback and timeout let done = false; let timer; const finish = () => { if (done) return; done = true; clearTimeout(timer); resolve(); }; this.viewer.camera.flyTo({ destination, orientation: { heading: Cesium.Math.toRadians(cameraState.heading || 0), pitch: Cesium.Math.toRadians(cameraState.pitch || -35), roll: Cesium.Math.toRadians(cameraState.roll || 0), }, duration, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, complete: finish, cancel: finish, }); // Safety timeout in case Cesium callbacks fail to fire if (!done) timer = setTimeout(finish, (duration + 0.6) * 1000); }); } /** * Cancellable sleep that polls the token every ~70ms, allowing prompt * interruption without waiting for the full duration. * @param {number} ms - Total sleep time in milliseconds * @param {{ cancelled: boolean }} token - Cancellation token */ 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)); } } /** * Start an interval that updates the progress bar based on wall-clock * elapsed time relative to the estimated total run duration. * @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); } /** * Clean up after a scene run (whether completed, errored, or cancelled). * Stops the progress ticker, exits recording mode, finalizes telemetry, * and resets UI buttons to the idle state. */ _finishRun() { clearInterval(this._progressTimer); this._progressTimer = null; 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. this._runAbort?.abort(); this._runAbort = null; this.styleManager.setRecordingMode(false); this._setPlaybackActive(false); this._updateRuntime(''); this._running = false; // Finalize telemetry and archive it for download if (this._activeRun) { this._activeRun.endedAt = new Date().toISOString(); this._activeRun.wasCancelled = this._runToken?.cancelled || false; this._lastRun = this._activeRun; this._lastRunJson = JSON.stringify(this._activeRun, null, 2); this._activeRun = null; } this._runToken = null; this._setButtons(false); } /** * Toggle disabled state on all scene panel buttons based on run state. * Editing controls are disabled during a run; stop is disabled when idle. * @param {boolean} isRunning */ _setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); } _setPlaybackActive(active) { this._presentation.playbackActive = active; this._publish({ type: 'playback-presentation' }); } _setPlaybackKeyboardEnabled(enabled) { this._presentation.keyboardEnabled = enabled; this._publish({ type: 'playback-keyboard' }); } /** * Update the progress bar fill width and label. * @param {number} progress - Value in [0, 1] */ _setProgress(progress) { this._presentation.progress = progress; this._publish({ type: 'progress-changed' }); } /** * Set the status line text in the scene panel. * @param {string} text */ _updateStatus(text) { this._presentation.status = text; this._publish({ type: 'status-changed' }); } /** * Set the runtime label (scene/shot name) and toggle its active class. * @param {string} text - Empty string hides the label */ _updateRuntime(text) { this._presentation.runtime = text; this._publish({ type: 'runtime-changed' }); } /** * Append a timestamped telemetry event to the active run log. * @param {string} type - Event type identifier (e.g. 'shot_start', 'scene_stopped') * @param {Object|null} payload - Arbitrary event data */ _logEvent(type, payload) { if (!this._activeRun) return; this._publish({ type: 'run-event', event: type, detail: payload || null }); this._activeRun.events.push({ t: new Date().toISOString(), type, payload: payload || null, }); } }