Add Director authoring, import previews and scene bundles

This commit is contained in:
Sameh Khamis committed 2026-09-15 20:15:32 -07:00
1 parent 62f66c4b86
commit 078fc290ec
25 files changed
+1930 -48

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
import { withShareSignal } from './lifetime.js';
import { fields, array, fail } from '../documentFields.js';
import { PACK_LIMITS, validateAssetPath } from '../packs/manifest.js';
export const BUNDLE_SOURCE = 'scene-bundle';
export const SHARE_LIMITS = Object.freeze({
bytes: 50 * 1024 * 1024,
assets: 64,
});
const MIME = new Set([
'application/json',
'application/geo+json',
'image/png',
'video/mp4',
'video/webm',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
]);
const packsOf = (project) =>
project.scenes.flatMap((scene) => scene.dataPacks || []);
const checkAbort = (signal) => signal?.throwIfAborted();
const digest = async (bytes) =>
Array.from(
new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
(n) => n.toString(16).padStart(2, '0'),
).join('');
function encode(bytes) {
const chunks = [];
for (let i = 0; i < bytes.length; i += 32768)
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 32768)));
return btoa(chunks.join(''));
}
function decode(value) {
if (
typeof value !== 'string' ||
!value.length ||
value.length > Math.ceil(PACK_LIMITS.bytes / 3) * 4 ||
value.length % 4 !== 0 ||
/[^A-Za-z0-9+/=]/.test(value) ||
(value.includes('=') && !/^[A-Za-z0-9+/]+={1,2}$/.test(value))
)
fail('assets', 'invalid or oversized base64 asset');
return Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
}
function checkBytes(bytes, total) {
if (
!(bytes instanceof Uint8Array) ||
!bytes.length ||
bytes.length > PACK_LIMITS.bytes ||
total > PACK_LIMITS.totalBytes
)
fail('assets', 'asset byte limit exceeded');
}
function checkMime(mimeType) {
if (!MIME.has(mimeType)) fail('assets', 'unsupported media type');
}
/** Read inert scene JSON or a bounded asset bundle, verifying bytes before admission. */
export async function parseSceneShare(text, { signal } = {}) {
checkAbort(signal);
if (
typeof text !== 'string' ||
text.length > SHARE_LIMITS.bytes ||
new TextEncoder().encode(text).length > SHARE_LIMITS.bytes
)
fail('$', 'share exceeds 50 MiB');
let input;
try {
input = JSON.parse(text);
} catch {
fail('$', 'invalid JSON');
}
if (input?.format !== 'gev-scene-bundle')
return { project: parseSceneDocument(text), assets: new Map() };
fields(input, '$', ['format', 'version', 'project', 'assets']);
if (input.version !== 1) fail('version', 'unsupported bundle version');
const project = parseSceneDocument(JSON.stringify(input.project));
array(input.assets, 'assets', SHARE_LIMITS.assets);
const assets = new Map();
let total = 0;
for (const entry of input.assets) {
checkAbort(signal);
fields(entry, 'assets', ['path', 'mimeType', 'base64', 'sha256']);
validateAssetPath(entry.path);
checkMime(entry.mimeType);
if (assets.has(entry.path)) fail('assets', 'duplicate asset path');
const bytes = decode(entry.base64);
total += bytes.length;
checkBytes(bytes, total);
const hash = await digest(bytes);
checkAbort(signal);
if (entry.sha256 !== hash) fail('assets', 'asset integrity mismatch');
assets.set(entry.path, { bytes, mimeType: entry.mimeType, sha256: hash });
}
const used = new Set();
for (const pack of packsOf(project)) {
if (pack.source.adapter !== BUNDLE_SOURCE)
fail('project', 'bundle must include every declared pack');
const asset = assets.get(pack.source.path);
used.add(pack.source.path);
if (
!asset ||
pack.byteLength !== asset.bytes.length ||
pack.sha256 !== asset.sha256
)
fail('project', 'missing or mismatched bundle asset');
}
if (used.size !== assets.size) fail('assets', 'unreferenced bundle asset');
return { project, assets };
}
/** File reads have a known budget before text decoding; large bundles use a distinct suffix. */
export async function readSceneShare(file, options) {
const limit = file.name?.endsWith('.gevbundle.json')
? SHARE_LIMITS.bytes
: 5 * 1024 * 1024;
if (file.size > limit)
fail(
'$',
limit === SHARE_LIMITS.bytes
? 'share exceeds 50 MiB'
: 'file exceeds 5 MiB',
);
checkAbort(options?.signal);
const text = await withShareSignal(file.text(), options?.signal);
checkAbort(options?.signal);
return parseSceneShare(text, options);
}
/** Export only explicitly supplied pack bytes; never fetch external assets or alter the source project. */
export async function createSceneBundle(
project,
resolveAsset,
{ signal } = {},
) {
const copy = parseSceneDocument(stringifySceneDocument(project));
const assets = [],
known = new Map();
let total = 0;
for (const pack of packsOf(copy)) {
checkAbort(signal);
const key = JSON.stringify([pack.source.adapter, pack.source.path]);
let entry = known.get(key);
if (!entry) {
if (assets.length >= SHARE_LIMITS.assets)
fail('assets', 'too many bundled assets');
const asset = await withShareSignal(
resolveAsset(pack, { signal }),
signal,
);
checkAbort(signal);
if (!asset) fail('assets', 'select a file for every declared data pack');
const bytes = asset.bytes;
total += bytes?.length || 0;
checkBytes(bytes, total);
checkMime(asset.mimeType);
const sha256 = await digest(bytes);
checkAbort(signal);
if (
(pack.byteLength && pack.byteLength !== bytes.length) ||
(pack.sha256 && pack.sha256 !== sha256)
)
fail('assets', 'selected file does not match declared integrity');
const path = `files/${assets.length}-${pack.source.path.split('/').at(-1).slice(0, 160)}`;
entry = {
path,
mimeType: asset.mimeType,
base64: encode(bytes),
sha256,
byteLength: bytes.length,
};
known.set(key, entry);
assets.push(entry);
} else if (
(pack.byteLength && pack.byteLength !== entry.byteLength) ||
(pack.sha256 && pack.sha256 !== entry.sha256)
)
fail('assets', 'conflicting shared asset integrity');
pack.source = { adapter: BUNDLE_SOURCE, path: entry.path };
pack.byteLength = entry.byteLength;
pack.sha256 = entry.sha256;
}
const text = JSON.stringify({
format: 'gev-scene-bundle',
version: 1,
project: copy,
assets: assets.map(({ byteLength, ...entry }) => entry),
});
if (new TextEncoder().encode(text).length > SHARE_LIMITS.bytes)
fail('$', 'share exceeds 50 MiB');
return text;
}
/** Keep bundled bytes in this project lifetime only; never fall back to a network source. */
export function createBundleAssets() {
let assets = new Map();
return {
replace(next = new Map()) {
assets = new Map(next);
},
clear() {
assets.clear();
},
snapshot: () => new Map(assets),
getState: () => ({
count: assets.size,
bytes: [...assets.values()].reduce((n, a) => n + a.bytes.length, 0),
}),
source({ path, signal, maxBytes = PACK_LIMITS.bytes }) {
checkAbort(signal);
validateAssetPath(path);
const asset = assets.get(path);
if (!asset || asset.bytes.length > maxBytes)
throw new Error('Bundle asset unavailable — reimport the bundle');
return { bytes: asset.bytes.slice(), mimeType: asset.mimeType };
},
};
}
+26
View File
@@ -0,0 +1,26 @@
/** Settle cancelled file/adaptor work promptly; late results never regain ownership. */
export function withShareSignal(work, signal) {
if (!signal) return Promise.resolve(work);
return new Promise((resolve, reject) => {
const abort = () => {
signal.removeEventListener('abort', abort);
reject(signal.reason);
};
if (signal.aborted) {
Promise.resolve(work).catch(() => {});
abort();
return;
}
signal.addEventListener('abort', abort, { once: true });
Promise.resolve(work).then(
(value) => {
signal.removeEventListener('abort', abort);
signal.aborted ? reject(signal.reason) : resolve(value);
},
(error) => {
signal.removeEventListener('abort', abort);
reject(error);
},
);
});
}
+43
View File
@@ -0,0 +1,43 @@
import { BUNDLE_SOURCE } from './bundle.js';
/** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare(
{ project, assets },
{ sourceIds = [], layerIds = [] } = {},
) {
const sources = new Set(sourceIds),
layers = new Set(layerIds);
const packs = project.scenes.flatMap((scene) =>
(scene.dataPacks || []).map((pack) => ({
scene: scene.title || scene.id,
id: pack.id,
path: pack.source.path,
attribution: pack.attribution,
status:
pack.source.adapter === BUNDLE_SOURCE
? assets.has(pack.source.path)
? 'Included in bundle'
: 'Missing bundle file — reimport its bundle'
: sources.has(pack.source.adapter)
? 'Source configured; file checked when loaded'
: 'Source unavailable',
})),
);
return {
scenes: project.scenes.length,
shots: project.scenes.reduce((n, s) => n + s.shots.length, 0),
packs,
missingLayers: [
...new Set(
project.scenes.flatMap((s) =>
s.shots.flatMap((shot) => Object.keys(shot.layers || {})),
),
),
].filter((id) => !layers.has(id)),
externalContent: project.scenes.some(
(s) =>
s.appliedShotPacks?.length || s.shots.some((shot) => shot.sourcePackId),
),
bundledBytes: [...assets.values()].reduce((n, a) => n + a.bytes.length, 0),
};
}
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createSceneBundle,
parseSceneShare,
readSceneShare,
createBundleAssets,
BUNDLE_SOURCE,
SHARE_LIMITS,
} from './bundle.js';
import { describeSceneShare } from './preview.js';
import { editSceneDetails, selectSceneDocument } from '../authoring.js';
const bytes = new TextEncoder().encode(
JSON.stringify({ type: 'FeatureCollection', features: [] }),
);
const fixture = () => ({
version: 6,
scenes: [
{
id: 'one',
title: 'Example',
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'test/data.geojson' },
attribution: {
text: 'Example author',
license: 'CC0-1.0',
url: 'https://example.org/source',
},
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'shot',
title: 'Original',
durationSec: 3,
holdSec: 2,
camera: { lat: 1, lon: 2, alt: 300, pitch: -60 },
layers: { traffic: false },
dataPackIds: ['data'],
},
],
},
{ id: 'other', shots: [] },
],
});
const asset = () => ({
bytes: bytes.slice(),
mimeType: 'application/geo+json',
});
test('selected-scene bundles round trip bytes and attribution without mutating the project or fetching', async () => {
const original = fixture(),
before = JSON.stringify(original);
let reads = 0;
const text = await createSceneBundle(
selectSceneDocument(original, 'one'),
() => {
reads++;
return asset();
},
);
const parsed = await parseSceneShare(text);
assert.equal(reads, 1);
assert.equal(JSON.stringify(original), before);
assert.equal(parsed.project.scenes.length, 1);
const pack = parsed.project.scenes[0].dataPacks[0];
assert.equal(pack.source.adapter, BUNDLE_SOURCE);
assert.equal(pack.byteLength, bytes.length);
assert.deepEqual(
pack.attribution,
original.scenes[0].dataPacks[0].attribution,
);
assert.deepEqual(parsed.assets.get(pack.source.path).bytes, bytes);
});
test('bundle rejects malformed bytes, unknown fields, traversal, duplicates, missing files and broken integrity', async () => {
const valid = JSON.parse(await createSceneBundle(fixture(), () => asset()));
const mutations = [
(b) => (b.version = 2),
(b) => (b.assets[0].path = '../secret'),
(b) => (b.assets[0].base64 = 'A==='),
(b) => (b.assets[0].base64 = '____'),
(b) => (b.assets[0].base64 = btoa('different')),
(b) => (b.assets[0].headers = {}),
(b) => (b.assets[0].mimeType = 'text/html'),
(b) => b.assets.push(b.assets[0]),
(b) => (b.assets = []),
(b) => (b.project.scenes[0].dataPacks[0].source.adapter = 'assets'),
(b) => (b.project.scenes[0].dataPacks = []),
(b) => (b.project.scenes[0].dataPacks[0].sha256 = '0'.repeat(64)),
(b) => (b.assets = Array.from({ length: 65 }, () => b.assets[0])),
];
for (const mutate of mutations) {
const b = structuredClone(valid);
mutate(b);
await assert.rejects(parseSceneShare(JSON.stringify(b)));
}
});
test('asset caps and declared integrity are enforced before creating a downloadable bundle', async () => {
await assert.rejects(
createSceneBundle(fixture(), () => ({
bytes: new Uint8Array(8 * 1024 * 1024 + 1),
mimeType: 'image/png',
})),
/limit/,
);
const p = fixture();
p.scenes[0].dataPacks[0].sha256 = '0'.repeat(64);
await assert.rejects(
createSceneBundle(p, () => asset()),
/integrity/,
);
await assert.rejects(
createSceneBundle(fixture(), () => null),
/select a file/,
);
});
test('duplicate pack paths share one asset and reject conflicting integrity', async () => {
const p = fixture();
p.scenes[0].dataPacks.push({
...structuredClone(p.scenes[0].dataPacks[0]),
id: 'second',
});
let reads = 0;
const text = await createSceneBundle(p, () => {
reads++;
return asset();
});
assert.equal(reads, 1);
assert.equal((await parseSceneShare(text)).assets.size, 1);
p.scenes[0].dataPacks[1].byteLength = 1;
await assert.rejects(
createSceneBundle(p, () => asset()),
/integrity/,
);
});
test('preview reports unavailable sources/layers and missing bundle assets without applying anything', () => {
const p = fixture();
const report = describeSceneShare(
{ project: p, assets: new Map() },
{ sourceIds: [], layerIds: [] },
);
assert.equal(report.packs[0].status, 'Source unavailable');
assert.deepEqual(report.missingLayers, ['traffic']);
p.scenes[0].dataPacks[0].source.adapter = BUNDLE_SOURCE;
assert.match(
describeSceneShare({ project: p, assets: new Map() }).packs[0].status,
/Missing bundle/,
);
});
test('bundle byte owner releases replacement data and has no network fallback', async () => {
const parsed = await parseSceneShare(
await createSceneBundle(fixture(), () => asset()),
);
const store = createBundleAssets();
store.replace(parsed.assets);
const path = parsed.project.scenes[0].dataPacks[0].source.path;
const first = store.source({ path });
first.bytes[0] = 0;
assert.deepEqual(store.source({ path }).bytes, bytes);
const controller = new AbortController();
controller.abort();
assert.throws(() => store.source({ path, signal: controller.signal }));
store.clear();
assert.deepEqual(store.getState(), { count: 0, bytes: 0 });
assert.throws(() => store.source({ path }), /unavailable/);
});
test('oversized files fail before reading and cancellation settles a stalled file without a late result', async () => {
let reads = 0;
await assert.rejects(
readSceneShare({
name: 'large.json',
size: 6 * 1024 * 1024,
text: () => {
reads++;
},
}),
/5 MiB/,
);
await assert.rejects(
readSceneShare({
name: 'large.gevbundle.json',
size: SHARE_LIMITS.bytes + 1,
text: () => {
reads++;
},
}),
/50 MiB/,
);
assert.equal(reads, 0);
let resolve;
const owner = new AbortController();
const work = readSceneShare(
{ name: 'pending.json', text: () => new Promise((r) => (resolve = r)) },
{ signal: owner.signal },
);
owner.abort();
await assert.rejects(work, /abort/i);
resolve(JSON.stringify(fixture()));
});
test('cancelled bundle export never resolves another asset or produces partial output', async () => {
let resolve;
const owner = new AbortController();
const work = createSceneBundle(
fixture(),
() => new Promise((r) => (resolve = r)),
{ signal: owner.signal },
);
owner.abort();
await assert.rejects(work, /abort/i);
resolve(asset());
});
test('details editing preserves content IDs, layers and provenance, rejects invalid drafts atomically', () => {
const p = fixture(),
before = JSON.stringify(p);
p.scenes[0].shots[0].sourcePackId = 'existing';
const scene = p.scenes[0],
shot = scene.shots[0];
const edited = editSceneDetails(
p,
'one',
'shot',
{
anchors: [
{ id: 'a', lat: 1, lon: 2, alt: 400, altitudeReference: 'ellipsoid' },
],
dataPacks: scene.dataPacks,
},
{
camera: { anchorId: 'a' },
durationSec: 4,
holdSec: 0,
dataPackIds: ['data'],
},
);
assert.equal(edited.scenes[0].shots[0].sourcePackId, 'existing');
assert.deepEqual(edited.scenes[0].shots[0].layers, shot.layers);
assert.equal(edited.scenes[0].shots[0].id, 'shot');
assert.throws(() =>
editSceneDetails(
p,
'one',
'shot',
{ anchors: [] },
{ camera: { anchorId: 'missing' } },
),
);
assert.throws(() =>
editSceneDetails(p, 'one', 'shot', { source: 'remote' }, {}),
);
assert.throws(() =>
editSceneDetails(p, 'one', 'shot', {}, { sourcePackId: 'replacement' }),
);
delete p.scenes[0].shots[0].sourcePackId;
assert.equal(JSON.stringify(p), before);
});
test('long valid source filenames still produce an importable bundle', async () => {
const p = fixture();
p.scenes[0].dataPacks[0].source.path = 'x'.repeat(1024);
const parsed = await parseSceneShare(
await createSceneBundle(p, () => asset()),
);
assert.equal(parsed.assets.size, 1);
});