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

+4
View File
@@ -1,5 +1,9 @@
# Changelog
- Director scene documents now support bounded data-pack manifests, per-shot
selection and registered GeoJSON/PNG/media loaders with explicit placement,
visible attribution and cancellation/disposal on Stop or replacement.
- Director version 4 adds named camera anchors and explicit pose-to-pose moves
with shared playback/seek interpolation, easing and holds. Navigation and
manual input cancel authored motion; older scene files retain existing flights.
+9 -2
View File
@@ -1,5 +1,12 @@
# God's Eye View Current State
Director version-5 documents add scene-local data-pack manifests and per-shot
selection. Registered sources acquire bounded, cancellable assets separately from
GeoJSON, PNG and manually played media presentation. Storage paths, attribution
and geographic placement remain distinct; Stop/replacement releases resources.
Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene
content, assets and credit remain unchanged.
Director version-4 documents add named geographic anchors and optional explicit
camera moves with easing, duration and holds. Playback and seek share one
coordinate sampler; navigation/manual input revokes authored motion and pending
@@ -34,9 +41,9 @@ 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. 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 4 is the export format, with
are protected from fallback writes. Version 5 is 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).
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.
+114
View File
@@ -0,0 +1,114 @@
# Director data packs
Version-5 scene documents can declare `dataPacks` on a scene and select them by
`dataPackIds` on each shot. Import validates declarations without loading assets.
Loading, seeking or playing a shot acquires only its selected packs. A shot with
no pack IDs clears the preceding shot's packs. Existing scene recipes and assets
keep their current paths, notices and behavior.
A pack manifest is version 1 and requires an ID, format, source, attribution and
placement. Sources describe storage; placement describes geography. Source names
refer to trusted application registrations, never scripts to import. A document
cannot supply request headers, keys, cookies, endpoints or executable handlers.
```json
{
"version": 5,
"scenes": [{
"id": "example",
"title": "My geographic notes",
"dataPacks": [{
"id": "notes",
"version": 1,
"format": "geojson",
"source": { "adapter": "assets", "path": "my-scene/notes.geojson" },
"attribution": { "text": "Your name", "license": "Your asset's actual license" },
"placement": { "altitudeReference": "ellipsoid" }
}],
"shots": [{
"id": "overview",
"camera": { "lat": 30.2672, "lon": -97.742, "alt": 2600, "pitch": -70 },
"durationSec": 4,
"holdSec": 2,
"dataPackIds": ["notes"]
}]
}]
}
```
For the standalone application, put that asset at
`public/scene-assets/my-scene/notes.geojson`. The registered `assets` source has
an explicit `/scene-assets/` base on the application's origin. Importing a file
from disk does not grant access to adjacent local files or infer a remote base.
Relative paths cannot contain traversal, query strings, fragments, URL syntax,
percent escapes or empty segments. Asset directories are served as static files;
this is not a new arbitrary-URL proxy.
## Formats and placement
- **`geojson`:** a UTF-8 `FeatureCollection`, served as `application/geo+json` or
`application/json`. Each feature needs a unique string `id`. Point, LineString
and Polygon (including holes) are supported. Coordinates are WGS84
`[longitude, latitude, ellipsoidHeightMeters]`; missing height means zero,
which can be underground. Supply appropriate heights. Geometry properties
are ignored: they cannot select external icons, HTML, styling or scripts.
Placement is `{ "altitudeReference": "ellipsoid" }`.
- **`image`:** PNG served as `image/png`, at most 4096×4096 pixels, checked before
decoding. Placement is `{ "bounds": [west, south, east, north], "height": 300,
"altitudeReference": "ellipsoid" }`. Bounds must increase and cannot cross
the dateline. The image is a horizontal geographic rectangle at that height.
- **`media`:** MP4/WebM video or MPEG/Ogg/WAV/WebM audio, with the matching standard
MIME type. Placement is `{ "anchorId": "media-location" }`, referring to a
scene-local anchor with explicit ellipsoid height. A labelled marker locates
the media; an attribution card provides native, user-operated playback
controls. Media never autoplays. Codec support depends on the browser. D5 does
not synchronize media time with scene time or embed third-party players.
Pack attribution always includes nonempty `text` and `license`; an optional
HTTPS `url` opens as a source link with no opener/referrer. Importing or sharing
an asset never changes its license. D5 adds no third-party content and preserves
all existing Nepal content and contributor notices.
Optional `byteLength` specifies the exact expected byte count; optional `sha256`
is a lowercase hexadecimal SHA-256 digest checked before rendering. There are at
most 8 packs per scene, 8 MiB per asset, and 32 MiB of selected assets per shot.
GeoJSON additionally allows at most 2,000 features and 50,000 positions.
Transforms, terrain-relative placement, terrain collision avoidance, arbitrary
GeoJSON styles, archives and local filesystem bundles are not implemented.
## Loading and resource ownership
The `director` package export provides `createAssetDirectorySource` and
`createDataPackSession`. The former receives an explicit trusted HTTP(S)
directory `baseUrl` and optional `fetchImpl`; it confines every relative asset
path to that directory. Cross-origin directories require CORS. Requests omit
credentials and referrers, reject redirects, and use `cache: 'no-store'`.
There is no persistent pack cache, offline fallback or silent substitute. A
missing, invalid, oversized or integrity-mismatched asset fails the shot's pack
load, removes partial results and reports a stable error without exposing a URL.
Successful loading leaves the attribution cards visible with the assets.
`createApplicationTools({ sceneDataPacks: { sources } })` and
`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a
registry of source functions. Each receives `{ path, signal, maxBytes }` and
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
signal, bound reads and return the real MIME type. They remain application code,
not scene document fields. Without registrations, referenced sources fail closed.
The portable session receives format adapters separately. Each adapter receives
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
`dispose()`. Core's scene composition supplies Cesium geometry/image rendering
and text-only attribution/native media controls. The session applies a 15-second
load deadline and rejects late results, even when an adapter ignores abort.
Stop, replacement, next-shot selection, scene release and destruction cancel
pending work, remove owned entities/cards, stop media and revoke object URLs.
Run completion releases packs with its run signal. Direct LOAD and seek retain
packs until the next operation or Stop. Resources are reacquired per shot;
there are no disabled pack refresh loops. `getDataPackState()` returns copied
status and resource counts for lifecycle diagnostics.
Run `node scripts/qa-director-packs.mjs` against a credentialed local server to
exercise the real import/load/seek/Stop path with self-authored synthetic assets.
This stage provides the manifest/loading boundary; interactive actions and
visual authoring follow separately.
+9 -8
View File
@@ -78,14 +78,15 @@ and river coordinates are bundled. See the
the same sampler. Ellipsoidal heights are explicit; terrain-relative input
is rejected. Existing shots retain ordinary flights. Curved paths, look-at
targets and visual authoring are later extensions.
4. **Data packs and placement.** Define a pack manifest with format/version,
source, attribution/license and optional integrity/size metadata. Distinguish
where bytes live (relative files, a URL, or an eventual bundle) from where
features appear (geographic bounds, anchors, transforms and elevation rules).
Start with existing GeoJSON/image/media adapters. Resolve relative paths
against an explicit document base; define CORS, caching, missing-asset and
offline behavior. Fetch only through registered source adapters with bounded,
cancellable loading. Scene files carry no credentials or executable modules.
4. **Data packs and placement — implemented for bounded assets.**
[Version 5](DIRECTOR-DATA-PACKS.md) defines a pack manifest with format/version,
source, attribution/license and optional integrity/size metadata. It distinguishes
registered asset directories from geographic coordinates, image bounds and
media anchors. GeoJSON, PNG and native media adapters load with byte/time
limits and explicit ellipsoid heights. Stop and replacement release resources.
Paths resolve against a trusted directory base; missing/offline assets fail
explicitly. Bundles, transforms and terrain-relative placement remain future
extensions. Scene files carry no credentials or executable modules.
5. **Declarative interactions.** Give pickable features stable IDs and connect
clicks to a small action registry: show a text/source card, focus an anchor,
seek a shot, or change an allowed layer state. Define selection feedback,
+10 -6
View File
@@ -1,13 +1,13 @@
# Scene document format
Scenes exports a UTF-8 JSON project. Version 4 adds [camera directions](DIRECTOR-CAMERA.md)
and is the write format. Versions 1, 2, 3 and unversioned legacy files migrate on
Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
a field path before the current project, selection or saved bytes change.
```json
{
"version": 4,
"version": 5,
"scenes": [{
"id": "my-scene",
"title": "My scene",
@@ -95,7 +95,11 @@ can preserve temporary edits. Storage writes use the same validation before repl
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. Version 4 supports scene-local camera anchors and authored moves. Asset-pack
manifests and declarative interactions remain planned extensions. They will
receive stable IDs and their own validated migration when introduced. No scene assets, source links, licenses or contributor attribution
loading progress and run diagnostics remain separate. Camera anchors and authored
moves arrived in version 4; data packs follow in version 5. Declarative
interactions remain a planned extension. No scene assets, source links, licenses or contributor attribution
change with this document boundary.
Version 5 additionally accepts scene `dataPacks` and shot `dataPackIds`, as
defined in [data packs](DIRECTOR-DATA-PACKS.md). Version-4 camera documents
continue to import without adding any packs.
+4 -1
View File
@@ -736,5 +736,8 @@
"src/director/document.test.mjs",
"src/director/camera.test.mjs",
"src/scenes/cameraMotion.test.mjs",
"scripts/qa-director-camera.mjs"
"scripts/qa-director-camera.mjs",
"src/director/packs/packs.test.mjs",
"scripts/qa-director-packs.mjs",
"src/scenes/dataPacks/presentation.test.mjs"
]
+21 -3
View File
@@ -621,7 +621,11 @@
"src/director/document.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js"
"src/director/camera.js",
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
"src/director/packs/manifest.js"
],
"external": []
},
@@ -655,7 +659,14 @@
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/scenes/cameraMotion.js"
"src/scenes/cameraMotion.js",
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
"src/director/packs/manifest.js",
"src/scenes/dataPacks/controller.js",
"src/scenes/dataPacks/presentation.js",
"src/scenes/dataPacks/geometry.js"
],
"external": ["cesium"]
},
@@ -1766,7 +1777,14 @@
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/scenes/cameraMotion.js"
"src/scenes/cameraMotion.js",
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
"src/director/packs/manifest.js",
"src/scenes/dataPacks/controller.js",
"src/scenes/dataPacks/presentation.js",
"src/scenes/dataPacks/geometry.js"
],
"external": [
"@mapbox/vector-tile",
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env node
/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const output = path.resolve('qa-shots/director-packs');
fs.mkdirSync(output, { recursive: true });
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: { r: 255, g: 80, b: 30, alpha: 0.65 },
},
})
.png()
.toBuffer();
const wav = Buffer.alloc(44 + 16000);
wav.write('RIFF');
wav.writeUInt32LE(wav.length - 8, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(16000, 40);
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
},
{
type: 'Feature',
id: 'line',
geometry: {
type: 'LineString',
coordinates: [
[-97.745, 30.268, 400],
[-97.735, 30.268, 400],
],
},
},
{
type: 'Feature',
id: 'polygon',
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.746, 30.265, 300],
[-97.74, 30.265, 300],
[-97.74, 30.267, 300],
[-97.746, 30.265, 300],
],
],
},
},
],
};
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [],
requests = [];
let pending,
failures = 0;
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.startsWith('/scene-assets/'))
return void request.continue();
requests.push(url.pathname);
if (url.pathname.endsWith('/slow.json')) {
pending = request;
return;
}
const reply = url.pathname.endsWith('/outline.json')
? { contentType: 'application/geo+json', body: JSON.stringify(geojson) }
: url.pathname.endsWith('/image.png')
? { contentType: 'image/png', body: png }
: url.pathname.endsWith('/audio.wav')
? { contentType: 'audio/wav', body: wav }
: { status: 404, body: 'missing' };
void request.respond(reply).catch(() => {});
});
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const manifest = (id, format, file, placement) => ({
id,
version: 1,
format,
source: { adapter: 'assets', path: `qa/${file}` },
attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' },
placement,
});
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
};
const project = {
version: 5,
scenes: [
{
id: 'packs',
title: 'Data packs QA',
anchors: [
{
id: 'media',
lat: 30.269,
lon: -97.737,
alt: 450,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
manifest('outline', 'geojson', 'outline.json', {
altitudeReference: 'ellipsoid',
}),
manifest('image', 'image', 'image.png', {
bounds: [-97.738, 30.264, -97.732, 30.267],
height: 300,
altitudeReference: 'ellipsoid',
}),
manifest('media', 'media', 'audio.wav', { anchorId: 'media' }),
manifest('slow', 'geojson', 'slow.json', {
altitudeReference: 'ellipsoid',
}),
manifest('missing', 'geojson', 'missing.json', {
altitudeReference: 'ellipsoid',
}),
],
shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({
id,
title: id,
camera,
durationSec: 0.3,
holdSec: 0.2,
visual: { style: 'normal', mapStack: 'photoreal' },
layers: {},
dataPackIds:
id === 'all'
? ['outline', 'image', 'media']
: id === 'empty'
? []
: [id],
})),
},
],
};
await d.importProjectFile(
new File([JSON.stringify(project)], 'packs.json'),
);
window.__packBaseline = d.viewer.entities.values.length;
window.__packPrimitiveBaseline = d.viewer.scene.primitives.length;
window.__packRevoked = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => {
window.__packRevoked.push(url);
revoke(url);
};
});
check('import does not acquire unselected assets', requests.length === 0);
const loaded = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 });
window.__packMaterials = Array.from(
{
length:
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
},
(_, i) =>
d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i)
.appearance?.material,
).filter(Boolean);
return {
started: result?.started,
state: d.getDataPackState(),
count: d.viewer.entities.values.length - window.__packBaseline,
primitives:
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
cards: document.querySelectorAll('[data-director-pack]').length,
paused: document.querySelector('[data-director-pack] audio')?.paused,
};
});
check(
'GeoJSON, PNG and media load only through registered sources',
loaded.started && loaded.state.count === 3 && requests.length === 3,
);
check(
'geometry and image batches plus anchored markers render separately',
loaded.count === 2 && loaded.primitives === 3,
);
check(
'attribution is visible and media does not autoplay',
loaded.cards === 3 && loaded.paused === true,
);
await page.waitForFunction(
() => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal',
{ timeout: 15000 },
);
await new Promise((r) => setTimeout(r, 5000));
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
const evidence = await page.evaluate(() => ({
mapStack: window.__godsEyeView.mapStackController.getActiveId(),
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
}));
fs.writeFileSync(
path.join(output, 'report.json'),
JSON.stringify(evidence, null, 2),
);
check(
'static geometry is ready before visual acceptance',
await page.evaluate(() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
return Array.from(
{ length: primitives.length - window.__packPrimitiveBaseline },
(_, i) => primitives.get(window.__packPrimitiveBaseline + i),
).every((p) => p.ready !== false);
}),
);
await page.screenshot({ path: path.join(output, 'packs.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
d._setCameraView({
...d.styleManager.getCameraState(),
heading: 30,
pitch: -85,
});
});
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await new Promise((r) => setTimeout(r, 2000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const life = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const media = document.querySelector('[data-director-pack] audio');
await media.play();
const played = !media.paused;
await d.loadShot('packs', 'empty', { flyDuration: 0.3 });
const cleared =
d.getDataPackState().count === 0 &&
!document.querySelector('[data-director-packs]') &&
d.viewer.entities.values.length === window.__packBaseline &&
d.viewer.scene.primitives.length === window.__packPrimitiveBaseline &&
media.paused &&
!media.getAttribute('src') &&
window.__packRevoked.length === 2 &&
window.__packMaterials.length === 1 &&
window.__packMaterials.every((material) => material.isDestroyed());
await d.seekScene('packs', 0.1);
const seek = d.getDataPackState().count === 3;
d.stopScene();
const stopped =
d.getDataPackState().count === 0 &&
d.viewer.entities.values.length === window.__packBaseline;
const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 });
return {
played,
cleared,
seek,
stopped,
missing: missing?.started === false && d.getDataPackState().count === 0,
};
});
for (const [name, passed] of Object.entries(life)) check(name, passed);
await page.evaluate(() => {
window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot(
'packs',
'slow',
);
});
const deadline = Date.now() + 15000;
while (!pending && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 50));
check('pending transport reached', !!pending);
const cancelled = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const start = performance.now();
d.stopScene();
await window.__pendingPack;
return performance.now() - start < 1000 && d.getDataPackState().count === 0;
});
check('Stop promptly settles a pending transport', cancelled);
if (pending)
await pending
.respond({
contentType: 'application/json',
body: JSON.stringify(geojson),
})
.catch(() => {});
check(
'late response cannot reintroduce entities',
await page.evaluate(
() =>
window.__godsEyeView.viewer.entities.values.length ===
window.__packBaseline &&
window.__godsEyeView.viewer.scene.primitives.length ===
window.__packPrimitiveBaseline,
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
await d.loadShot('packs', 'all', { flyDuration: 0.3 });
await d.importProjectFile(
new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'),
);
});
check(
'replacement import clears every pack resource',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getDataPackState().count === 0 &&
!document.querySelector('[data-director-packs]'),
),
);
check('no uncaught browser errors', errors.length === 0);
if (errors.length)
console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]')));
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+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,
});
}