From 92526e9a922ab23fa05c0468705b9216bbb7b0a6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 17:59:53 -0700 Subject: [PATCH] feat(director): add bounded scene data packs and placement --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 11 +- docs/DIRECTOR-DATA-PACKS.md | 114 +++++++ docs/DIRECTOR.md | 17 +- docs/SCENE-DOCUMENT.md | 16 +- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 24 +- scripts/qa-director-packs.mjs | 359 +++++++++++++++++++++ src/app/tools.js | 2 + src/director/camera.test.mjs | 4 +- src/director/document.js | 8 +- src/director/document.test.mjs | 2 +- src/director/packs/geojson.js | 62 ++++ src/director/packs/manifest.js | 138 ++++++++ src/director/packs/packs.test.mjs | 340 +++++++++++++++++++ src/director/packs/session.js | 159 +++++++++ src/director/packs/source.js | 63 ++++ src/director/playback.js | 4 + src/scenes/dataPacks/controller.js | 20 ++ src/scenes/dataPacks/geometry.js | 73 +++++ src/scenes/dataPacks/presentation.js | 217 +++++++++++++ src/scenes/dataPacks/presentation.test.mjs | 57 ++++ src/scenes/director.js | 26 ++ src/scenes/director.test.mjs | 2 +- src/scenes/playbackAdapter.js | 11 +- src/scenes/project.js | 2 + src/standalone/tools.js | 8 + 27 files changed, 1720 insertions(+), 28 deletions(-) create mode 100644 docs/DIRECTOR-DATA-PACKS.md create mode 100644 scripts/qa-director-packs.mjs create mode 100644 src/director/packs/geojson.js create mode 100644 src/director/packs/manifest.js create mode 100644 src/director/packs/packs.test.mjs create mode 100644 src/director/packs/session.js create mode 100644 src/director/packs/source.js create mode 100644 src/scenes/dataPacks/controller.js create mode 100644 src/scenes/dataPacks/geometry.js create mode 100644 src/scenes/dataPacks/presentation.js create mode 100644 src/scenes/dataPacks/presentation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c878c42c8..5d29775e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9dcbd379d..91a1c1871 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/docs/DIRECTOR-DATA-PACKS.md b/docs/DIRECTOR-DATA-PACKS.md new file mode 100644 index 000000000..eb32c2250 --- /dev/null +++ b/docs/DIRECTOR-DATA-PACKS.md @@ -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. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 0aefbf0e3..2ba452b7d 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -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, diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md index 35737156e..f6dc66636 100644 --- a/docs/SCENE-DOCUMENT.md +++ b/docs/SCENE-DOCUMENT.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b84602e3f..3374b88fc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 736800daf..8147f85e4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-director-packs.mjs b/scripts/qa-director-packs.mjs new file mode 100644 index 000000000..9ef68d420 --- /dev/null +++ b/scripts/qa-director-packs.mjs @@ -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; diff --git a/src/app/tools.js b/src/app/tools.js index 87b51a5f3..27d0cc2d1 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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, }); diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs index 54a613d0f..f3ecb0fbc 100644 --- a/src/director/camera.test.mjs +++ b/src/director/camera.test.mjs @@ -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', () => { diff --git a/src/director/document.js b/src/director/document.js index efa806ed8..0def9ff4d 100644 --- a/src/director/document.js +++ b/src/director/document.js @@ -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; } diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs index 692796921..18f4f017a 100644 --- a/src/director/document.test.mjs +++ b/src/director/document.test.mjs @@ -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( diff --git a/src/director/packs/geojson.js b/src/director/packs/geojson.js new file mode 100644 index 000000000..49f9f0f55 --- /dev/null +++ b/src/director/packs/geojson.js @@ -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 }; + }); +} diff --git a/src/director/packs/manifest.js b/src/director/packs/manifest.js new file mode 100644 index 000000000..eff00eb12 --- /dev/null +++ b/src/director/packs/manifest.js @@ -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'); + }); + }); +} diff --git a/src/director/packs/packs.test.mjs b/src/director/packs/packs.test.mjs new file mode 100644 index 000000000..036f31c02 --- /dev/null +++ b/src/director/packs/packs.test.mjs @@ -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: '