feat(director): validate scene documents and preserve saved edits

This commit is contained in:
Sameh Khamis committed 2026-09-15 16:20:36 -07:00
1 parent c894d7b920
commit 18300e7e00
13 files changed
+1155 -404

No files matched your search

+268
View File
@@ -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;
}
+161
View File
@@ -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,
);
});
+9
View File
@@ -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';