diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1c77983..f58c06b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Director validates bounded version-3 scene files before replacing a project, + preserves unreadable browser saves, migrates legacy bloom once and preserves + zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner. + + - Separate Director timing, seek calculations, playback clocks and registered scene-pack presentation rules. Preserve authored content and controls; Stop releases pending hold timers and stale ticks cannot affect replacement playback. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9b9af2ca8..48ab18fe8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -25,8 +25,11 @@ Pure authored-time/seek calculations and a playback clock now own timing, hold deadlines and subscriptions. Stop immediately clears timers and settles holds; stale tick callbacks cannot publish into a replacement. Registered pack rules own presentation overrides and Nepal map fallback without changing saved -shots or content. The versioned scene/data-pack format remains planned in -[Director](DIRECTOR.md). +shots or content. Scene-document validation and legacy migration now have separate +owners. Invalid imports preserve the current project; unreadable saved projects +are protected from fallback writes. Version 3 remains the export format, with +zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); +data packs and interactions remain planned in [Director](DIRECTOR.md). Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 4d268acf8..164ad7f2f 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -66,13 +66,12 @@ and river coordinates are bundled. See the calculations, an owned clock and registered presentation rules preserve existing recipes and content. Tests cover direct load, forward/backward seek, replay, bounded media waits and cancellation at pending transitions. -2. **Versioned scene document.** Build on the existing version-3 project export - and its importer. Specify a schema and migrations before changing writes. - Keep old imports and local-storage projects readable; reject unsupported - versions without replacing the current project. Include stable scene, shot, - anchor, pack and action IDs. Separate portable authored state from transient - loading state and run diagnostics. Bound document size, collection lengths, - numeric values and nesting; show errors at the affected field. +2. **Versioned scene document — implemented for existing camera-pose shots.** + [Version 3](SCENE-DOCUMENT.md) remains the write format. Bounded validation + rejects malformed files before replacing current state; legacy migration + preserves IDs, pack bindings and visual edits. Unreadable saved files are + protected from fallback writes. Camera anchors, pack manifests and actions + receive stable IDs with their respective later format extensions. 3. **Camera directions.** Define named geographic anchors and explicit move/hold segments: start and destination, duration, easing, heading, pitch, roll and stop duration. State coordinate units, altitude reference and diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md new file mode 100644 index 000000000..985742d1b --- /dev/null +++ b/docs/SCENE-DOCUMENT.md @@ -0,0 +1,102 @@ +# Scene document version 3 + +Scenes exports a UTF-8 JSON project. Version 3 remains the write format; existing +version-3 exports need no conversion. Versions 1, 2 and unversioned legacy files +are migrated on import. Unknown versions or unsupported fields are rejected with +a field path before the current project, selection or saved bytes change. + +```json +{ + "version": 3, + "scenes": [{ + "id": "my-scene", + "title": "My scene", + "shots": [{ + "id": "my-shot", + "title": "Arrival", + "durationSec": 4, + "holdSec": 1, + "camera": { "lat": 51.5, "lon": -0.12, "alt": 800, + "heading": 0, "pitch": -35, "roll": 0 }, + "visual": { "style": "normal" }, + "layers": { "traffic": { "enabled": true } } + }] + }] +} +``` + +## Fields and migration + +The root requires `scenes` (an array, which may be empty). Optional root fields +are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs). +Each scene requires a `shots` array; optional fields are `id`, `title`, +`releaseLayerIds` and `appliedShotPacks`. A pack marker has `id`, optional numeric +`version` and `shotBindings` (original shot title to saved shot ID). These markers +retain the editor's existing pack-update and rename behavior. + +Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`, +`sourcePackId` and `sourcePackVersion`. Scene IDs are unique within the project; +shot IDs are unique within their scene. Existing IDs and pack bindings survive +migration. Missing IDs are generated once and persisted by the next save/export. +Missing titles and optional camera/visual fields receive the editor's defaults. +Empty scenes and intentionally empty projects remain empty. + +Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses the +existing camera-pose height in meters. Import preserves finite heights below +100 meters and zero pitch, which previously fell through to defaults. Explicit +altitude-reference and terrain-relative directions are a subsequent format step. +Flight durations retain the editor's minimum of 0.2 seconds and its 4-second +fallback for zero; holds may be zero. + +Visual fields are `style`, `mapStack`, `styleParams`, `bloom`, `sharpen`, `hud`, and +`detection`, and `scope`. Bloom has `enabled`, `intensity`, `version`; sharpen has `enabled`, +`intensity`; HUD has `visible`, `variant`; detection has `mode`, `density`, +`allocation`, `fadePct` and `outsideOpacityPct`. Scope has `enabled`, `featherPct`. +The latter settings were written by Update Shot but previously lost during +normalization; they now survive import and capture. +Version-1/2 bloom without an explicit scale version uses the original inverted +scale and migrates once to scale 2. The scene document and bloom scale versions +are separate. Legacy numeric strings are accepted for numeric fields. + +Layer entries accept a boolean shorthand or `{ "enabled": true, "params": {} }`. +Parameters and style parameters retain nested JSON data. They are interpreted by +already registered components; importing a file does not fetch its URLs, register +modules, evaluate code or apply its layers. Unsupported structural fields are +rejected instead of silently dropping potential edits. Map-stack selection +retains the existing registered-stack behavior. + +## Validation limits + +`gods-eye-view/director` exports `parseSceneDocument(text)`, +`validateSceneDocument(project)`, `stringifySceneDocument(project)`, +`SceneDocumentError`, `SCENE_DOCUMENT_VERSION` and `SCENE_DOCUMENT_LIMITS`. +Validation is independent of the renderer, storage and recipes. Parsing checks +UTF-8 size before JSON decoding; the file importer also checks file size before +reading it. Migration/defaults live separately in `src/scenes/project.js`. + +Limits are 5 MiB, 256 scenes, 10,000 total shots, 10,000 entries per collection, +24 levels of nesting, 200,000 visited values, 65,536 characters per JSON string, +256 characters per field/ID, and 4,096 characters per title. Numeric values must +be finite. Camera limits are latitude ±90, longitude ±180, altitude −12,000 to +1 billion meters, heading/roll ±360 and pitch ±90; durations/holds are at most +86,400 seconds. Post-processing values are bounded and normalized to the existing +slider ranges. Object prototype keys are refused at every depth. + +An invalid import leaves the current project and playback untouched. A valid +import cancels and settles pending playback before replacing the project. If +multiple files are read concurrently, only the newest request can replace it. +Disposal prevents a pending import from publishing. + +The browser storage key remains `godsEyeView.sceneProject.v2` for compatibility; +that key's suffix is not the document version. An unreadable saved project stays +in storage. The app supplies temporary defaults and a visible warning, but blocks +saving over the original bytes until a valid file is explicitly imported. Export +can preserve temporary edits. Storage writes use the same validation before replacing saved bytes; invalid +edits and storage write failures surface an unsaved toast. + +Only authored project fields belong in this file. Runtime clocks, camera flights, +loading progress and run diagnostics remain separate. Camera anchors, asset-pack +manifests and declarative interactions are planned extensions, not accepted fields +in version 3. They will receive stable IDs and their own validated migration when +introduced. No scene assets, source links, licenses or contributor attribution +change with this document boundary. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eab826105..f334c4e46 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -732,5 +732,6 @@ "src/director/clock.test.mjs", "src/director/timeline.test.mjs", "src/scenes/packs/registry.test.mjs", - "scripts/qa-director-timing.mjs" + "scripts/qa-director-timing.mjs", + "src/director/document.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dfb217391..9df4d9135 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -617,7 +617,8 @@ "modules": [ "src/director/playback.js", "src/director/clock.js", - "src/director/timeline.js" + "src/director/timeline.js", + "src/director/document.js" ], "external": [] }, @@ -645,7 +646,9 @@ "src/scenes/shotPresentation.js", "src/scenes/packs/registry.js", "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js" + "src/scenes/packs/nepal.js", + "src/director/document.js", + "src/scenes/project.js" ], "external": ["cesium"] }, @@ -1750,7 +1753,9 @@ "src/scenes/shotPresentation.js", "src/scenes/packs/registry.js", "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js" + "src/scenes/packs/nepal.js", + "src/director/document.js", + "src/scenes/project.js" ], "external": [ "@mapbox/vector-tile", diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index c4980f314..f56a69239 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -274,6 +274,40 @@ try { 'QA Scene', ), ); + const savedBefore = await page.evaluate(() => + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + const futureFile = path.join(shots, 'future.json'); + fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] })); + await input.uploadFile(futureFile); + await page.waitForFunction(() => + document.getElementById('scene-status').textContent.includes('$.version'), + ); + check( + 'Unsupported versions leave authored state and saved bytes unchanged', + await page.evaluate( + (saved) => + localStorage.getItem('godsEyeView.sceneProject.v2') === saved && + window.__godsEyeView.sceneDirector._getSelectedScene().title === + 'QA Scene', + savedBefore, + ), + ); + const malformed = structuredClone(fixture); + malformed.scenes[0].shots[0].camera.lat = 91; + const malformedFile = path.join(shots, 'malformed.json'); + fs.writeFileSync(malformedFile, JSON.stringify(malformed)); + await input.uploadFile(malformedFile); + await page.waitForFunction(() => + document.getElementById('scene-status').textContent.includes('camera.lat'), + ); + check( + 'Invalid camera field identifies its path without replacing the project', + await page.evaluate( + (saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved, + savedBefore, + ), + ); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); await page.screenshot({ path: path.join(shots, 'narrow.png') }); diff --git a/src/director/document.js b/src/director/document.js new file mode 100644 index 000000000..d0ecdebb4 --- /dev/null +++ b/src/director/document.js @@ -0,0 +1,268 @@ +/** Bounded, renderer-independent validation for authored scene documents. */ +export const SCENE_DOCUMENT_VERSION = 3; +export const SCENE_DOCUMENT_LIMITS = Object.freeze({ + bytes: 5 * 1024 * 1024, + scenes: 256, + shots: 10000, + collection: 10000, + depth: 24, + nodes: 200000, + string: 65536, +}); + +/** A project error identifies a field without echoing its supplied value. */ +export class SceneDocumentError extends Error { + constructor(path, reason) { + super(`${path}: ${reason}`); + this.name = 'SceneDocumentError'; + this.path = path; + } +} +const fail = (path, reason) => { + throw new SceneDocumentError(path, reason); +}; +const object = (value, path) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(path, 'expected an object'); +}; +function fields(value, path, allowed) { + object(value, path); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field'); + } +} +function string(value, path, max = 256) { + if (typeof value !== 'string' || !value.trim() || value.length > max) + fail(path, `expected nonempty text, at most ${max} characters`); +} +function number(value, path, min, max, legacy) { + const numeric = + legacy && typeof value === 'string' && value.trim() ? Number(value) : value; + if ( + typeof numeric !== 'number' || + !Number.isFinite(numeric) || + numeric < min || + numeric > max + ) + fail(path, `expected a number from ${min} to ${max}`); +} +function optional(value, key, path, check) { + if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`); +} +function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) { + if (!Array.isArray(value) || value.length > max) + fail(path, `expected an array of at most ${max} entries`); +} +function ids(value, path) { + array(value, path); + value.forEach((id, index) => string(id, `${path}[${index}]`)); +} +function uniqueId(value, path, seen) { + if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration. + string(value.id, `${path}.id`); + if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID'); + seen.add(value.id); +} +function jsonTree(value, path, budget, depth = 0) { + if ( + ++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes || + depth > SCENE_DOCUMENT_LIMITS.depth + ) + fail(path, 'project complexity limit exceeded'); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.length > SCENE_DOCUMENT_LIMITS.string) + fail(path, 'text is too long'); + return; + } + if (typeof value === 'number' && Number.isFinite(value)) return; + if (!value || typeof value !== 'object') fail(path, 'expected a JSON value'); + if ( + Object.getPrototypeOf(value) !== Object.prototype && + !Array.isArray(value) && + Object.getPrototypeOf(value) !== null + ) + fail(path, 'expected a JSON object'); + const entries = Object.entries(value); + if (entries.length > SCENE_DOCUMENT_LIMITS.collection) + fail(path, 'too many entries'); + for (const [key, child] of entries) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) + fail(path, 'unsafe object key'); + if (key.length > 256) fail(path, 'field name is too long'); + jsonTree(child, `${path}.${key}`, budget, depth + 1); + } +} +function visual(value, path, legacy) { + fields(value, path, [ + 'style', + 'bloom', + 'sharpen', + 'hud', + 'detection', + 'scope', + 'mapStack', + 'styleParams', + ]); + for (const key of ['style', 'mapStack']) optional(value, key, path, string); + optional(value, 'styleParams', path, object); + const specs = { + bloom: { enabled: 'boolean', intensity: [-100, 10000], version: [1, 100] }, + sharpen: { enabled: 'boolean', intensity: [0, 100] }, + hud: { visible: 'boolean', variant: 'string' }, + detection: { + mode: 'string', + density: [0, 100], + allocation: 'string', + fadePct: [0, 100], + outsideOpacityPct: [0, 100], + }, + scope: { enabled: 'boolean', featherPct: [0, 100] }, + }; + for (const [key, spec] of Object.entries(specs)) + optional(value, key, path, (entry, field) => { + fields(entry, field, Object.keys(spec)); + for (const [name, type] of Object.entries(spec)) + optional(entry, name, field, (item, at) => { + if (Array.isArray(type)) number(item, at, ...type, legacy); + else if (type === 'string') string(item, at); + else if (typeof item !== 'boolean') fail(at, 'expected a boolean'); + }); + }); +} + +/** Validate a parsed scene project without mutating it or applying any state. */ +export function validateSceneDocument(project) { + jsonTree(project, '$', { nodes: 0 }); + fields(project, '$', [ + 'version', + 'createdAt', + 'updatedAt', + 'installedBuiltInSceneIds', + 'scenes', + ]); + const version = project.version ?? 1; + if (![1, 2, 3].includes(version)) + fail('$.version', 'unsupported scene project version'); + const legacy = version < 3; + for (const key of ['createdAt', 'updatedAt']) + optional(project, key, '$', string); + optional(project, 'installedBuiltInSceneIds', '$', ids); + array(project.scenes, '$.scenes', SCENE_DOCUMENT_LIMITS.scenes); + const sceneIds = new Set(); + let shotCount = 0; + project.scenes.forEach((scene, index) => { + const path = `$.scenes[${index}]`; + fields(scene, path, [ + 'id', + 'title', + 'releaseLayerIds', + 'appliedShotPacks', + 'shots', + ]); + uniqueId(scene, path, sceneIds); + optional(scene, 'title', path, (v, p) => string(v, p, 4096)); + optional(scene, 'releaseLayerIds', path, ids); + optional(scene, 'appliedShotPacks', path, (packs, field) => { + array(packs, field); + const packIds = new Set(); + packs.forEach((pack, i) => { + const at = `${field}[${i}]`; + fields(pack, at, ['id', 'version', 'shotBindings']); + string(pack.id, `${at}.id`); + uniqueId(pack, at, packIds); + optional(pack, 'version', at, (v, p) => + number(v, p, 1, 1000000, legacy), + ); + optional(pack, 'shotBindings', at, (bindings, p) => { + object(bindings, p); + for (const [title, id] of Object.entries(bindings)) + string(id, `${p}.${title}`); + }); + }); + }); + array(scene.shots, `${path}.shots`); + shotCount += scene.shots.length; + if (shotCount > SCENE_DOCUMENT_LIMITS.shots) + fail(`${path}.shots`, 'too many shots in project'); + const shotIds = new Set(); + scene.shots.forEach((shot, i) => { + const at = `${path}.shots[${i}]`; + fields(shot, at, [ + 'id', + 'title', + 'durationSec', + 'holdSec', + 'camera', + 'visual', + 'layers', + 'sourcePackId', + 'sourcePackVersion', + ]); + uniqueId(shot, at, shotIds); + optional(shot, 'title', at, (v, p) => string(v, p, 4096)); + optional(shot, 'sourcePackId', at, string); + optional(shot, 'sourcePackVersion', at, (v, p) => + number(v, p, 1, 1000000, legacy), + ); + optional(shot, 'durationSec', at, (v, p) => + number(v, p, 0, 86400, legacy), + ); + optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy)); + optional(shot, 'camera', at, (camera, field) => { + const bounds = { + lat: [-90, 90], + lon: [-180, 180], + alt: [-12000, 1e9], + heading: [-360, 360], + pitch: [-90, 90], + roll: [-360, 360], + }; + fields(camera, field, Object.keys(bounds)); + for (const [key, range] of Object.entries(bounds)) + optional(camera, key, field, (v, p) => + number(v, p, ...range, legacy), + ); + }); + optional(shot, 'visual', at, (v, p) => visual(v, p, legacy)); + optional(shot, 'layers', at, (layers, field) => { + object(layers, field); + for (const [id, entry] of Object.entries(layers)) { + string(id, field); + if (typeof entry === 'boolean') continue; + const p = `${field}.${id}`; + fields(entry, p, ['enabled', 'params']); + if (typeof entry.enabled !== 'boolean') + fail(`${p}.enabled`, 'expected a boolean'); + optional(entry, 'params', p, object); + } + }); + }); + }); + return project; +} + +/** Read bounded JSON. No URLs, modules or actions are executed during import. */ +export function parseSceneDocument(text) { + if ( + typeof text !== 'string' || + text.length > SCENE_DOCUMENT_LIMITS.bytes || + new TextEncoder().encode(text).byteLength > SCENE_DOCUMENT_LIMITS.bytes + ) + fail('$', 'file exceeds 5 MiB'); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + fail('$', 'invalid JSON'); + } + return validateSceneDocument(parsed); +} + +/** Export authored project fields only; callers pass the project, never run state. */ +export function stringifySceneDocument(project) { + // Round-trip drops optional undefined fields created by the editor. + const text = JSON.stringify(project, null, 2); + parseSceneDocument(text); + return text; +} diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs new file mode 100644 index 000000000..d9a2f9ab4 --- /dev/null +++ b/src/director/document.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseSceneDocument, + stringifySceneDocument, + validateSceneDocument, + SCENE_DOCUMENT_LIMITS, +} from './document.js'; +import { createDefaultProject, normalizeProject } from '../scenes/project.js'; + +const fixture = () => ({ + version: 3, + scenes: [ + { + id: 'scene', + title: 'My scene', + shots: [ + { + id: 'shot', + title: 'My shot', + durationSec: 3, + holdSec: 0, + camera: { lat: 1, lon: 2, alt: 42, pitch: 0 }, + layers: { + traffic: { enabled: true, params: { nested: [1, 'two', null] } }, + }, + }, + ], + }, + ], +}); +test('all built-in authored content exports and normalizes without edit loss', () => { + const project = createDefaultProject(); + const migrated = normalizeProject( + parseSceneDocument(stringifySceneDocument(project)), + ); + assert.deepEqual( + JSON.parse(stringifySceneDocument(migrated)), + JSON.parse(stringifySceneDocument(project)), + ); + const edited = normalizeProject(fixture()); + edited.scenes[0].appliedShotPacks = [ + { id: 'pack', version: 3, shotBindings: { Original: 'shot' } }, + ]; + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(edited))), + edited, + ); + assert.equal(edited.scenes[0].shots[0].camera.pitch, 0); + assert.equal(edited.scenes[0].shots[0].camera.alt, 42); +}); +test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds survive', () => { + for (const version of [undefined, 1, 2]) { + const project = fixture(); + project.version = version; + project.scenes[0].shots[0].visual = { + bloom: { enabled: true, intensity: 25 }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.equal(migrated.version, 3); + assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150); + assert.equal(migrated.scenes[0].shots[0].id, 'shot'); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); + } +}); +test('an intentionally empty project stays empty', () => { + assert.deepEqual( + normalizeProject(parseSceneDocument('{"version":3,"scenes":[]}')).scenes, + [], + ); +}); +test('missing legacy IDs become stable after the first saved migration', () => { + const project = { scenes: [{ shots: [{}] }] }; + const migrated = normalizeProject(validateSceneDocument(project)); + assert.ok(migrated.scenes[0].id); + assert.ok(migrated.scenes[0].shots[0].id); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); +}); +test('invalid shapes, versions, unknown fields and unsafe keys fail with field paths', () => { + for (const text of [ + 'null', + '[]', + '{}', + '{"version":99,"scenes":[]}', + '{"version":3,"scenes":[],"run":{}}', + '{"scenes":[],"__proto__":{}}', + ]) { + assert.throws(() => parseSceneDocument(text), { + name: 'SceneDocumentError', + }); + } + const project = fixture(); + project.scenes[0].shots[0].camera.lat = 91; + assert.throws( + () => validateSceneDocument(project), + /\$\.scenes\[0\]\.shots\[0\]\.camera.lat/, + ); + project.scenes[0].shots[0].camera.lat = 1; + project.scenes[0].shots.push(structuredClone(project.scenes[0].shots[0])); + assert.throws(() => validateSceneDocument(project), /duplicate ID/); +}); +test('document byte, nesting, collection, finite-number and string bounds are enforced', () => { + assert.throws( + () => parseSceneDocument(' '.repeat(SCENE_DOCUMENT_LIMITS.bytes + 1)), + /5 MiB/, + ); + assert.throws( + () => + parseSceneDocument( + JSON.stringify({ + scenes: [], + createdAt: 'é'.repeat(SCENE_DOCUMENT_LIMITS.bytes / 2), + }), + ), + /5 MiB/, + ); + const project = fixture(); + project.scenes[0].shots[0].camera.lat = Infinity; + assert.throws(() => validateSceneDocument(project), /JSON value/); + project.scenes[0].shots[0].camera.lat = 1; + let nested = project.scenes[0].shots[0].layers.traffic.params; + for (let i = 0; i < 30; i++) nested = nested.child = {}; + assert.throws(() => validateSceneDocument(project), /complexity/); + assert.throws( + () => validateSceneDocument({ scenes: Array(257).fill({ shots: [] }) }), + /256/, + ); +}); + +test('captured scope and extended detection edits survive migration', () => { + const project = fixture(); + project.scenes[0].shots[0].visual = { + scope: { enabled: true, featherPct: 11 }, + detection: { + mode: 'DENSE', + density: 75, + allocation: 'ELASTIC', + fadePct: 7, + outsideOpacityPct: 1, + }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.scope, + project.scenes[0].shots[0].visual.scope, + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.detection, + project.scenes[0].shots[0].visual.detection, + ); +}); diff --git a/src/director/playback.js b/src/director/playback.js index 9279dfcf8..ff5620ed5 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -103,3 +103,12 @@ export { cameraAtProgress, } from './timeline.js'; export { createPlaybackClock } from './clock.js'; + +export { + parseSceneDocument, + validateSceneDocument, + stringifySceneDocument, + SceneDocumentError, + SCENE_DOCUMENT_VERSION, + SCENE_DOCUMENT_LIMITS, +} from './document.js'; diff --git a/src/scenes/director.js b/src/scenes/director.js index 345566b97..5c3c44a92 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -23,7 +23,6 @@ import { getSceneAppendRecipeById, } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; -import { MAP_STACKS } from '../maps/catalog.js'; import { createDefaultScenePacks } from './packs/defaults.js'; import { layerStatesForShot, @@ -34,380 +33,27 @@ import { import { sceneTimingForShot, sceneSeekState } from '../director/timeline.js'; import { createPlaybackClock } from '../director/clock.js'; import { - BLOOM_INTENSITY_DEFAULT, - BLOOM_SCALE_VERSION, - decodeBloomIntensity, -} from '../bloom.js'; + normalizeLayerEntry, + PROJECT_VERSION, + DEFAULT_SHOT_DURATION_SEC, + DEFAULT_HOLD_SEC, + uid, + deepClone, + recipeToScene, + createDefaultProject, + normalizeShot, + normalizeProject, +} from './project.js'; +import { + parseSceneDocument, + stringifySceneDocument, + SCENE_DOCUMENT_LIMITS, + SceneDocumentError, +} from '../director/document.js'; /** @constant {string} localStorage key for the serialized project */ const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1'; -/** @constant {number} Current schema version for project migration */ -const PROJECT_VERSION = 3; -/** @constant {number} Fallback camera flight duration per shot (seconds) */ -const DEFAULT_SHOT_DURATION_SEC = 4; -/** @constant {number} Default hold/pause after a shot completes (seconds) */ -const DEFAULT_HOLD_SEC = 0.9; -const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id)); - -/** - * Generate a short random identifier with the given prefix. - * @param {string} prefix - e.g. 'shot', 'scene' - * @returns {string} Identifier like "shot-a1b2c3d4" - */ -function uid(prefix) { - return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; -} - -/** - * Deep-clone a JSON-serializable value via round-trip stringify/parse. - * @param {*} value - * @returns {*} - */ -function deepClone(value) { - return JSON.parse(JSON.stringify(value)); -} - -/** - * Normalize a raw layer state entry into a canonical { enabled, params? } shape. - * Accepts both boolean shorthand and full object forms. - * @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params }) - * @returns {{ enabled: boolean, params?: Object }} - */ -function normalizeLayerEntry(entry) { - if (entry && typeof entry === 'object') { - return { - enabled: !!entry.enabled, - params: - entry.params && typeof entry.params === 'object' - ? deepClone(entry.params) - : undefined, - }; - } - return { enabled: !!entry }; -} - -/** - * Normalize a raw bloom post-processing state, migrating intensity values - * across bloom scale versions so older saved projects render correctly. - * @param {Object} rawBloom - Raw bloom state from storage or recipe - * @param {Object} [options] - * @param {number} [options.projectVersion] - Schema version of the source project - * @param {number} [options.fallbackIntensity] - Default intensity if not stored - * @returns {{ enabled: boolean, intensity: number, version: number }} - */ -function normalizeBloomState( - rawBloom = {}, - { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}, -) { - // Determine which bloom scale the stored value was encoded under. - // Older projects (version < PROJECT_VERSION) used scale version 1. - const explicitVersion = Number(rawBloom.version); - const bloomVersion = Number.isFinite(explicitVersion) - ? explicitVersion - : projectVersion >= PROJECT_VERSION - ? BLOOM_SCALE_VERSION - : 1; - - const rawIntensity = Number.isFinite(Number(rawBloom.intensity)) - ? Number(rawBloom.intensity) - : fallbackIntensity; - - return { - enabled: !!rawBloom.enabled, - intensity: decodeBloomIntensity(rawIntensity, bloomVersion), - version: BLOOM_SCALE_VERSION, - }; -} - -/** - * Convert a static scene recipe (from recipes.js) into a mutable scene object - * with fully normalized shots. Each keyframe in the recipe's cameraPath becomes - * one shot, inheriting the recipe's style, post, and layer configuration. - * @param {Object} recipe - A SCENE_RECIPES entry - * @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }} - */ -function recipeToScene(recipe) { - const post = recipe.post || {}; - const ui = recipe.ui || {}; - const styleParams = - post.styleParams && typeof post.styleParams === 'object' - ? deepClone(post.styleParams) - : {}; - - // Normalize layer targets from the recipe into canonical form - const layers = {}; - for (const [layerId, target] of Object.entries(recipe.layers || {})) { - layers[layerId] = normalizeLayerEntry(target); - } - - // Derive HUD visibility/variant from recipe's ui.hudMode string - const hudMode = ui.hudMode || 'minimal'; - const hudVisible = hudMode !== 'off'; - const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical'; - - // Convert each cameraPath keyframe into a shot with shared visual state - const path = recipe.cameraPath || []; - const shots = path.map((keyframe, idx) => { - const keyframeLayers = deepClone(layers); - for (const [layerId, target] of Object.entries(keyframe.layers || {})) { - keyframeLayers[layerId] = normalizeLayerEntry(target); - } - const mapStack = keyframe.mapStack || post.mapStack; - return { - id: uid('shot'), - title: keyframe.title || `Shot ${idx + 1}`, - durationSec: Math.max( - 0.2, - keyframe.duration || DEFAULT_SHOT_DURATION_SEC, - ), - holdSec: Math.max(0, keyframe.hold || 0), - camera: { - lat: keyframe.lat, - lon: keyframe.lon, - alt: keyframe.alt, - heading: keyframe.heading || 0, - pitch: keyframe.pitch || -40, - roll: keyframe.roll || 0, - }, - visual: { - style: recipe.style || 'normal', - bloom: { - enabled: - typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, - intensity: - typeof post.bloom === 'number' - ? decodeBloomIntensity(post.bloom, 1) - : BLOOM_INTENSITY_DEFAULT, - version: BLOOM_SCALE_VERSION, - }, - sharpen: { - enabled: - typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, - intensity: 65, - }, - hud: { - visible: hudVisible, - variant: hudVariant, - }, - detection: { - mode: post.detectionMode || 'OFF', - density: 35, - }, - ...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}), - styleParams, - }, - layers: keyframeLayers, - ...(recipe.id ? { sourcePackId: recipe.id } : {}), - ...(recipe.version ? { sourcePackVersion: recipe.version } : {}), - }; - }); - - return { - id: recipe.id || uid('scene'), - title: recipe.title || 'Untitled Scene', - releaseLayerIds: [ - ...new Set( - (Array.isArray(recipe.releaseLayerIds) - ? recipe.releaseLayerIds - : [] - ).filter((layerId) => typeof layerId === 'string' && layerId.trim()), - ), - ], - appliedShotPacks: [], - shots, - }; -} - -/** - * Create a fresh default project by converting all built-in SCENE_RECIPES. - * @returns {Object} A new project object with version, timestamps, and scenes - */ -function createDefaultProject() { - return { - version: PROJECT_VERSION, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - installedBuiltInSceneIds: SCENE_RECIPES.filter( - (recipe) => typeof recipe.installAlongsideSceneId === 'string', - ).map((recipe) => recipe.id), - scenes: SCENE_RECIPES.map(recipeToScene), - }; -} - -/** - * Normalize a raw shot object from storage or capture into a fully validated - * shape with safe defaults for every field. Handles version migration for - * bloom intensity and coerces all numeric fields. - * @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema) - * @param {number} [index=0] - Positional index used for fallback title - * @param {Object} [options] - * @param {number} [options.projectVersion] - Schema version of the enclosing project - * @returns {Object} Fully normalized shot - */ -function normalizeShot( - rawShot, - index = 0, - { projectVersion = PROJECT_VERSION } = {}, -) { - const camera = rawShot?.camera || {}; - const visual = rawShot?.visual || {}; - const bloom = visual.bloom || {}; - const sharpen = visual.sharpen || {}; - const hud = visual.hud || {}; - const detection = visual.detection || {}; - - return { - id: rawShot?.id || uid('shot'), - title: rawShot?.title || `Shot ${index + 1}`, - durationSec: Math.max( - 0.2, - Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC, - ), - holdSec: Math.max(0, Number(rawShot?.holdSec) || 0), - camera: { - lat: Number(camera.lat) || 0, - lon: Number(camera.lon) || 0, - alt: Math.max(100, Number(camera.alt) || 800), - heading: Number(camera.heading) || 0, - pitch: Number(camera.pitch) || -35, - roll: Number(camera.roll) || 0, - }, - visual: { - style: visual.style || 'normal', - bloom: normalizeBloomState(bloom, { - projectVersion, - fallbackIntensity: 50, - }), - sharpen: { - enabled: !!sharpen.enabled, - intensity: Math.max( - 0, - Math.min( - 100, - Number.isFinite(Number(sharpen.intensity)) - ? Number(sharpen.intensity) - : 65, - ), - ), - }, - hud: { - visible: typeof hud.visible === 'boolean' ? hud.visible : true, - variant: typeof hud.variant === 'string' ? hud.variant : 'tactical', - }, - detection: { - mode: typeof detection.mode === 'string' ? detection.mode : 'OFF', - density: Math.max( - 0, - Math.min( - 100, - Number.isFinite(Number(detection.density)) - ? Number(detection.density) - : 35, - ), - ), - }, - ...(SCENE_MAP_STACK_IDS.has(visual.mapStack) - ? { mapStack: visual.mapStack } - : {}), - styleParams: - visual.styleParams && typeof visual.styleParams === 'object' - ? deepClone(visual.styleParams) - : {}, - }, - layers: Object.fromEntries( - Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [ - layerId, - normalizeLayerEntry(value), - ]), - ), - ...(typeof rawShot?.sourcePackId === 'string' - ? { sourcePackId: rawShot.sourcePackId } - : {}), - ...(Number.isFinite(Number(rawShot?.sourcePackVersion)) - ? { sourcePackVersion: Number(rawShot.sourcePackVersion) } - : {}), - }; -} - -/** - * Normalize and migrate an entire project object loaded from storage or import. - * Falls back to the default recipe-based project when input is invalid or empty. - * @param {Object|null} rawProject - Raw project data (potentially from an older schema) - * @returns {Object} Fully normalized project at the current PROJECT_VERSION - */ -function normalizeProject(rawProject) { - if (!rawProject || typeof rawProject !== 'object') - return createDefaultProject(); - const projectVersion = Number.isFinite(Number(rawProject.version)) - ? Number(rawProject.version) - : 1; - - const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : []; - const scenes = scenesRaw - .map((scene, sceneIdx) => { - const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : []; - const shots = shotsRaw.map((shot, shotIdx) => - normalizeShot(shot, shotIdx, { projectVersion }), - ); - return { - id: scene?.id || uid('scene'), - title: scene?.title || `Scene ${sceneIdx + 1}`, - releaseLayerIds: [ - ...new Set( - (Array.isArray(scene?.releaseLayerIds) - ? scene.releaseLayerIds - : [] - ).filter( - (layerId) => typeof layerId === 'string' && layerId.trim(), - ), - ), - ], - appliedShotPacks: (Array.isArray(scene?.appliedShotPacks) - ? scene.appliedShotPacks - : [] - ) - .filter((entry) => typeof entry?.id === 'string') - .map((entry) => ({ - id: entry.id, - version: Number(entry.version) || 1, - ...(entry.shotBindings && typeof entry.shotBindings === 'object' - ? { - shotBindings: Object.fromEntries( - Object.entries(entry.shotBindings).filter( - ([title, shotId]) => - typeof title === 'string' && typeof shotId === 'string', - ), - ), - } - : {}), - })), - shots, - }; - }) - // Keep scenes that have shots or at least a title - .filter((scene) => scene.shots.length > 0 || scene.title); - - if (!scenes.length) { - return createDefaultProject(); - } - - return { - version: PROJECT_VERSION, - createdAt: rawProject.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - installedBuiltInSceneIds: [ - ...new Set( - (Array.isArray(rawProject.installedBuiltInSceneIds) - ? rawProject.installedBuiltInSceneIds - : [] - ).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()), - ), - ], - scenes, - }; -} - /** * Orchestrates deterministic cinematic scene playback. * @@ -475,7 +121,9 @@ export class SceneDirector { this._loadedSceneId = null; this._presentation = { - status: 'Ready', + status: this._storageReadError + ? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.' + : 'Ready', progress: 0, runtime: '', playbackActive: false, @@ -555,7 +203,7 @@ export class SceneDirector { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return createDefaultProject(); - const project = normalizeProject(JSON.parse(raw)); + const project = normalizeProject(parseSceneDocument(raw)); const installed = new Set(project.installedBuiltInSceneIds || []); let migrated = false; for (const recipe of SCENE_RECIPES) { @@ -586,7 +234,9 @@ export class SceneDirector { if (migrated) { project.installedBuiltInSceneIds = [...installed]; try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(project)); + const payload = JSON.stringify(project); + parseSceneDocument(payload); + localStorage.setItem(STORAGE_KEY, payload); } catch (e) { // Keep the migrated in-memory project usable even if this origin // refuses persistence; the normal UI save path will surface errors. @@ -597,16 +247,26 @@ export class SceneDirector { } } return project; - } catch { + } catch (error) { + // Never overwrite an unreadable or newer saved document with defaults. + this._storageReadError = error; return createDefaultProject(); } } /** Persist the current project state to localStorage with an updated timestamp. */ _saveProject() { + if (this._storageReadError) { + this._toastStorageError( + 'Scene not saved — existing saved project could not be read. Export edits or import a valid file.', + ); + return; + } this._project.updatedAt = new Date().toISOString(); try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this._project)); + const payload = JSON.stringify(this._project); + parseSceneDocument(payload); + localStorage.setItem(STORAGE_KEY, payload); } catch (e) { // Private browsing / block-all-cookies / quota-exceeded throws here. The // in-memory project stays usable this session, but persistence failed — @@ -615,13 +275,18 @@ export class SceneDirector { '[Scenes] Could not persist project (storage unavailable):', e, ); - this._toastStorageError(); + this._toastStorageError( + e instanceof SceneDocumentError + ? `Scene not saved — ${e.message}` + : undefined, + ); } } /** Surface a "scene not saved" notice via the global toast + scene status line. */ - _toastStorageError() { - const message = 'Scene not saved — browser storage unavailable'; + _toastStorageError( + message = 'Scene not saved — browser storage unavailable', + ) { this._updateStatus(message); try { const toast = document.getElementById('toast'); @@ -2038,7 +1703,13 @@ export class SceneDirector { exportProject() { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const fileName = `scene-presets-${stamp}.json`; - const payload = JSON.stringify(this._project, null, 2); + let payload; + try { + payload = stringifySceneDocument(this._project); + } catch (error) { + this._updateStatus(`Export failed: ${error.message}`); + return; + } // Trigger a browser download via a temporary anchor element const blob = new Blob([payload], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -2059,18 +1730,34 @@ export class SceneDirector { * @param {File} file - Browser File object from an */ async importProjectFile(file) { + const generation = (this._importGeneration = + (this._importGeneration || 0) + 1); try { + if (file.size > SCENE_DOCUMENT_LIMITS.bytes) { + throw new SceneDocumentError('$', 'file exceeds 5 MiB'); + } const text = await file.text(); - if (this._destroyed) return; - const parsed = JSON.parse(text); - this._project = normalizeProject(parsed); - this._selectedSceneId = this._project.scenes[0]?.id || null; - this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; + if (this._destroyed || generation !== this._importGeneration) return; + const project = normalizeProject(parseSceneDocument(text)); + // Validate before touching playback, selection or the saved project. + this.stopScene('Importing project'); + await Promise.allSettled([...(this._pendingWork || [])]); + if (this._destroyed || generation !== this._importGeneration) return; + this._project = project; + this._storageReadError = null; + this._selectedSceneId = project.scenes[0]?.id || null; + this._selectedShotId = project.scenes[0]?.shots[0]?.id || null; + this._loadedSceneId = null; this._saveProject(); - this._publish({ type: 'project-imported', project: this._project }); + this._publish({ type: 'project-imported', project }); this._updateStatus(`Imported ${file.name}`); - } catch { - this._updateStatus('Import failed (invalid JSON)'); + } catch (error) { + if (this._destroyed || generation !== this._importGeneration) return; + this._updateStatus( + error instanceof SceneDocumentError + ? `Import failed: ${error.message}` + : 'Import failed (could not read JSON file)', + ); } } diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index def1b4969..b1be372f4 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -614,10 +614,11 @@ function installSceneRuntime(project = PROJECT_FIXTURE) { removeEventListener() {}, body: { classList: noopClassList, appendChild() {} }, }; + const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]); globalThis.localStorage = { - getItem: () => JSON.stringify(project), - setItem() {}, - removeItem() {}, + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value), + removeItem: (key) => stored.delete(key), }; return () => { @@ -1397,3 +1398,96 @@ test('Scene load outcomes exclude superseded and disposed completions', async () assert.equal(seen.length, count); } finally { restore(); } }); + +test('invalid and unsupported imports retain the current project, selection and saved bytes', async () => { + const { director, restore } = makeDirector(); + try { + const project = director._project; + const selected = director._selectedShotId; + const saved = localStorage.getItem('godsEyeView.sceneProject.v2'); + for (const value of ['{}', '{"version":99,"scenes":[]}', '{"scenes":[{"shots":"bad"}]}']) { + await director.importProjectFile({ name: 'bad.json', text: async () => value }); + assert.equal(director._project, project); + assert.equal(director._selectedShotId, selected); + assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved); + assert.match(director._presentation.status, /Import failed: \$/); + } + let read = false; + await director.importProjectFile({ size: 6 * 1024 * 1024, text: async () => { read = true; } }); + assert.equal(read, false); + assert.equal(director._project, project); + } finally { restore(); } +}); + +test('newer imports win delayed file reads, and an empty project is preserved', async () => { + const { director, restore } = makeDirector(); + try { + let release; + const older = director.importProjectFile({ name: 'old.json', text: () => new Promise((resolve) => { release = resolve; }) }); + await director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' }); + release(JSON.stringify(PROJECT_FIXTURE)); + await older; + assert.deepEqual(director._project.scenes, []); + assert.equal(director._selectedSceneId, null); + assert.deepEqual(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes, []); + } finally { restore(); } +}); + +test('unsupported stored documents cannot be overwritten by fallback edits', async () => { + const { director, restore } = makeDirector({ project: { version: 99, scenes: [] } }); + try { + const saved = localStorage.getItem('godsEyeView.sceneProject.v2'); + assert.ok(director._storageReadError); + director._project.scenes[0].title = 'Fallback edit'; + director._saveProject(); + assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved); + await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) }); + assert.equal(director._storageReadError, null); + assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 3); + } finally { restore(); } +}); + +test('valid import settles a cancelled load before replacing the project', async () => { + const { director, styleManager, restore } = makeDirector(); + let release; + styleManager.applyVisualState = () => new Promise((resolve) => { release = resolve; }); + try { + const before = director._project; + const load = director.loadShot('scene-1', 'shot-a'); + await settle(); + const importing = director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' }); + await settle(); + assert.equal(director._project, before, 'old work still owns cleanup'); + release(true); + await Promise.all([load, importing]); + assert.deepEqual(director._project.scenes, []); + assert.equal(director._pendingWork.size, 0); + assert.equal(director.getPlaybackTimingState().activeTimers, 0); + } finally { restore(); } +}); + +test('a delayed import cannot publish after disposal', async () => { + const { director, restore } = makeDirector(); + let release; + try { + const before = director._project; + const reading = director.importProjectFile({ name: 'empty.json', text: () => new Promise((resolve) => { release = resolve; }) }); + await director.destroy(); + release('{"version":3,"scenes":[]}'); + await reading; + assert.equal(director._project, before); + } finally { restore(); } +}); + +test('invalid authored edits cannot persist an unreadable project over a good save', () => { + const { director, restore } = makeDirector(); + try { + const before = localStorage.getItem('godsEyeView.sceneProject.v2'); + let notice; + director._toastStorageError = (message) => { notice = message; }; + director._project.scenes[0].shots[0].camera.lat = 91; + director._saveProject(); + assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before); + assert.match(notice, /camera.lat/); + } finally { restore(); } +}); diff --git a/src/scenes/project.js b/src/scenes/project.js new file mode 100644 index 000000000..c71517ae4 --- /dev/null +++ b/src/scenes/project.js @@ -0,0 +1,383 @@ +/** Authored project defaults and legacy migrations; no editor or playback state. */ +import { SCENE_DOCUMENT_VERSION } from '../director/document.js'; +import { SCENE_RECIPES } from './recipes.js'; +import { MAP_STACKS } from '../maps/catalog.js'; +import { + BLOOM_INTENSITY_DEFAULT, + BLOOM_SCALE_VERSION, + decodeBloomIntensity, +} from '../bloom.js'; + +/** @constant {number} Current schema version for project migration */ +export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION; +/** @constant {number} Fallback camera flight duration per shot (seconds) */ +export const DEFAULT_SHOT_DURATION_SEC = 4; +/** @constant {number} Default hold/pause after a shot completes (seconds) */ +export const DEFAULT_HOLD_SEC = 0.9; +const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id)); + +/** + * Generate a short random identifier with the given prefix. + * @param {string} prefix - e.g. 'shot', 'scene' + * @returns {string} Identifier like "shot-a1b2c3d4" + */ +export function uid(prefix) { + return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Deep-clone a JSON-serializable value via round-trip stringify/parse. + * @param {*} value + * @returns {*} + */ +export function deepClone(value) { + return JSON.parse(JSON.stringify(value)); +} + +/** + * Normalize a raw layer state entry into a canonical { enabled, params? } shape. + * Accepts both boolean shorthand and full object forms. + * @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params }) + * @returns {{ enabled: boolean, params?: Object }} + */ +export function normalizeLayerEntry(entry) { + if (entry && typeof entry === 'object') { + return { + enabled: !!entry.enabled, + params: + entry.params && typeof entry.params === 'object' + ? deepClone(entry.params) + : undefined, + }; + } + return { enabled: !!entry }; +} + +/** + * Normalize a raw bloom post-processing state, migrating intensity values + * across bloom scale versions so older saved projects render correctly. + * @param {Object} rawBloom - Raw bloom state from storage or recipe + * @param {Object} [options] + * @param {number} [options.projectVersion] - Schema version of the source project + * @param {number} [options.fallbackIntensity] - Default intensity if not stored + * @returns {{ enabled: boolean, intensity: number, version: number }} + */ +function normalizeBloomState( + rawBloom = {}, + { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}, +) { + // Determine which bloom scale the stored value was encoded under. + // Older projects (version < PROJECT_VERSION) used scale version 1. + const explicitVersion = Number(rawBloom.version); + const bloomVersion = Number.isFinite(explicitVersion) + ? explicitVersion + : projectVersion >= PROJECT_VERSION + ? BLOOM_SCALE_VERSION + : 1; + + const rawIntensity = Number.isFinite(Number(rawBloom.intensity)) + ? Number(rawBloom.intensity) + : fallbackIntensity; + + return { + enabled: !!rawBloom.enabled, + intensity: decodeBloomIntensity(rawIntensity, bloomVersion), + version: BLOOM_SCALE_VERSION, + }; +} + +/** + * Convert a static scene recipe (from recipes.js) into a mutable scene object + * with fully normalized shots. Each keyframe in the recipe's cameraPath becomes + * one shot, inheriting the recipe's style, post, and layer configuration. + * @param {Object} recipe - A SCENE_RECIPES entry + * @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }} + */ +export function recipeToScene(recipe) { + const post = recipe.post || {}; + const ui = recipe.ui || {}; + const styleParams = + post.styleParams && typeof post.styleParams === 'object' + ? deepClone(post.styleParams) + : {}; + + // Normalize layer targets from the recipe into canonical form + const layers = {}; + for (const [layerId, target] of Object.entries(recipe.layers || {})) { + layers[layerId] = normalizeLayerEntry(target); + } + + // Derive HUD visibility/variant from recipe's ui.hudMode string + const hudMode = ui.hudMode || 'minimal'; + const hudVisible = hudMode !== 'off'; + const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical'; + + // Convert each cameraPath keyframe into a shot with shared visual state + const path = recipe.cameraPath || []; + const shots = path.map((keyframe, idx) => { + const keyframeLayers = deepClone(layers); + for (const [layerId, target] of Object.entries(keyframe.layers || {})) { + keyframeLayers[layerId] = normalizeLayerEntry(target); + } + const mapStack = keyframe.mapStack || post.mapStack; + return { + id: uid('shot'), + title: keyframe.title || `Shot ${idx + 1}`, + durationSec: Math.max( + 0.2, + keyframe.duration || DEFAULT_SHOT_DURATION_SEC, + ), + holdSec: Math.max(0, keyframe.hold || 0), + camera: { + lat: keyframe.lat, + lon: keyframe.lon, + alt: keyframe.alt, + heading: keyframe.heading || 0, + pitch: keyframe.pitch || -40, + roll: keyframe.roll || 0, + }, + visual: { + style: recipe.style || 'normal', + bloom: { + enabled: + typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, + intensity: + typeof post.bloom === 'number' + ? decodeBloomIntensity(post.bloom, 1) + : BLOOM_INTENSITY_DEFAULT, + version: BLOOM_SCALE_VERSION, + }, + sharpen: { + enabled: + typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, + intensity: 65, + }, + hud: { + visible: hudVisible, + variant: hudVariant, + }, + detection: { + mode: post.detectionMode || 'OFF', + density: 35, + }, + ...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}), + styleParams, + }, + layers: keyframeLayers, + ...(recipe.id ? { sourcePackId: recipe.id } : {}), + ...(recipe.version ? { sourcePackVersion: recipe.version } : {}), + }; + }); + + return { + id: recipe.id || uid('scene'), + title: recipe.title || 'Untitled Scene', + releaseLayerIds: [ + ...new Set( + (Array.isArray(recipe.releaseLayerIds) + ? recipe.releaseLayerIds + : [] + ).filter((layerId) => typeof layerId === 'string' && layerId.trim()), + ), + ], + appliedShotPacks: [], + shots, + }; +} + +/** + * Create a fresh default project by converting all built-in SCENE_RECIPES. + * @returns {Object} A new project object with version, timestamps, and scenes + */ +export function createDefaultProject() { + return { + version: PROJECT_VERSION, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + installedBuiltInSceneIds: SCENE_RECIPES.filter( + (recipe) => typeof recipe.installAlongsideSceneId === 'string', + ).map((recipe) => recipe.id), + scenes: SCENE_RECIPES.map(recipeToScene), + }; +} + +/** + * Normalize a raw shot object from storage or capture into a fully validated + * shape with safe defaults for every field. Handles version migration for + * bloom intensity and coerces all numeric fields. + * @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema) + * @param {number} [index=0] - Positional index used for fallback title + * @param {Object} [options] + * @param {number} [options.projectVersion] - Schema version of the enclosing project + * @returns {Object} Fully normalized shot + */ +export function normalizeShot( + rawShot, + index = 0, + { projectVersion = PROJECT_VERSION } = {}, +) { + const camera = rawShot?.camera || {}; + const visual = rawShot?.visual || {}; + const bloom = visual.bloom || {}; + const sharpen = visual.sharpen || {}; + const hud = visual.hud || {}; + const detection = visual.detection || {}; + + return { + id: rawShot?.id || uid('shot'), + title: rawShot?.title || `Shot ${index + 1}`, + durationSec: Math.max( + 0.2, + Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC, + ), + holdSec: Math.max(0, Number(rawShot?.holdSec) || 0), + camera: { + lat: Number(camera.lat) || 0, + lon: Number(camera.lon) || 0, + alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800, + heading: Number(camera.heading) || 0, + pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35, + roll: Number(camera.roll) || 0, + }, + visual: { + style: visual.style || 'normal', + bloom: normalizeBloomState(bloom, { + projectVersion, + fallbackIntensity: 50, + }), + sharpen: { + enabled: !!sharpen.enabled, + intensity: Math.max( + 0, + Math.min( + 100, + Number.isFinite(Number(sharpen.intensity)) + ? Number(sharpen.intensity) + : 65, + ), + ), + }, + hud: { + visible: typeof hud.visible === 'boolean' ? hud.visible : true, + variant: typeof hud.variant === 'string' ? hud.variant : 'tactical', + }, + detection: { + ...(typeof detection.allocation === 'string' + ? { allocation: detection.allocation } + : {}), + ...(Number.isFinite(Number(detection.fadePct)) + ? { fadePct: Number(detection.fadePct) } + : {}), + ...(Number.isFinite(Number(detection.outsideOpacityPct)) + ? { outsideOpacityPct: Number(detection.outsideOpacityPct) } + : {}), + mode: typeof detection.mode === 'string' ? detection.mode : 'OFF', + density: Math.max( + 0, + Math.min( + 100, + Number.isFinite(Number(detection.density)) + ? Number(detection.density) + : 35, + ), + ), + }, + ...(visual.scope && typeof visual.scope === 'object' + ? { scope: deepClone(visual.scope) } + : {}), + ...(SCENE_MAP_STACK_IDS.has(visual.mapStack) + ? { mapStack: visual.mapStack } + : {}), + styleParams: + visual.styleParams && typeof visual.styleParams === 'object' + ? deepClone(visual.styleParams) + : {}, + }, + layers: Object.fromEntries( + Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [ + layerId, + normalizeLayerEntry(value), + ]), + ), + ...(typeof rawShot?.sourcePackId === 'string' + ? { sourcePackId: rawShot.sourcePackId } + : {}), + ...(Number.isFinite(Number(rawShot?.sourcePackVersion)) + ? { sourcePackVersion: Number(rawShot.sourcePackVersion) } + : {}), + }; +} + +/** + * Normalize and migrate an entire project object loaded from storage or import. + * Defaults and migrates validated documents; empty projects remain empty. + * @param {Object|null} rawProject - Raw project data (potentially from an older schema) + * @returns {Object} Fully normalized project at the current PROJECT_VERSION + */ +export function normalizeProject(rawProject) { + if (!rawProject || typeof rawProject !== 'object') + return createDefaultProject(); + const projectVersion = Number.isFinite(Number(rawProject.version)) + ? Number(rawProject.version) + : 1; + + const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : []; + const scenes = scenesRaw + .map((scene, sceneIdx) => { + const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : []; + const shots = shotsRaw.map((shot, shotIdx) => + normalizeShot(shot, shotIdx, { projectVersion }), + ); + return { + id: scene?.id || uid('scene'), + title: scene?.title || `Scene ${sceneIdx + 1}`, + releaseLayerIds: [ + ...new Set( + (Array.isArray(scene?.releaseLayerIds) + ? scene.releaseLayerIds + : [] + ).filter( + (layerId) => typeof layerId === 'string' && layerId.trim(), + ), + ), + ], + appliedShotPacks: (Array.isArray(scene?.appliedShotPacks) + ? scene.appliedShotPacks + : [] + ) + .filter((entry) => typeof entry?.id === 'string') + .map((entry) => ({ + id: entry.id, + version: Number(entry.version) || 1, + ...(entry.shotBindings && typeof entry.shotBindings === 'object' + ? { + shotBindings: Object.fromEntries( + Object.entries(entry.shotBindings).filter( + ([title, shotId]) => + typeof title === 'string' && typeof shotId === 'string', + ), + ), + } + : {}), + })), + shots, + }; + }) + // Keep scenes that have shots or at least a title + .filter((scene) => scene.shots.length > 0 || scene.title); + + return { + version: PROJECT_VERSION, + createdAt: rawProject.createdAt || new Date().toISOString(), + updatedAt: rawProject.updatedAt || new Date().toISOString(), + installedBuiltInSceneIds: [ + ...new Set( + (Array.isArray(rawProject.installedBuiltInSceneIds) + ? rawProject.installedBuiltInSceneIds + : [] + ).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()), + ), + ], + scenes, + }; +}