mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(director): validate scene documents and preserve saved edits
This commit is contained in:
1 parent
c894d7b920
commit
18300e7e00
13 files changed
+1155
-404
No files matched your search
+75
-388
@@ -23,7 +23,6 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { MAP_STACKS } from '../maps/catalog.js';
|
||||
import { createDefaultScenePacks } from './packs/defaults.js';
|
||||
import {
|
||||
layerStatesForShot,
|
||||
@@ -34,380 +33,27 @@ import {
|
||||
import { sceneTimingForShot, sceneSeekState } from '../director/timeline.js';
|
||||
import { createPlaybackClock } from '../director/clock.js';
|
||||
import {
|
||||
BLOOM_INTENSITY_DEFAULT,
|
||||
BLOOM_SCALE_VERSION,
|
||||
decodeBloomIntensity,
|
||||
} from '../bloom.js';
|
||||
normalizeLayerEntry,
|
||||
PROJECT_VERSION,
|
||||
DEFAULT_SHOT_DURATION_SEC,
|
||||
DEFAULT_HOLD_SEC,
|
||||
uid,
|
||||
deepClone,
|
||||
recipeToScene,
|
||||
createDefaultProject,
|
||||
normalizeShot,
|
||||
normalizeProject,
|
||||
} from './project.js';
|
||||
import {
|
||||
parseSceneDocument,
|
||||
stringifySceneDocument,
|
||||
SCENE_DOCUMENT_LIMITS,
|
||||
SceneDocumentError,
|
||||
} from '../director/document.js';
|
||||
|
||||
/** @constant {string} localStorage key for the serialized project */
|
||||
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
|
||||
const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1';
|
||||
/** @constant {number} Current schema version for project migration */
|
||||
const PROJECT_VERSION = 3;
|
||||
/** @constant {number} Fallback camera flight duration per shot (seconds) */
|
||||
const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||
/** @constant {number} Default hold/pause after a shot completes (seconds) */
|
||||
const DEFAULT_HOLD_SEC = 0.9;
|
||||
const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id));
|
||||
|
||||
/**
|
||||
* Generate a short random identifier with the given prefix.
|
||||
* @param {string} prefix - e.g. 'shot', 'scene'
|
||||
* @returns {string} Identifier like "shot-a1b2c3d4"
|
||||
*/
|
||||
function uid(prefix) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a JSON-serializable value via round-trip stringify/parse.
|
||||
* @param {*} value
|
||||
* @returns {*}
|
||||
*/
|
||||
function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw layer state entry into a canonical { enabled, params? } shape.
|
||||
* Accepts both boolean shorthand and full object forms.
|
||||
* @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params })
|
||||
* @returns {{ enabled: boolean, params?: Object }}
|
||||
*/
|
||||
function normalizeLayerEntry(entry) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
return {
|
||||
enabled: !!entry.enabled,
|
||||
params:
|
||||
entry.params && typeof entry.params === 'object'
|
||||
? deepClone(entry.params)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { enabled: !!entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw bloom post-processing state, migrating intensity values
|
||||
* across bloom scale versions so older saved projects render correctly.
|
||||
* @param {Object} rawBloom - Raw bloom state from storage or recipe
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.projectVersion] - Schema version of the source project
|
||||
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
|
||||
* @returns {{ enabled: boolean, intensity: number, version: number }}
|
||||
*/
|
||||
function normalizeBloomState(
|
||||
rawBloom = {},
|
||||
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||
) {
|
||||
// Determine which bloom scale the stored value was encoded under.
|
||||
// Older projects (version < PROJECT_VERSION) used scale version 1.
|
||||
const explicitVersion = Number(rawBloom.version);
|
||||
const bloomVersion = Number.isFinite(explicitVersion)
|
||||
? explicitVersion
|
||||
: projectVersion >= PROJECT_VERSION
|
||||
? BLOOM_SCALE_VERSION
|
||||
: 1;
|
||||
|
||||
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
|
||||
? Number(rawBloom.intensity)
|
||||
: fallbackIntensity;
|
||||
|
||||
return {
|
||||
enabled: !!rawBloom.enabled,
|
||||
intensity: decodeBloomIntensity(rawIntensity, bloomVersion),
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a static scene recipe (from recipes.js) into a mutable scene object
|
||||
* with fully normalized shots. Each keyframe in the recipe's cameraPath becomes
|
||||
* one shot, inheriting the recipe's style, post, and layer configuration.
|
||||
* @param {Object} recipe - A SCENE_RECIPES entry
|
||||
* @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }}
|
||||
*/
|
||||
function recipeToScene(recipe) {
|
||||
const post = recipe.post || {};
|
||||
const ui = recipe.ui || {};
|
||||
const styleParams =
|
||||
post.styleParams && typeof post.styleParams === 'object'
|
||||
? deepClone(post.styleParams)
|
||||
: {};
|
||||
|
||||
// Normalize layer targets from the recipe into canonical form
|
||||
const layers = {};
|
||||
for (const [layerId, target] of Object.entries(recipe.layers || {})) {
|
||||
layers[layerId] = normalizeLayerEntry(target);
|
||||
}
|
||||
|
||||
// Derive HUD visibility/variant from recipe's ui.hudMode string
|
||||
const hudMode = ui.hudMode || 'minimal';
|
||||
const hudVisible = hudMode !== 'off';
|
||||
const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical';
|
||||
|
||||
// Convert each cameraPath keyframe into a shot with shared visual state
|
||||
const path = recipe.cameraPath || [];
|
||||
const shots = path.map((keyframe, idx) => {
|
||||
const keyframeLayers = deepClone(layers);
|
||||
for (const [layerId, target] of Object.entries(keyframe.layers || {})) {
|
||||
keyframeLayers[layerId] = normalizeLayerEntry(target);
|
||||
}
|
||||
const mapStack = keyframe.mapStack || post.mapStack;
|
||||
return {
|
||||
id: uid('shot'),
|
||||
title: keyframe.title || `Shot ${idx + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
keyframe.duration || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, keyframe.hold || 0),
|
||||
camera: {
|
||||
lat: keyframe.lat,
|
||||
lon: keyframe.lon,
|
||||
alt: keyframe.alt,
|
||||
heading: keyframe.heading || 0,
|
||||
pitch: keyframe.pitch || -40,
|
||||
roll: keyframe.roll || 0,
|
||||
},
|
||||
visual: {
|
||||
style: recipe.style || 'normal',
|
||||
bloom: {
|
||||
enabled:
|
||||
typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
|
||||
intensity:
|
||||
typeof post.bloom === 'number'
|
||||
? decodeBloomIntensity(post.bloom, 1)
|
||||
: BLOOM_INTENSITY_DEFAULT,
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
},
|
||||
sharpen: {
|
||||
enabled:
|
||||
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
||||
intensity: 65,
|
||||
},
|
||||
hud: {
|
||||
visible: hudVisible,
|
||||
variant: hudVariant,
|
||||
},
|
||||
detection: {
|
||||
mode: post.detectionMode || 'OFF',
|
||||
density: 35,
|
||||
},
|
||||
...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}),
|
||||
styleParams,
|
||||
},
|
||||
layers: keyframeLayers,
|
||||
...(recipe.id ? { sourcePackId: recipe.id } : {}),
|
||||
...(recipe.version ? { sourcePackVersion: recipe.version } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: recipe.id || uid('scene'),
|
||||
title: recipe.title || 'Untitled Scene',
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(recipe.releaseLayerIds)
|
||||
? recipe.releaseLayerIds
|
||||
: []
|
||||
).filter((layerId) => typeof layerId === 'string' && layerId.trim()),
|
||||
),
|
||||
],
|
||||
appliedShotPacks: [],
|
||||
shots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh default project by converting all built-in SCENE_RECIPES.
|
||||
* @returns {Object} A new project object with version, timestamps, and scenes
|
||||
*/
|
||||
function createDefaultProject() {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
installedBuiltInSceneIds: SCENE_RECIPES.filter(
|
||||
(recipe) => typeof recipe.installAlongsideSceneId === 'string',
|
||||
).map((recipe) => recipe.id),
|
||||
scenes: SCENE_RECIPES.map(recipeToScene),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw shot object from storage or capture into a fully validated
|
||||
* shape with safe defaults for every field. Handles version migration for
|
||||
* bloom intensity and coerces all numeric fields.
|
||||
* @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema)
|
||||
* @param {number} [index=0] - Positional index used for fallback title
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.projectVersion] - Schema version of the enclosing project
|
||||
* @returns {Object} Fully normalized shot
|
||||
*/
|
||||
function normalizeShot(
|
||||
rawShot,
|
||||
index = 0,
|
||||
{ projectVersion = PROJECT_VERSION } = {},
|
||||
) {
|
||||
const camera = rawShot?.camera || {};
|
||||
const visual = rawShot?.visual || {};
|
||||
const bloom = visual.bloom || {};
|
||||
const sharpen = visual.sharpen || {};
|
||||
const hud = visual.hud || {};
|
||||
const detection = visual.detection || {};
|
||||
|
||||
return {
|
||||
id: rawShot?.id || uid('shot'),
|
||||
title: rawShot?.title || `Shot ${index + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||
camera: {
|
||||
lat: Number(camera.lat) || 0,
|
||||
lon: Number(camera.lon) || 0,
|
||||
alt: Math.max(100, Number(camera.alt) || 800),
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number(camera.pitch) || -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
},
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
projectVersion,
|
||||
fallbackIntensity: 50,
|
||||
}),
|
||||
sharpen: {
|
||||
enabled: !!sharpen.enabled,
|
||||
intensity: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(sharpen.intensity))
|
||||
? Number(sharpen.intensity)
|
||||
: 65,
|
||||
),
|
||||
),
|
||||
},
|
||||
hud: {
|
||||
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
|
||||
variant: typeof hud.variant === 'string' ? hud.variant : 'tactical',
|
||||
},
|
||||
detection: {
|
||||
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
|
||||
density: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(detection.density))
|
||||
? Number(detection.density)
|
||||
: 35,
|
||||
),
|
||||
),
|
||||
},
|
||||
...(SCENE_MAP_STACK_IDS.has(visual.mapStack)
|
||||
? { mapStack: visual.mapStack }
|
||||
: {}),
|
||||
styleParams:
|
||||
visual.styleParams && typeof visual.styleParams === 'object'
|
||||
? deepClone(visual.styleParams)
|
||||
: {},
|
||||
},
|
||||
layers: Object.fromEntries(
|
||||
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
|
||||
layerId,
|
||||
normalizeLayerEntry(value),
|
||||
]),
|
||||
),
|
||||
...(typeof rawShot?.sourcePackId === 'string'
|
||||
? { sourcePackId: rawShot.sourcePackId }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(rawShot?.sourcePackVersion))
|
||||
? { sourcePackVersion: Number(rawShot.sourcePackVersion) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and migrate an entire project object loaded from storage or import.
|
||||
* Falls back to the default recipe-based project when input is invalid or empty.
|
||||
* @param {Object|null} rawProject - Raw project data (potentially from an older schema)
|
||||
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
|
||||
*/
|
||||
function normalizeProject(rawProject) {
|
||||
if (!rawProject || typeof rawProject !== 'object')
|
||||
return createDefaultProject();
|
||||
const projectVersion = Number.isFinite(Number(rawProject.version))
|
||||
? Number(rawProject.version)
|
||||
: 1;
|
||||
|
||||
const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : [];
|
||||
const scenes = scenesRaw
|
||||
.map((scene, sceneIdx) => {
|
||||
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
|
||||
const shots = shotsRaw.map((shot, shotIdx) =>
|
||||
normalizeShot(shot, shotIdx, { projectVersion }),
|
||||
);
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(scene?.releaseLayerIds)
|
||||
? scene.releaseLayerIds
|
||||
: []
|
||||
).filter(
|
||||
(layerId) => typeof layerId === 'string' && layerId.trim(),
|
||||
),
|
||||
),
|
||||
],
|
||||
appliedShotPacks: (Array.isArray(scene?.appliedShotPacks)
|
||||
? scene.appliedShotPacks
|
||||
: []
|
||||
)
|
||||
.filter((entry) => typeof entry?.id === 'string')
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: Number(entry.version) || 1,
|
||||
...(entry.shotBindings && typeof entry.shotBindings === 'object'
|
||||
? {
|
||||
shotBindings: Object.fromEntries(
|
||||
Object.entries(entry.shotBindings).filter(
|
||||
([title, shotId]) =>
|
||||
typeof title === 'string' && typeof shotId === 'string',
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
shots,
|
||||
};
|
||||
})
|
||||
// Keep scenes that have shots or at least a title
|
||||
.filter((scene) => scene.shots.length > 0 || scene.title);
|
||||
|
||||
if (!scenes.length) {
|
||||
return createDefaultProject();
|
||||
}
|
||||
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
createdAt: rawProject.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
installedBuiltInSceneIds: [
|
||||
...new Set(
|
||||
(Array.isArray(rawProject.installedBuiltInSceneIds)
|
||||
? rawProject.installedBuiltInSceneIds
|
||||
: []
|
||||
).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()),
|
||||
),
|
||||
],
|
||||
scenes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates deterministic cinematic scene playback.
|
||||
*
|
||||
@@ -475,7 +121,9 @@ export class SceneDirector {
|
||||
this._loadedSceneId = null;
|
||||
|
||||
this._presentation = {
|
||||
status: 'Ready',
|
||||
status: this._storageReadError
|
||||
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
|
||||
: 'Ready',
|
||||
progress: 0,
|
||||
runtime: '',
|
||||
playbackActive: false,
|
||||
@@ -555,7 +203,7 @@ export class SceneDirector {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return createDefaultProject();
|
||||
const project = normalizeProject(JSON.parse(raw));
|
||||
const project = normalizeProject(parseSceneDocument(raw));
|
||||
const installed = new Set(project.installedBuiltInSceneIds || []);
|
||||
let migrated = false;
|
||||
for (const recipe of SCENE_RECIPES) {
|
||||
@@ -586,7 +234,9 @@ export class SceneDirector {
|
||||
if (migrated) {
|
||||
project.installedBuiltInSceneIds = [...installed];
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||
const payload = JSON.stringify(project);
|
||||
parseSceneDocument(payload);
|
||||
localStorage.setItem(STORAGE_KEY, payload);
|
||||
} catch (e) {
|
||||
// Keep the migrated in-memory project usable even if this origin
|
||||
// refuses persistence; the normal UI save path will surface errors.
|
||||
@@ -597,16 +247,26 @@ export class SceneDirector {
|
||||
}
|
||||
}
|
||||
return project;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Never overwrite an unreadable or newer saved document with defaults.
|
||||
this._storageReadError = error;
|
||||
return createDefaultProject();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the current project state to localStorage with an updated timestamp. */
|
||||
_saveProject() {
|
||||
if (this._storageReadError) {
|
||||
this._toastStorageError(
|
||||
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._project.updatedAt = new Date().toISOString();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._project));
|
||||
const payload = JSON.stringify(this._project);
|
||||
parseSceneDocument(payload);
|
||||
localStorage.setItem(STORAGE_KEY, payload);
|
||||
} catch (e) {
|
||||
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
||||
// in-memory project stays usable this session, but persistence failed —
|
||||
@@ -615,13 +275,18 @@ export class SceneDirector {
|
||||
'[Scenes] Could not persist project (storage unavailable):',
|
||||
e,
|
||||
);
|
||||
this._toastStorageError();
|
||||
this._toastStorageError(
|
||||
e instanceof SceneDocumentError
|
||||
? `Scene not saved — ${e.message}`
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Surface a "scene not saved" notice via the global toast + scene status line. */
|
||||
_toastStorageError() {
|
||||
const message = 'Scene not saved — browser storage unavailable';
|
||||
_toastStorageError(
|
||||
message = 'Scene not saved — browser storage unavailable',
|
||||
) {
|
||||
this._updateStatus(message);
|
||||
try {
|
||||
const toast = document.getElementById('toast');
|
||||
@@ -2038,7 +1703,13 @@ export class SceneDirector {
|
||||
exportProject() {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `scene-presets-${stamp}.json`;
|
||||
const payload = JSON.stringify(this._project, null, 2);
|
||||
let payload;
|
||||
try {
|
||||
payload = stringifySceneDocument(this._project);
|
||||
} catch (error) {
|
||||
this._updateStatus(`Export failed: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
// Trigger a browser download via a temporary anchor element
|
||||
const blob = new Blob([payload], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -2059,18 +1730,34 @@ export class SceneDirector {
|
||||
* @param {File} file - Browser File object from an <input type="file">
|
||||
*/
|
||||
async importProjectFile(file) {
|
||||
const generation = (this._importGeneration =
|
||||
(this._importGeneration || 0) + 1);
|
||||
try {
|
||||
if (file.size > SCENE_DOCUMENT_LIMITS.bytes) {
|
||||
throw new SceneDocumentError('$', 'file exceeds 5 MiB');
|
||||
}
|
||||
const text = await file.text();
|
||||
if (this._destroyed) return;
|
||||
const parsed = JSON.parse(text);
|
||||
this._project = normalizeProject(parsed);
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
||||
if (this._destroyed || generation !== this._importGeneration) return;
|
||||
const project = normalizeProject(parseSceneDocument(text));
|
||||
// Validate before touching playback, selection or the saved project.
|
||||
this.stopScene('Importing project');
|
||||
await Promise.allSettled([...(this._pendingWork || [])]);
|
||||
if (this._destroyed || generation !== this._importGeneration) return;
|
||||
this._project = project;
|
||||
this._storageReadError = null;
|
||||
this._selectedSceneId = project.scenes[0]?.id || null;
|
||||
this._selectedShotId = project.scenes[0]?.shots[0]?.id || null;
|
||||
this._loadedSceneId = null;
|
||||
this._saveProject();
|
||||
this._publish({ type: 'project-imported', project: this._project });
|
||||
this._publish({ type: 'project-imported', project });
|
||||
this._updateStatus(`Imported ${file.name}`);
|
||||
} catch {
|
||||
this._updateStatus('Import failed (invalid JSON)');
|
||||
} catch (error) {
|
||||
if (this._destroyed || generation !== this._importGeneration) return;
|
||||
this._updateStatus(
|
||||
error instanceof SceneDocumentError
|
||||
? `Import failed: ${error.message}`
|
||||
: 'Import failed (could not read JSON file)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -614,10 +614,11 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
|
||||
removeEventListener() {},
|
||||
body: { classList: noopClassList, appendChild() {} },
|
||||
};
|
||||
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify(project),
|
||||
setItem() {},
|
||||
removeItem() {},
|
||||
getItem: (key) => stored.get(key) ?? null,
|
||||
setItem: (key, value) => stored.set(key, value),
|
||||
removeItem: (key) => stored.delete(key),
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -1397,3 +1398,96 @@ test('Scene load outcomes exclude superseded and disposed completions', async ()
|
||||
assert.equal(seen.length, count);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('invalid and unsupported imports retain the current project, selection and saved bytes', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const project = director._project;
|
||||
const selected = director._selectedShotId;
|
||||
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
for (const value of ['{}', '{"version":99,"scenes":[]}', '{"scenes":[{"shots":"bad"}]}']) {
|
||||
await director.importProjectFile({ name: 'bad.json', text: async () => value });
|
||||
assert.equal(director._project, project);
|
||||
assert.equal(director._selectedShotId, selected);
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||
assert.match(director._presentation.status, /Import failed: \$/);
|
||||
}
|
||||
let read = false;
|
||||
await director.importProjectFile({ size: 6 * 1024 * 1024, text: async () => { read = true; } });
|
||||
assert.equal(read, false);
|
||||
assert.equal(director._project, project);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('newer imports win delayed file reads, and an empty project is preserved', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
let release;
|
||||
const older = director.importProjectFile({ name: 'old.json', text: () => new Promise((resolve) => { release = resolve; }) });
|
||||
await director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
|
||||
release(JSON.stringify(PROJECT_FIXTURE));
|
||||
await older;
|
||||
assert.deepEqual(director._project.scenes, []);
|
||||
assert.equal(director._selectedSceneId, null);
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes, []);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('unsupported stored documents cannot be overwritten by fallback edits', async () => {
|
||||
const { director, restore } = makeDirector({ project: { version: 99, scenes: [] } });
|
||||
try {
|
||||
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
assert.ok(director._storageReadError);
|
||||
director._project.scenes[0].title = 'Fallback edit';
|
||||
director._saveProject();
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
||||
assert.equal(director._storageReadError, null);
|
||||
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 3);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('valid import settles a cancelled load before replacing the project', async () => {
|
||||
const { director, styleManager, restore } = makeDirector();
|
||||
let release;
|
||||
styleManager.applyVisualState = () => new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
const before = director._project;
|
||||
const load = director.loadShot('scene-1', 'shot-a');
|
||||
await settle();
|
||||
const importing = director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
|
||||
await settle();
|
||||
assert.equal(director._project, before, 'old work still owns cleanup');
|
||||
release(true);
|
||||
await Promise.all([load, importing]);
|
||||
assert.deepEqual(director._project.scenes, []);
|
||||
assert.equal(director._pendingWork.size, 0);
|
||||
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a delayed import cannot publish after disposal', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
let release;
|
||||
try {
|
||||
const before = director._project;
|
||||
const reading = director.importProjectFile({ name: 'empty.json', text: () => new Promise((resolve) => { release = resolve; }) });
|
||||
await director.destroy();
|
||||
release('{"version":3,"scenes":[]}');
|
||||
await reading;
|
||||
assert.equal(director._project, before);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('invalid authored edits cannot persist an unreadable project over a good save', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const before = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
let notice;
|
||||
director._toastStorageError = (message) => { notice = message; };
|
||||
director._project.scenes[0].shots[0].camera.lat = 91;
|
||||
director._saveProject();
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before);
|
||||
assert.match(notice, /camera.lat/);
|
||||
} finally { restore(); }
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/** Authored project defaults and legacy migrations; no editor or playback state. */
|
||||
import { SCENE_DOCUMENT_VERSION } from '../director/document.js';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
import { MAP_STACKS } from '../maps/catalog.js';
|
||||
import {
|
||||
BLOOM_INTENSITY_DEFAULT,
|
||||
BLOOM_SCALE_VERSION,
|
||||
decodeBloomIntensity,
|
||||
} from '../bloom.js';
|
||||
|
||||
/** @constant {number} Current schema version for project migration */
|
||||
export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION;
|
||||
/** @constant {number} Fallback camera flight duration per shot (seconds) */
|
||||
export const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||
/** @constant {number} Default hold/pause after a shot completes (seconds) */
|
||||
export const DEFAULT_HOLD_SEC = 0.9;
|
||||
const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id));
|
||||
|
||||
/**
|
||||
* Generate a short random identifier with the given prefix.
|
||||
* @param {string} prefix - e.g. 'shot', 'scene'
|
||||
* @returns {string} Identifier like "shot-a1b2c3d4"
|
||||
*/
|
||||
export function uid(prefix) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a JSON-serializable value via round-trip stringify/parse.
|
||||
* @param {*} value
|
||||
* @returns {*}
|
||||
*/
|
||||
export function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw layer state entry into a canonical { enabled, params? } shape.
|
||||
* Accepts both boolean shorthand and full object forms.
|
||||
* @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params })
|
||||
* @returns {{ enabled: boolean, params?: Object }}
|
||||
*/
|
||||
export function normalizeLayerEntry(entry) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
return {
|
||||
enabled: !!entry.enabled,
|
||||
params:
|
||||
entry.params && typeof entry.params === 'object'
|
||||
? deepClone(entry.params)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { enabled: !!entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw bloom post-processing state, migrating intensity values
|
||||
* across bloom scale versions so older saved projects render correctly.
|
||||
* @param {Object} rawBloom - Raw bloom state from storage or recipe
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.projectVersion] - Schema version of the source project
|
||||
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
|
||||
* @returns {{ enabled: boolean, intensity: number, version: number }}
|
||||
*/
|
||||
function normalizeBloomState(
|
||||
rawBloom = {},
|
||||
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||
) {
|
||||
// Determine which bloom scale the stored value was encoded under.
|
||||
// Older projects (version < PROJECT_VERSION) used scale version 1.
|
||||
const explicitVersion = Number(rawBloom.version);
|
||||
const bloomVersion = Number.isFinite(explicitVersion)
|
||||
? explicitVersion
|
||||
: projectVersion >= PROJECT_VERSION
|
||||
? BLOOM_SCALE_VERSION
|
||||
: 1;
|
||||
|
||||
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
|
||||
? Number(rawBloom.intensity)
|
||||
: fallbackIntensity;
|
||||
|
||||
return {
|
||||
enabled: !!rawBloom.enabled,
|
||||
intensity: decodeBloomIntensity(rawIntensity, bloomVersion),
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a static scene recipe (from recipes.js) into a mutable scene object
|
||||
* with fully normalized shots. Each keyframe in the recipe's cameraPath becomes
|
||||
* one shot, inheriting the recipe's style, post, and layer configuration.
|
||||
* @param {Object} recipe - A SCENE_RECIPES entry
|
||||
* @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }}
|
||||
*/
|
||||
export function recipeToScene(recipe) {
|
||||
const post = recipe.post || {};
|
||||
const ui = recipe.ui || {};
|
||||
const styleParams =
|
||||
post.styleParams && typeof post.styleParams === 'object'
|
||||
? deepClone(post.styleParams)
|
||||
: {};
|
||||
|
||||
// Normalize layer targets from the recipe into canonical form
|
||||
const layers = {};
|
||||
for (const [layerId, target] of Object.entries(recipe.layers || {})) {
|
||||
layers[layerId] = normalizeLayerEntry(target);
|
||||
}
|
||||
|
||||
// Derive HUD visibility/variant from recipe's ui.hudMode string
|
||||
const hudMode = ui.hudMode || 'minimal';
|
||||
const hudVisible = hudMode !== 'off';
|
||||
const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical';
|
||||
|
||||
// Convert each cameraPath keyframe into a shot with shared visual state
|
||||
const path = recipe.cameraPath || [];
|
||||
const shots = path.map((keyframe, idx) => {
|
||||
const keyframeLayers = deepClone(layers);
|
||||
for (const [layerId, target] of Object.entries(keyframe.layers || {})) {
|
||||
keyframeLayers[layerId] = normalizeLayerEntry(target);
|
||||
}
|
||||
const mapStack = keyframe.mapStack || post.mapStack;
|
||||
return {
|
||||
id: uid('shot'),
|
||||
title: keyframe.title || `Shot ${idx + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
keyframe.duration || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, keyframe.hold || 0),
|
||||
camera: {
|
||||
lat: keyframe.lat,
|
||||
lon: keyframe.lon,
|
||||
alt: keyframe.alt,
|
||||
heading: keyframe.heading || 0,
|
||||
pitch: keyframe.pitch || -40,
|
||||
roll: keyframe.roll || 0,
|
||||
},
|
||||
visual: {
|
||||
style: recipe.style || 'normal',
|
||||
bloom: {
|
||||
enabled:
|
||||
typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
|
||||
intensity:
|
||||
typeof post.bloom === 'number'
|
||||
? decodeBloomIntensity(post.bloom, 1)
|
||||
: BLOOM_INTENSITY_DEFAULT,
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
},
|
||||
sharpen: {
|
||||
enabled:
|
||||
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
||||
intensity: 65,
|
||||
},
|
||||
hud: {
|
||||
visible: hudVisible,
|
||||
variant: hudVariant,
|
||||
},
|
||||
detection: {
|
||||
mode: post.detectionMode || 'OFF',
|
||||
density: 35,
|
||||
},
|
||||
...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}),
|
||||
styleParams,
|
||||
},
|
||||
layers: keyframeLayers,
|
||||
...(recipe.id ? { sourcePackId: recipe.id } : {}),
|
||||
...(recipe.version ? { sourcePackVersion: recipe.version } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: recipe.id || uid('scene'),
|
||||
title: recipe.title || 'Untitled Scene',
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(recipe.releaseLayerIds)
|
||||
? recipe.releaseLayerIds
|
||||
: []
|
||||
).filter((layerId) => typeof layerId === 'string' && layerId.trim()),
|
||||
),
|
||||
],
|
||||
appliedShotPacks: [],
|
||||
shots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh default project by converting all built-in SCENE_RECIPES.
|
||||
* @returns {Object} A new project object with version, timestamps, and scenes
|
||||
*/
|
||||
export function createDefaultProject() {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
installedBuiltInSceneIds: SCENE_RECIPES.filter(
|
||||
(recipe) => typeof recipe.installAlongsideSceneId === 'string',
|
||||
).map((recipe) => recipe.id),
|
||||
scenes: SCENE_RECIPES.map(recipeToScene),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw shot object from storage or capture into a fully validated
|
||||
* shape with safe defaults for every field. Handles version migration for
|
||||
* bloom intensity and coerces all numeric fields.
|
||||
* @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema)
|
||||
* @param {number} [index=0] - Positional index used for fallback title
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.projectVersion] - Schema version of the enclosing project
|
||||
* @returns {Object} Fully normalized shot
|
||||
*/
|
||||
export function normalizeShot(
|
||||
rawShot,
|
||||
index = 0,
|
||||
{ projectVersion = PROJECT_VERSION } = {},
|
||||
) {
|
||||
const camera = rawShot?.camera || {};
|
||||
const visual = rawShot?.visual || {};
|
||||
const bloom = visual.bloom || {};
|
||||
const sharpen = visual.sharpen || {};
|
||||
const hud = visual.hud || {};
|
||||
const detection = visual.detection || {};
|
||||
|
||||
return {
|
||||
id: rawShot?.id || uid('shot'),
|
||||
title: rawShot?.title || `Shot ${index + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||
camera: {
|
||||
lat: Number(camera.lat) || 0,
|
||||
lon: Number(camera.lon) || 0,
|
||||
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
},
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
projectVersion,
|
||||
fallbackIntensity: 50,
|
||||
}),
|
||||
sharpen: {
|
||||
enabled: !!sharpen.enabled,
|
||||
intensity: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(sharpen.intensity))
|
||||
? Number(sharpen.intensity)
|
||||
: 65,
|
||||
),
|
||||
),
|
||||
},
|
||||
hud: {
|
||||
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
|
||||
variant: typeof hud.variant === 'string' ? hud.variant : 'tactical',
|
||||
},
|
||||
detection: {
|
||||
...(typeof detection.allocation === 'string'
|
||||
? { allocation: detection.allocation }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(detection.fadePct))
|
||||
? { fadePct: Number(detection.fadePct) }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(detection.outsideOpacityPct))
|
||||
? { outsideOpacityPct: Number(detection.outsideOpacityPct) }
|
||||
: {}),
|
||||
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
|
||||
density: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(detection.density))
|
||||
? Number(detection.density)
|
||||
: 35,
|
||||
),
|
||||
),
|
||||
},
|
||||
...(visual.scope && typeof visual.scope === 'object'
|
||||
? { scope: deepClone(visual.scope) }
|
||||
: {}),
|
||||
...(SCENE_MAP_STACK_IDS.has(visual.mapStack)
|
||||
? { mapStack: visual.mapStack }
|
||||
: {}),
|
||||
styleParams:
|
||||
visual.styleParams && typeof visual.styleParams === 'object'
|
||||
? deepClone(visual.styleParams)
|
||||
: {},
|
||||
},
|
||||
layers: Object.fromEntries(
|
||||
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
|
||||
layerId,
|
||||
normalizeLayerEntry(value),
|
||||
]),
|
||||
),
|
||||
...(typeof rawShot?.sourcePackId === 'string'
|
||||
? { sourcePackId: rawShot.sourcePackId }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(rawShot?.sourcePackVersion))
|
||||
? { sourcePackVersion: Number(rawShot.sourcePackVersion) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and migrate an entire project object loaded from storage or import.
|
||||
* Defaults and migrates validated documents; empty projects remain empty.
|
||||
* @param {Object|null} rawProject - Raw project data (potentially from an older schema)
|
||||
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
|
||||
*/
|
||||
export function normalizeProject(rawProject) {
|
||||
if (!rawProject || typeof rawProject !== 'object')
|
||||
return createDefaultProject();
|
||||
const projectVersion = Number.isFinite(Number(rawProject.version))
|
||||
? Number(rawProject.version)
|
||||
: 1;
|
||||
|
||||
const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : [];
|
||||
const scenes = scenesRaw
|
||||
.map((scene, sceneIdx) => {
|
||||
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
|
||||
const shots = shotsRaw.map((shot, shotIdx) =>
|
||||
normalizeShot(shot, shotIdx, { projectVersion }),
|
||||
);
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(scene?.releaseLayerIds)
|
||||
? scene.releaseLayerIds
|
||||
: []
|
||||
).filter(
|
||||
(layerId) => typeof layerId === 'string' && layerId.trim(),
|
||||
),
|
||||
),
|
||||
],
|
||||
appliedShotPacks: (Array.isArray(scene?.appliedShotPacks)
|
||||
? scene.appliedShotPacks
|
||||
: []
|
||||
)
|
||||
.filter((entry) => typeof entry?.id === 'string')
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: Number(entry.version) || 1,
|
||||
...(entry.shotBindings && typeof entry.shotBindings === 'object'
|
||||
? {
|
||||
shotBindings: Object.fromEntries(
|
||||
Object.entries(entry.shotBindings).filter(
|
||||
([title, shotId]) =>
|
||||
typeof title === 'string' && typeof shotId === 'string',
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
shots,
|
||||
};
|
||||
})
|
||||
// Keep scenes that have shots or at least a title
|
||||
.filter((scene) => scene.shots.length > 0 || scene.title);
|
||||
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
createdAt: rawProject.createdAt || new Date().toISOString(),
|
||||
updatedAt: rawProject.updatedAt || new Date().toISOString(),
|
||||
installedBuiltInSceneIds: [
|
||||
...new Set(
|
||||
(Array.isArray(rawProject.installedBuiltInSceneIds)
|
||||
? rawProject.installedBuiltInSceneIds
|
||||
: []
|
||||
).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()),
|
||||
),
|
||||
],
|
||||
scenes,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user