mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
1302 lines
48 KiB
JavaScript
1302 lines
48 KiB
JavaScript
/**
|
|
* @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.<string, { enabled: boolean, params?: 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 <input type="file">
|
|
*/
|
|
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.<string, { enabled: boolean, params?: 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<boolean>} 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,
|
|
});
|
|
}
|
|
|
|
}
|