feat(director): add bounded scene data packs and placement

This commit is contained in:
Sameh Khamis committed 2026-09-15 17:59:53 -07:00
1 parent 4436d7f18e
commit 92526e9a92
27 files changed
+1720 -28

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ export function createApplicationTools({
voice = {},
startChrome,
onSceneDirector,
sceneDataPacks,
signal,
defer,
}) {
@@ -28,6 +29,7 @@ export function createApplicationTools({
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks,
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
+2 -2
View File
@@ -62,7 +62,7 @@ test('version 4 retains anchor identity, references and explicit move edits thro
project,
);
});
test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4', () => {
test('v3 unversioned bloom already uses scale 2 and is not migrated again in later formats', () => {
const project = normalizeProject(
parseSceneDocument(
JSON.stringify({
@@ -73,7 +73,7 @@ test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4'
}),
),
);
assert.equal(project.version, 4);
assert.equal(project.version, 5);
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
});
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
+6 -2
View File
@@ -12,9 +12,10 @@ import {
uniqueId,
jsonTree,
} from './documentFields.js';
import { validateSceneDataPacks } from './packs/manifest.js';
import { validateSceneCameras } from './cameraDocument.js';
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
export const SCENE_DOCUMENT_VERSION = 4;
export const SCENE_DOCUMENT_VERSION = 5;
function visual(value, path, legacy) {
fields(value, path, [
@@ -65,7 +66,7 @@ export function validateSceneDocument(project) {
'scenes',
]);
const version = Object.hasOwn(project, 'version') ? project.version : 1;
if (![1, 2, 3, 4].includes(version))
if (![1, 2, 3, 4, 5].includes(version))
fail('$.version', 'unsupported scene project version');
const legacy = version < 3;
for (const key of ['createdAt', 'updatedAt'])
@@ -83,6 +84,7 @@ export function validateSceneDocument(project) {
'appliedShotPacks',
'shots',
...(version >= 4 ? ['anchors'] : []),
...(version >= 5 ? ['dataPacks'] : []),
]);
uniqueId(scene, path, sceneIds);
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
@@ -123,6 +125,7 @@ export function validateSceneDocument(project) {
'sourcePackId',
'sourcePackVersion',
...(version >= 4 ? ['move'] : []),
...(version >= 5 ? ['dataPackIds'] : []),
]);
uniqueId(shot, at, shotIds);
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
@@ -149,6 +152,7 @@ export function validateSceneDocument(project) {
});
});
validateSceneCameras(scene, path, version);
validateSceneDataPacks(scene, path);
});
return project;
}
+1 -1
View File
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.version, 4);
assert.equal(migrated.version, 5);
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
assert.deepEqual(
+62
View File
@@ -0,0 +1,62 @@
import { PACK_LIMITS } from './manifest.js';
/** Decode a bounded geometry-only GeoJSON collection; properties never become HTML, styles or URLs. */
export function decodePackGeoJSON(bytes) {
const value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
);
if (
value?.type !== 'FeatureCollection' ||
!Array.isArray(value.features) ||
value.features.length > PACK_LIMITS.features
)
throw new Error('Expected a bounded FeatureCollection');
let positions = 0;
const ids = new Set();
function position(p) {
if (
!Array.isArray(p) ||
![2, 3].includes(p.length) ||
p.some((v) => !Number.isFinite(v)) ||
Math.abs(p[0]) > 180 ||
Math.abs(p[1]) > 90 ||
(p.length === 3 && (p[2] < -12000 || p[2] > 1e9)) ||
++positions > PACK_LIMITS.positions
)
throw new Error('Invalid geographic position');
return [p[0], p[1], p[2] ?? 0];
}
function line(points, ring = false) {
if (!Array.isArray(points) || points.length < (ring ? 4 : 2))
throw new Error('Invalid line');
const normalized = points.map(position);
if (ring && normalized[0].some((v, i) => v !== normalized.at(-1)[i]))
throw new Error('Unclosed ring');
return normalized;
}
return value.features.map((feature) => {
const id = feature?.id;
if (
feature?.type !== 'Feature' ||
typeof id !== 'string' ||
!id.trim() ||
id.length > 256 ||
ids.has(id)
)
throw new Error('Features require distinct string IDs');
ids.add(id);
const g = feature.geometry;
let coordinates;
if (g?.type === 'Point') coordinates = position(g.coordinates);
else if (g?.type === 'LineString') coordinates = line(g.coordinates);
else if (
g?.type === 'Polygon' &&
Array.isArray(g.coordinates) &&
g.coordinates.length &&
g.coordinates.length <= 128
)
coordinates = g.coordinates.map((ring) => line(ring, true));
else throw new Error('Unsupported geometry');
return { id, type: g.type, coordinates };
});
}
+138
View File
@@ -0,0 +1,138 @@
import {
fields,
string,
number,
array,
optional,
fail,
} from '../documentFields.js';
export const PACK_LIMITS = Object.freeze({
packs: 8,
bytes: 8 * 1024 * 1024,
totalBytes: 32 * 1024 * 1024,
features: 2000,
positions: 50000,
});
/** Asset names are relative directory paths, never requests or executable modules. */
export function validateAssetPath(value, path = 'source.path') {
string(value, path, 1024);
if (
!value
.split('/')
.every((part) => /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(part))
)
fail(
path,
'expected a relative asset path without URL syntax or traversal',
);
}
/** Validate one declarative pack; loading requires separately registered application adapters. */
export function validateDataPack(pack, path, anchorIds) {
fields(pack, path, [
'id',
'version',
'format',
'source',
'attribution',
'placement',
'byteLength',
'sha256',
]);
string(pack.id, `${path}.id`);
if (pack.version !== 1) fail(`${path}.version`, 'unsupported pack version');
if (!['geojson', 'image', 'media'].includes(pack.format))
fail(`${path}.format`, 'unsupported pack format');
fields(pack.source, `${path}.source`, ['adapter', 'path']);
string(pack.source.adapter, `${path}.source.adapter`);
validateAssetPath(pack.source.path, `${path}.source.path`);
fields(pack.attribution, `${path}.attribution`, ['text', 'license', 'url']);
string(pack.attribution.text, `${path}.attribution.text`, 4096);
string(pack.attribution.license, `${path}.attribution.license`, 4096);
optional(pack.attribution, 'url', `${path}.attribution`, (url, at) => {
string(url, at, 2048);
let parsed;
try {
parsed = new URL(url);
} catch {
fail(at, 'expected an HTTPS source link');
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
fail(
at,
'expected an HTTPS source link without credentials, query or fragment',
);
});
optional(pack, 'byteLength', path, (v, at) => {
number(v, at, 1, PACK_LIMITS.bytes, false);
if (!Number.isInteger(v)) fail(at, 'expected an integer');
});
optional(pack, 'sha256', path, (v, at) => {
if (typeof v !== 'string' || !/^[a-f0-9]{64}$/.test(v))
fail(at, 'expected a lowercase SHA-256 digest');
});
const p = pack.placement,
at = `${path}.placement`;
fields(
p,
at,
pack.format === 'image'
? ['bounds', 'height', 'altitudeReference']
: pack.format === 'media'
? ['anchorId']
: ['altitudeReference'],
);
if (pack.format === 'media') {
if (!anchorIds.has(p.anchorId))
fail(`${at}.anchorId`, 'unknown scene anchor');
} else {
if (p.altitudeReference !== 'ellipsoid')
fail(`${at}.altitudeReference`, 'expected ellipsoid height in meters');
if (pack.format === 'image') {
array(p.bounds, `${at}.bounds`, 4);
if (p.bounds.length !== 4)
fail(`${at}.bounds`, 'expected west, south, east, north');
p.bounds.forEach((v, i) =>
number(
v,
`${at}.bounds[${i}]`,
i % 2 ? -90 : -180,
i % 2 ? 90 : 180,
false,
),
);
if (p.bounds[0] >= p.bounds[2] || p.bounds[1] >= p.bounds[3])
fail(`${at}.bounds`, 'expected increasing non-dateline bounds');
number(p.height, `${at}.height`, -12000, 1e9, false);
}
}
}
/** Validate scene-local pack IDs and shot references without acquiring assets. */
export function validateSceneDataPacks(scene, path) {
const packs = Object.hasOwn(scene, 'dataPacks') ? scene.dataPacks : [];
array(packs, `${path}.dataPacks`, PACK_LIMITS.packs);
const anchors = new Set((scene.anchors || []).map((a) => a.id));
const seen = new Set();
packs.forEach((pack, i) => {
validateDataPack(pack, `${path}.dataPacks[${i}]`, anchors);
if (seen.has(pack.id))
fail(`${path}.dataPacks[${i}].id`, 'duplicate pack ID');
seen.add(pack.id);
});
scene.shots.forEach((shot, i) => {
optional(shot, 'dataPackIds', `${path}.shots[${i}]`, (ids, at) => {
array(ids, at, PACK_LIMITS.packs);
if (new Set(ids).size !== ids.length || ids.some((id) => !seen.has(id)))
fail(at, 'expected distinct scene pack IDs');
});
});
}
+340
View File
@@ -0,0 +1,340 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
import { normalizeProject } from '../../scenes/project.js';
import { createDataPackSession } from './session.js';
import { createAssetDirectorySource } from './source.js';
import { decodePackGeoJSON } from './geojson.js';
const pack = () => ({
id: 'outline',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'example/outline.geojson' },
attribution: { text: 'Example author', license: 'CC0-1.0' },
placement: { altitudeReference: 'ellipsoid' },
});
const project = () => ({
version: 5,
scenes: [
{
id: 'scene',
dataPacks: [pack()],
shots: [{ id: 'shot', dataPackIds: ['outline'] }],
},
],
});
const asset = () => ({
bytes: new Uint8Array([1, 2, 3]),
mimeType: 'application/json',
});
const deferred = () => {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
};
test('v5 packs and shot references round trip through project migration without acquiring bytes', () => {
const normalized = normalizeProject(
parseSceneDocument(JSON.stringify(project())),
);
assert.deepEqual(
JSON.parse(stringifySceneDocument(normalized)).scenes[0].dataPacks,
project().scenes[0].dataPacks,
);
assert.deepEqual(normalized.scenes[0].shots[0].dataPackIds, ['outline']);
for (const version of [1, 2, 3, 4]) {
const old = normalizeProject(
parseSceneDocument(
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
),
);
assert.equal(old.version, 5);
assert.equal(old.scenes[0].dataPacks, undefined);
}
});
test('manifest rejects duplicate/unknown IDs, unsupported placement and request or credential syntax', () => {
const mutations = [
(s) => {
s.dataPacks = null;
},
(s) => s.dataPacks.push(pack()),
(s) => s.shots[0].dataPackIds.push('missing'),
(s) => s.shots[0].dataPackIds.push('outline'),
(s) => (s.dataPacks[0].placement.altitudeReference = 'terrain'),
(s) => (s.dataPacks[0].source.headers = { Authorization: 'secret' }),
(s) => (s.dataPacks[0].attribution.url = 'javascript:alert(1)'),
(s) => (s.dataPacks[0].byteLength = 0),
(s) => (s.dataPacks[0].sha256 = 'wrong'),
...[
'../x',
'/api/status',
'https://example.com/x',
'x?a=1',
'x#key',
'%2e%2e/x',
'a\\b',
'a//b',
].map((path) => (s) => (s.dataPacks[0].source.path = path)),
];
for (const mutate of mutations) {
const p = project();
mutate(p.scenes[0]);
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
}
const old = project();
old.version = 4;
assert.throws(() => parseSceneDocument(JSON.stringify(old)));
});
test('image bounds and media anchor references are explicit and validated', () => {
const p = project(),
s = p.scenes[0],
a = s.dataPacks[0];
a.format = 'image';
a.placement = {
bounds: [-98, 30, -97, 31],
height: 200,
altitudeReference: 'ellipsoid',
};
assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p)));
a.placement.bounds[2] = -99;
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
a.format = 'media';
a.placement = { anchorId: 'one' };
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
s.anchors = [
{ id: 'one', lat: 30, lon: -97, alt: 200, altitudeReference: 'ellipsoid' },
];
assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p)));
});
test('directory source confines paths, strips credentials and rejects redirects, oversized streaming bodies and missing assets', async () => {
const requests = [];
const source = createAssetDirectorySource({
baseUrl: 'https://assets.example.org/packs/',
fetchImpl: async (...args) => {
requests.push(args);
return new Response(new Uint8Array([1, 2, 3]), {
headers: { 'Content-Type': 'application/json' },
});
},
});
assert.deepEqual(
(await source({ path: 'a/data.json' })).bytes,
asset().bytes,
);
assert.equal(requests[0][0], 'https://assets.example.org/packs/a/data.json');
assert.equal(requests[0][1].credentials, 'omit');
assert.equal(requests[0][1].redirect, 'error');
assert.equal(requests[0][1].referrerPolicy, 'no-referrer');
await assert.rejects(source({ path: '../api/test' }));
assert.equal(requests.length, 1);
await assert.rejects(source({ path: 'a', maxBytes: 2 }), /byte limit/);
const missing = createAssetDirectorySource({
baseUrl: 'https://example.org/packs/',
fetchImpl: async () => new Response('', { status: 404 }),
});
await assert.rejects(missing({ path: 'a' }), /unavailable/);
});
test('GeoJSON preserves stable geometry IDs but never properties or remote style hints', () => {
const data = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'p',
properties: { title: '<script>', marker: 'https://bad.example/x' },
geometry: { type: 'Point', coordinates: [-97, 30, 300] },
},
],
};
const decode = () =>
decodePackGeoJSON(new TextEncoder().encode(JSON.stringify(data)));
assert.deepEqual(decode(), [
{ id: 'p', type: 'Point', coordinates: [-97, 30, 300] },
]);
data.features.push(data.features[0]);
assert.throws(decode, /IDs/);
data.features.pop();
data.features[0].geometry.coordinates[1] = 91;
assert.throws(decode, /position/);
data.features[0].geometry = {
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[2, 2],
],
],
};
assert.throws(decode, /ring/);
});
test('pack session removes presentations and cancels the transport on Stop', async () => {
let disposed = 0,
signal;
const session = createDataPackSession({
sources: {
assets: async (options) => {
signal = options.signal;
return asset();
},
},
adapters: {
geojson: async () => ({
dispose() {
disposed++;
},
}),
},
});
await session.load([pack()]);
assert.deepEqual(session.getState(), { status: 'ready', count: 1 });
session.clear();
session.destroy();
assert.equal(disposed, 1);
assert.equal(signal.aborted, true);
assert.equal(session.getState().count, 0);
});
test('replacing a pending source settles promptly and ignores its late bytes', async () => {
const first = deferred();
let mounted = 0,
calls = 0;
const session = createDataPackSession({
sources: { assets: () => (++calls === 1 ? first.promise : asset()) },
adapters: {
geojson: () => {
mounted++;
return {
dispose() {
mounted--;
},
};
},
},
});
const old = session.load([pack()]);
await session.load([pack()]);
assert.equal(await old, false);
first.resolve(asset());
await new Promise((r) => setImmediate(r));
assert.equal(mounted, 1);
session.destroy();
assert.equal(mounted, 0);
});
test('late renderer resources are disposed after cancellation without mutating a replacement', async () => {
const rendering = deferred();
let disposed = 0;
const entered = deferred();
const session = createDataPackSession({
sources: { assets: asset },
adapters: {
geojson: () => {
entered.resolve();
return rendering.promise;
},
},
});
const load = session.load([pack()]);
await entered.promise;
session.clear();
assert.equal(await load, false);
rendering.resolve({
dispose() {
disposed++;
},
});
await new Promise((r) => setImmediate(r));
assert.equal(disposed, 1);
});
test('an abort between renderer settlement and continuation cannot leak the returned resource', async () => {
const controller = new AbortController();
let disposed = 0;
const session = createDataPackSession({
sources: { assets: asset },
adapters: {
geojson: () => {
queueMicrotask(() => controller.abort());
return {
dispose() {
disposed++;
},
};
},
},
});
assert.equal(
await session.load([pack()], { signal: controller.signal }),
false,
);
assert.equal(disposed, 1);
assert.equal(session.getState().count, 0);
});
test('timeout settles an uncooperative adapter and failed packs roll back earlier resources', async () => {
const session = createDataPackSession({
timeoutMs: 10,
sources: { assets: () => new Promise(() => {}) },
adapters: { geojson: () => assert.fail() },
});
await assert.rejects(session.load([pack()]), /could not load/);
assert.equal(session.getState().status, 'idle');
let disposed = 0,
called = 0;
const rollback = createDataPackSession({
sources: {
assets: () => {
if (++called === 2) throw new Error('secret URL');
return asset();
},
},
adapters: {
geojson: () => ({
dispose() {
disposed++;
},
}),
},
});
await assert.rejects(
rollback.load([pack(), { ...pack(), id: 'second' }]),
(e) => !e.message.includes('secret') && /could not load/.test(e.message),
);
assert.equal(disposed, 1);
assert.equal(rollback.getState().count, 0);
});
test('byte and integrity checks run before rendering; adapter names never resolve inherited properties', async () => {
let mounted = 0;
const session = createDataPackSession({
sources: { assets: asset },
adapters: {
geojson: () => {
mounted++;
return { dispose() {} };
},
},
});
for (const overrides of [
{ byteLength: 2 },
{ sha256: '0'.repeat(64) },
{ source: { adapter: 'toString', path: 'a' } },
])
await assert.rejects(session.load([{ ...pack(), ...overrides }]));
assert.equal(mounted, 0);
const digest = await crypto.subtle.digest('SHA-256', asset().bytes);
const sha256 = Buffer.from(digest).toString('hex');
await session.load([{ ...pack(), byteLength: 3, sha256 }]);
assert.equal(mounted, 1);
session.destroy();
});
+159
View File
@@ -0,0 +1,159 @@
import { PACK_LIMITS, validateDataPack } from './manifest.js';
// Also settle adapters that fail to observe abort; dispose any late resource they return.
function untilAbort(promise, signal, late = () => {}) {
return new Promise((resolve, reject) => {
let ended = false;
const abort = () => {
if (!ended) {
ended = true;
reject(signal.reason);
}
};
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
Promise.resolve(promise).then(
(value) => {
signal.removeEventListener('abort', abort);
if (ended) late(value);
else {
ended = true;
resolve(value);
}
},
(error) => {
signal.removeEventListener('abort', abort);
if (!ended) {
ended = true;
reject(error);
}
},
);
});
}
/** Own bounded pack acquisition and disposable presentations for a single shot. */
export function createDataPackSession({
sources = {},
adapters = {},
timeoutMs = 15000,
} = {}) {
const sourceMap = new Map(Object.entries(sources));
const adapterMap = new Map(Object.entries(adapters));
let active = null,
disposed = false;
function clear() {
const run = active;
if (!run) return;
active = null;
run.controller.abort();
run.detach();
clearTimeout(run.timer);
for (const handle of run.handles.splice(0).reverse()) handle.dispose();
}
return {
clear,
destroy() {
disposed = true;
clear();
},
getState() {
return {
status: active?.status || 'idle',
count: active?.handles.length || 0,
};
},
async load(packs, { anchors = [], signal } = {}) {
clear();
if (!Array.isArray(packs) || packs.length > PACK_LIMITS.packs)
throw new Error('Too many data packs');
const anchorIds = new Set(anchors.map((anchor) => anchor.id));
packs.forEach((pack, i) =>
validateDataPack(pack, `packs[${i}]`, anchorIds),
);
if (disposed || signal?.aborted) return false;
if (!packs.length) return true;
const controller = new AbortController();
const run = {
controller,
handles: [],
status: 'loading',
detach: () => signal?.removeEventListener('abort', cancel),
};
const cancel = () => {
if (active === run) clear();
};
active = run;
signal?.addEventListener('abort', cancel, { once: true });
run.timer = setTimeout(
() => controller.abort(new Error('Asset load timed out')),
timeoutMs,
);
let total = 0;
try {
for (const pack of packs) {
const source = sourceMap.get(pack.source.adapter),
adapter = adapterMap.get(pack.format);
if (!source || !adapter)
throw new Error('Data pack adapter unavailable');
const asset = await untilAbort(
source({
path: pack.source.path,
signal: controller.signal,
maxBytes: pack.byteLength || PACK_LIMITS.bytes,
}),
controller.signal,
);
controller.signal.throwIfAborted();
const { bytes } = asset;
if (
!(bytes instanceof Uint8Array) ||
!bytes.length ||
bytes.length > PACK_LIMITS.bytes ||
(pack.byteLength && bytes.length !== pack.byteLength)
)
throw new Error('Asset byte length invalid');
total += bytes.length;
if (total > PACK_LIMITS.totalBytes)
throw new Error('Shot assets exceed byte limit');
if (pack.sha256) {
const digest = await untilAbort(
crypto.subtle.digest('SHA-256', bytes),
controller.signal,
);
const hex = Array.from(new Uint8Array(digest), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
if (hex !== pack.sha256)
throw new Error('Asset integrity mismatch');
}
const handle = await untilAbort(
adapter({ pack, asset, anchors, signal: controller.signal }),
controller.signal,
(late) => late?.dispose(),
);
if (!handle || typeof handle.dispose !== 'function')
throw new Error('Data pack adapter requires disposal');
if (active !== run || controller.signal.aborted) {
handle.dispose();
controller.signal.throwIfAborted();
return false;
}
run.handles.push(handle);
}
controller.signal.throwIfAborted();
clearTimeout(run.timer);
run.status = 'ready';
return true;
} catch (error) {
const superseded = active !== run || signal?.aborted || disposed;
if (active === run) clear();
if (superseded) return false;
// Source errors may include URLs; only stable messages reach the UI.
throw new Error(
'Data pack could not load: check its source, format, size or integrity',
);
}
},
};
}
+63
View File
@@ -0,0 +1,63 @@
import { PACK_LIMITS, validateAssetPath } from './manifest.js';
/** Register a fixed asset directory. Scene files cannot replace its origin or escape its path. */
export function createAssetDirectorySource({
baseUrl,
fetchImpl = globalThis.fetch,
}) {
const base = new URL(baseUrl);
if (
!['https:', 'http:'].includes(base.protocol) ||
base.username ||
base.password ||
base.search ||
base.hash ||
!base.pathname.endsWith('/')
)
throw new TypeError(
'Asset source requires an explicit HTTP(S) directory URL',
);
return async ({ path, signal, maxBytes = PACK_LIMITS.bytes }) => {
validateAssetPath(path);
const response = await fetchImpl(new URL(path, base).href, {
signal,
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
cache: 'no-store',
});
if (!response.ok) throw new Error('Asset unavailable');
const reader = response.body?.getReader();
if (!reader) throw new Error('Asset stream unavailable');
const chunks = [];
let length = 0;
try {
if (Number(response.headers.get('content-length')) > maxBytes)
throw new Error('Asset exceeds byte limit');
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
chunks.push(value);
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return {
bytes,
mimeType: (response.headers.get('content-type') || '')
.split(';')[0]
.trim()
.toLowerCase(),
};
};
}
+4
View File
@@ -118,3 +118,7 @@ export {
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
export { createAssetDirectorySource } from './packs/source.js';
export { createDataPackSession } from './packs/session.js';
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
+20
View File
@@ -0,0 +1,20 @@
import { createDataPackSession } from '../../director/packs/session.js';
import { createPackPresentations } from './presentation.js';
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
const session = createDataPackSession({
sources,
adapters: createPackPresentations(viewer),
});
return {
...session,
apply(scene, shot, token) {
const wanted = new Set(shot.dataPackIds || []);
return session.load(
(scene.dataPacks || []).filter((pack) => wanted.has(pack.id)),
{ anchors: scene.anchors, signal: token?.signal },
);
},
};
}
+73
View File
@@ -0,0 +1,73 @@
import * as Cesium from 'cesium';
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Render a bounded geometry pack in owned batches; no asynchronous entity geometry or update loop. */
export function renderPackGeometry({ asset, pack }, handle) {
if (!['application/json', 'application/geo+json'].includes(asset.mimeType))
throw new Error('Expected GeoJSON');
const features = decodePackGeoJSON(asset.bytes);
const polygons = [];
let lines;
for (const feature of features) {
const id = { packId: pack.id, featureId: feature.id };
if (feature.type === 'Point')
handle.add({
name: `${pack.id} / ${feature.id}`,
position: xyz(feature.coordinates),
point: {
pixelSize: 12,
color: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
},
});
else if (feature.type === 'LineString') {
if (!lines) {
lines = new Cesium.PolylineCollection();
handle.primitive(lines);
}
lines.add({
id,
positions: feature.coordinates.map(xyz),
width: 4,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.CYAN,
}),
});
} else
polygons.push(
new Cesium.GeometryInstance({
id,
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
feature.coordinates[0].map(xyz),
feature.coordinates
.slice(1)
.map((ring) => new Cesium.PolygonHierarchy(ring.map(xyz))),
),
perPositionHeight: true,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.GEODESIC,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.CYAN.withAlpha(0.35),
),
},
}),
);
}
if (polygons.length)
handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: polygons,
appearance: new Cesium.PerInstanceColorAppearance({
translucent: true,
flat: true,
closed: false,
}),
}),
);
}
+217
View File
@@ -0,0 +1,217 @@
import * as Cesium from 'cesium';
import { renderPackGeometry } from './geometry.js';
function caption(viewer, pack) {
const card = document.createElement('section');
card.dataset.directorPack = pack.id;
Object.assign(card.style, {
color: '#e3faff',
background: '#08141eed',
border: '1px solid #2491a8',
padding: '8px',
margin: '4px',
maxWidth: '260px',
font: '12px sans-serif',
});
const title = document.createElement('div');
title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
card.append(title);
if (pack.attribution.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.href = pack.attribution.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.color = '#6eeaff';
card.append(link);
}
return card;
}
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Create rendering adapters; data acquisition and authored placement stay independent. */
export function createPackPresentations(viewer) {
let panel;
function addCard(pack) {
if (!panel) {
panel = document.createElement('div');
panel.dataset.directorPacks = '';
Object.assign(panel.style, {
position: 'absolute',
right: '10px',
bottom: '70px',
maxHeight: '40vh',
overflowY: 'auto',
zIndex: '30',
});
viewer.container.append(panel);
}
const card = caption(viewer, pack);
panel.append(card);
return card;
}
function resource(pack) {
const entities = [],
primitives = [],
materials = [],
urls = [];
let card,
destroyed = false;
return {
add(spec) {
const entity = viewer.entities.add(spec);
entities.push(entity);
viewer.scene.requestRender();
return entity;
},
primitive(value) {
primitives.push(viewer.scene.primitives.add(value));
if (value.appearance?.material)
materials.push(value.appearance.material);
viewer.scene.requestRender();
},
card() {
return (card ||= addCard(pack));
},
url(blob) {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
},
dispose() {
if (destroyed) return;
destroyed = true;
for (const media of card?.querySelectorAll('video,audio') || []) {
media.pause();
media.removeAttribute('src');
media.load();
}
card?.remove();
if (panel && !panel.childElementCount) {
panel.remove();
panel = null;
}
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
for (const primitive of primitives.splice(0))
viewer.scene.primitives.remove(primitive);
for (const material of materials.splice(0)) {
if (!material.isDestroyed()) material.destroy();
}
for (const url of urls.splice(0)) URL.revokeObjectURL(url);
viewer.scene.requestRender();
},
};
}
function guarded(render) {
return async (context) => {
const handle = resource(context.pack);
const abort = () => handle.dispose();
context.signal.addEventListener('abort', abort, { once: true });
try {
context.signal.throwIfAborted();
await render(context, handle);
context.signal.throwIfAborted();
handle.card();
return handle;
} catch (error) {
handle.dispose();
throw error;
} finally {
context.signal.removeEventListener('abort', abort);
}
};
}
return {
geojson: guarded(renderPackGeometry),
image: guarded(async ({ asset, pack, signal }, handle) => {
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
const bytes = asset.bytes;
if (
bytes.length < 24 ||
![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].every(
(v, i) => bytes[i] === v,
)
)
throw new Error('Invalid PNG header');
const header = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
const width = header.getUint32(16),
height = header.getUint32(20);
if (!width || !height || width > 4096 || height > 4096)
throw new Error('Image dimensions exceed limit');
const image = new Image();
image.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
const abort = () => {
image.src = '';
};
signal.addEventListener('abort', abort, { once: true });
try {
await image.decode();
} finally {
signal.removeEventListener('abort', abort);
}
signal.throwIfAborted();
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
throw new Error('Image dimensions exceed limit');
handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: new Cesium.GeometryInstance({
id: { packId: pack.id },
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds),
height: pack.placement.height,
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
}),
appearance: new Cesium.MaterialAppearance({
material: Cesium.Material.fromType('Image', { image }),
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
flat: true,
faceForward: true,
translucent: true,
}),
}),
);
}),
media: guarded(({ asset, pack, anchors }, handle) => {
if (
![
'video/mp4',
'video/webm',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
].includes(asset.mimeType)
)
throw new Error('Unsupported media');
const anchor = anchors.find(
(item) => item.id === pack.placement.anchorId,
);
if (!anchor) throw new Error('Missing media anchor');
handle.add({
name: pack.id,
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
label: {
text: pack.id,
font: '14px sans-serif',
pixelOffset: new Cesium.Cartesian2(0, -24),
},
});
const media = document.createElement(
asset.mimeType.startsWith('video/') ? 'video' : 'audio',
);
media.controls = true;
media.preload = 'none';
media.style.width = '240px';
media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
handle.card().append(media);
}),
};
}
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPackPresentations } from './presentation.js';
test('abort releases an in-flight image URL before the decoder settles or the viewer is destroyed', async () => {
let settle;
const pending = new Promise((resolve) => {
settle = resolve;
});
const priorImage = globalThis.Image;
const priorRevoke = URL.revokeObjectURL;
const revoked = [];
globalThis.Image = class {
decode() {
return pending;
}
};
URL.revokeObjectURL = (url) => {
revoked.push(url);
priorRevoke(url);
};
const viewer = {
scene: { requestRender() {} },
entities: {
remove() {
assert.fail('No entity should be acquired');
},
},
};
const signalOwner = new AbortController();
const bytes = new Uint8Array(24);
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
const header = new DataView(bytes.buffer);
header.setUint32(16, 8);
header.setUint32(20, 8);
try {
const rendering = createPackPresentations(viewer).image({
pack: {},
asset: { bytes, mimeType: 'image/png' },
signal: signalOwner.signal,
});
const rejected = assert.rejects(rendering, /abort/i);
signalOwner.abort();
assert.equal(
revoked.length,
1,
'object URL must be released synchronously with cancellation',
);
settle();
await rejected;
assert.equal(revoked.length, 1, 'late decode cannot dispose twice');
} finally {
settle();
globalThis.Image = priorImage;
URL.revokeObjectURL = priorRevoke;
}
});
+26
View File
@@ -25,6 +25,7 @@ import {
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createDefaultScenePacks } from './packs/defaults.js';
import {
layerStatesForShot,
@@ -77,6 +78,7 @@ export class SceneDirector {
{
isMapStackAvailable = () => false,
scenePacks = createDefaultScenePacks(),
dataPacks = {},
} = {},
) {
this._destroyed = false;
@@ -86,6 +88,7 @@ export class SceneDirector {
this.dataManager = dataManager;
this._isMapStackAvailable = isMapStackAvailable;
this._scenePacks = scenePacks;
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
this._cameraMotion = createCameraMotion({
applyPose: (pose) => this._setCameraView(pose),
});
@@ -200,6 +203,7 @@ export class SceneDirector {
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
this._cameraMotion?.destroy();
this._dataPacks?.destroy();
this._controls?.destroy();
this._state.destroy();
this._destroyPromise = Promise.resolve().then(async () => {
@@ -997,6 +1001,7 @@ export class SceneDirector {
// manager) rather than merely ignored once it has already committed.
this._cancelActiveSceneTravel();
this._usesAuthoredCamera = !!shot.move;
this._dataPacks?.clear();
this._loadAbort?.abort();
const controller = new AbortController();
this._loadAbort = controller;
@@ -1041,6 +1046,9 @@ export class SceneDirector {
if (this._loadAbort === controller) this._loadAbort = null;
return { started: false, reason: 'layers-refused' };
}
if (!(await this._applyDataPacks(scene, shot, token)))
return { started: false, reason: 'data-packs-unavailable' };
if (token.cancelled) return;
if (seekState) {
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
@@ -1613,6 +1621,7 @@ export class SceneDirector {
// Aborting cancels a layer transition already in flight; bumping the
// generation disowns everything the load has not yet started.
this._cancelActiveSceneTravel();
this._dataPacks?.clear();
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration++;
@@ -1723,6 +1732,7 @@ export class SceneDirector {
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
*/
stopScene(reason = 'Stopped') {
this._dataPacks?.clear();
this._sceneSeekGeneration++;
this._loadAbort?.abort();
this._loadAbort = null;
@@ -1738,6 +1748,21 @@ export class SceneDirector {
this._logEvent('scene_stopped', { reason });
}
/** Apply only the shot's declared packs through registered sources. */
async _applyDataPacks(scene, shot, token) {
try {
return (await this._dataPacks?.apply(scene, shot, token)) ?? true;
} catch (error) {
if (!token?.cancelled) this._updateStatus(error.message);
return false;
}
}
/** Copied resource state for lifecycle checks and diagnostics. */
getDataPackState() {
return this._dataPacks?.getState() || { status: 'idle', count: 0 };
}
/** Export the entire project as a timestamped JSON file download. */
exportProject() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -1912,6 +1937,7 @@ export class SceneDirector {
* @returns {Promise<boolean>} True only when every owned layer is released
*/
async _releaseSceneLayers(scene, token = null) {
this._dataPacks?.clear();
const layerIds = Array.isArray(scene?.releaseLayerIds)
? scene.releaseLayerIds
: [];
+1 -1
View File
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
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, 4);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 5);
} finally { restore(); }
});
+9 -2
View File
@@ -29,11 +29,18 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
{ isCurrent: () => !token.cancelled && !token.signal?.aborted },
);
},
applyLayers({ scene, shot, token }) {
return director._applyLayerStates(
async applyLayers({ scene, shot, token }) {
const result = await director._applyLayerStates(
director._layerStatesForShot(scene, shot),
token,
);
if (
!token.cancelled &&
!result.refused.length &&
!(await director._applyDataPacks(scene, shot, token))
)
throw new Error('Data packs unavailable');
return result;
},
travel({ scene, shot, token }) {
director._loadedSceneId = scene.id;
+2
View File
@@ -253,6 +253,7 @@ export function normalizeShot(
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
: {}),
},
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {
style: visual.style || 'normal',
@@ -346,6 +347,7 @@ export function normalizeProject(rawProject) {
return {
id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`,
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
releaseLayerIds: [
...new Set(
+8
View File
@@ -1,8 +1,16 @@
import { createAssetDirectorySource } from '../director/packs/source.js';
import { createApplicationTools } from '../app/tools.js';
import { startStandaloneChrome } from './startupChrome.js';
export function createStandaloneTools(options) {
return createApplicationTools({
startChrome: startStandaloneChrome,
sceneDataPacks: {
sources: {
assets: createAssetDirectorySource({
baseUrl: new URL('/scene-assets/', window.location.href).href,
}),
},
},
...options,
});
}