diff --git a/CHANGELOG.md b/CHANGELOG.md index 922e206e7..5c8ab5dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ Add feed provenance to analyst/view answers and HUD context while retaining exis Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). +- 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. + + +- Director validates bounded version-3 scene files before replacing a project, + preserves unreadable browser saves, migrates legacy bloom once and preserves + zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner. + + +- Separate Director timing, seek calculations, playback clocks and registered + scene-pack presentation rules. Preserve authored content and controls; Stop + releases pending hold timers and stale ticks cannot affect replacement playback. + + - Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up. - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1714771a5..cc3992742 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,6 +6,20 @@ Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow- Satellite and local infrastructure layers expose on-demand analyst records through their current factory owners. Analyst counts and ranks explicitly cover only bounded examined loaded records (default 2,000 per new layer, core satellite rows before dense extras); omitted records can change nearest/count and satellite distance is ground distance. Existing tools and result fields remain available. +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 +holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid +reference. Legacy projects retain their ordinary flights, content and edits. +See [camera directions](DIRECTOR-CAMERA.md). + Realtime voice composes separate connection, response/tool, Radio handoff, input and audio-meter, cost, viewport-context and diagnostic owners. The existing controller exports and session/backend contracts remain available. Tool protocol, @@ -27,8 +41,15 @@ Director's ordered shot runner is exported independently of rendering, UI, storage and scene content. The existing scene controller supplies an adapter for visual/layer state, camera travel, media holds and release. Playback cancellation, preview cleanup, saved projects and all scene assets/attribution are preserved. -Timeline extraction and a shareable scene/data-pack format are planned in -[Director](DIRECTOR.md); those follow-up capabilities are not implemented yet. +Pure authored-time/seek calculations and a playback clock now own timing, +hold deadlines and subscriptions. Stop immediately clears timers and settles +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 5 is the export format, with +zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.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-CAMERA.md b/docs/DIRECTOR-CAMERA.md new file mode 100644 index 000000000..2145c699d --- /dev/null +++ b/docs/DIRECTOR-CAMERA.md @@ -0,0 +1,109 @@ +# Director camera directions + +Version 4 scene documents can name geographic anchors and describe an explicit +camera move followed by a hold. Existing camera-pose shots still use the ordinary +camera flight. Import, playback, Load, Replay and seek accept the new directions; +a visual anchor/path editor is a later authoring step. + +```json +{ + "version": 4, + "scenes": [{ + "id": "austin", + "title": "Across Austin", + "anchors": [ + { "id": "west", "title": "West viewpoint", "lat": 30.2672, + "lon": -97.76, "alt": 6000, "altitudeReference": "ellipsoid" }, + { "id": "east", "title": "East viewpoint", "lat": 30.2672, + "lon": -97.73, "alt": 7000, "altitudeReference": "ellipsoid" } + ], + "shots": [{ + "id": "crossing", + "title": "Cross the city", + "durationSec": 4, + "holdSec": 2, + "camera": { "anchorId": "east", "heading": 10, "pitch": -45, "roll": 0 }, + "move": { + "from": { "anchorId": "west", "heading": 350, "pitch": -45, "roll": 0 }, + "easing": "cubic-in-out" + }, + "visual": { "style": "normal" }, + "layers": {} + }] + }] +} +``` + +## One owner for each authored value + +An anchor is a scene-local geographic position, with a stable `id`, optional +`title`, latitude, longitude, height and explicit altitude reference. Each scene +accepts at most 1,024 anchors. IDs must be unique within that scene. References to +missing anchors fail validation before replacing a project. + +The shot's `camera` is its destination. It can be an inline pose or an +`anchorId` plus orientation. The optional `move.from` uses the same pose/reference +shape. References cannot also carry inline coordinates. They do not form chains. +Heading, pitch and roll belong to each camera pose, so two shots can look in +different directions from the same geographic anchor. Omitted orientation uses +heading 0, pitch −35 and roll 0 degrees. + +An explicit move requires `move.from`, `move.easing`, `durationSec` and `holdSec`. +Its duration is 0.2–86,400 seconds; the hold is 0–86,400 seconds. The destination +is not copied into another field. `linear` and `cubic-in-out` are the supported +easings. A shot without `move` retains the existing ordinary flight, even in a +version-4 file. Its destination may also reference an anchor. + +Capture continues to create ordinary camera-pose shots. Update Shot stores the +current viewport as an inline destination, preserving an explicit move's start +and easing; it does not change a shared anchor and thereby move other shots. +Editing imported directions and anchors currently means editing the scene JSON. + +## Coordinates and altitude + +Latitude/longitude use WGS84 degrees. Orientation uses the application's Cesium +heading/pitch/roll convention in degrees: heading rotates from local north, +negative pitch looks down, and zero pitch is horizontal. Camera positions use +meters above the WGS84 ellipsoid, matching `Cartesian3.fromDegrees` and captured +camera poses. These heights are not mean-sea-level elevations or heights above +the visible surface. + +Every anchor and every inline endpoint of an explicit move must say +`"altitudeReference": "ellipsoid"`. Ordinary legacy poses without that field +already use the same reference. Terrain-relative and mean-sea-level references +are rejected; no terrain request, default ground estimate or implicit conversion +is made. Authors must provide ellipsoidal heights with enough clearance for their +scene. The format does not promise collision avoidance or terrain following. + +During an explicit move, latitude and altitude interpolate between endpoints. +Longitude takes the shortest signed arc across the dateline; heading and roll +also take their shortest signed arcs. An exact 180-degree tie takes the negative +arc. This is an authored coordinate interpolation, not a geodesic or a camera +flight with an automatically raised arc. Endpoints are exact, including zero +pitch. Curved paths and look-at targets are later extensions. + +## Time, seeking and cancellation + +Load and Replay of an explicit move start at its authored `from` pose, regardless +of the current viewport. Play uses that same start. Both live playback and seek +use `sampleCameraMove`; a seek into the hold uses the exact destination. Backward +seeks do not depend on a prior camera position. Existing scene-pack media/reveal +rules can extend holds as before. + +Each active explicit move owns one animation-frame callback. Stop, replacement, +import, teardown, a navigation handoff or manual pointer/wheel input revokes that +callback and settles the move. Navigation also cancels a run during an authored +hold, so the next shot cannot retake the camera. Camera claims still respect +Cockpit's refusal. Already queued callbacks cannot move a replacement scene. +The timing diagnostic includes the active authored camera animation. + +The renderer-independent `gods-eye-view/director` export provides +`resolveCameraPose(scene, camera)`, `resolveCameraMove(scene, shot)` and +`sampleCameraMove(move, progress)`. These expect validated/normalized data. +Rendering and frame ownership live in `src/scenes/cameraMotion.js`; the existing +scene controller supplies camera application and lifecycle cancellation. + +Version-1/2/3 projects migrate to version 4 without converting their ordinary +shots into explicit moves. IDs, positions, durations, visual settings, packs and +source attribution survive. The bloom scale remains independent of the document +version; version-3 bloom is never interpreted as the older inverted scale. 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 079920ec4..2ba452b7d 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -1,7 +1,7 @@ # Director: reusable scene playback -Director is the scene playback system. The first extraction separates ordered -shot execution from the scene editor and application rendering. This document +Director is the scene playback system. Ordered shot execution, authored timing and playback clocks are separated +from the scene editor and application rendering. This document also records the proposed next steps; the sharing format and interactions below are a design plan, not features already available. @@ -33,9 +33,26 @@ untrusted file validation belongs at the import boundary. `src/scenes/playbackAdapter.js` connects this runner to the existing `SceneDirector`. The latter still owns editor state, saved-project import/export, -camera ownership, seeking, clocks and presentation. Pack-specific layer states, -media holds and map preferences retain their current implementation. This first -slice does not make the entire scene controller renderer-independent. +camera ownership and asynchronous load/seek arbitration. Pure seek calculations +live in `src/director/timeline.js`; `src/director/clock.js` owns the run/load/shot +tickers, hold deadlines, snapshots and clock subscriptions. Stop settles pending +holds immediately, and revoked tick callbacks cannot affect a replacement clock. +`getPlaybackTimingState()` exposes copied snapshots and an active-timer count for +lifecycle diagnostics without exposing timer handles. + +The `director` export also provides `sceneTimingForShot`, `sceneSeekState`, +`cameraAtProgress` and `createPlaybackClock`. Timeline functions receive duration +and hold resolvers; the clock receives timing, running-state and progress callbacks. +They import no renderer, storage, recipe or dataset. + +`src/scenes/shotPresentation.js` prepares layer/seek context and resolves holds +and map preferences through a scene-pack registry. The `scenes` export provides +`createScenePackRegistry({ recipes, adapters })`; pass the result as `scenePacks` +in the SceneDirector constructor options. Trusted adapters can supply +`minimumHoldSec(states)`, `resolveVisual(shot, visual, isMapStackAvailable)` and +`cancelMotion(getLayerModule)`. Default composition registers the existing Nepal +presentation rules. These are code-level composition hooks, not executable +modules imported from scene files. Existing media-owner waits remain bounded. All authored scenes, assets, IDs, source links, attribution and existing JSON projects are preserved. The Nepal sequence remains the contribution introduced @@ -45,31 +62,31 @@ and river coordinates are bundled. See the ## Next changes, in order -1. **Timeline and content separation.** Extract pure duration/seek calculations - and a clock with an explicit lifetime. Move content-specific hold, layer and - presentation rules behind registered scene-pack adapters. Keep existing - recipes and content intact. Test direct load, forward/backward seek, replay, - media timeout and Stop at every pending transition. -2. **Versioned scene document.** Build on the existing version-3 project export - and its importer. Specify a schema and migrations before changing writes. - Keep old imports and local-storage projects readable; reject unsupported - versions without replacing the current project. Include stable scene, shot, - anchor, pack and action IDs. Separate portable authored state from transient - loading state and run diagnostics. Bound document size, collection lengths, - numeric values and nesting; show errors at the affected field. -3. **Camera directions.** Define named geographic anchors and explicit - move/hold segments: start and destination, duration, easing, heading, pitch, - roll and stop duration. State coordinate units, altitude reference and - terrain-relative behavior. Support the current camera-pose shots first; - paths, look-at targets and pauses follow with deterministic seek behavior. -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. +1. **Timeline and content separation — implemented.** Pure duration/seek + calculations, an owned clock and registered presentation rules preserve + existing recipes and content. Tests cover direct load, forward/backward seek, + replay, bounded media waits and cancellation at pending transitions. +2. **Versioned scene document — implemented for existing camera-pose shots.** + [The scene document](SCENE-DOCUMENT.md) preserves version-1/2/3 imports. Bounded validation + rejects malformed files before replacing current state; legacy migration + preserves IDs, pack bindings and visual edits. Unreadable saved files are + protected from fallback writes. Camera anchors, pack manifests and actions + receive stable IDs with their respective later format extensions. +3. **Camera directions — implemented for pose-to-pose moves.** + [Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit + start/destination poses, easing, duration and holds. Playback and seek share + 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 — 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 new file mode 100644 index 000000000..f6dc66636 --- /dev/null +++ b/docs/SCENE-DOCUMENT.md @@ -0,0 +1,105 @@ +# Scene document format + +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": 5, + "scenes": [{ + "id": "my-scene", + "title": "My scene", + "shots": [{ + "id": "my-shot", + "title": "Arrival", + "durationSec": 4, + "holdSec": 1, + "camera": { "lat": 51.5, "lon": -0.12, "alt": 800, + "heading": 0, "pitch": -35, "roll": 0 }, + "visual": { "style": "normal" }, + "layers": { "traffic": { "enabled": true } } + }] + }] +} +``` + +## Fields and migration + +The root requires `scenes` (an array, which may be empty). Optional root fields +are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs). +Each scene requires a `shots` array; optional fields are `id`, `title`, +`releaseLayerIds`, `appliedShotPacks` and version-4 `anchors`. A pack marker has `id`, optional numeric +`version` and `shotBindings` (original shot title to saved shot ID). These markers +retain the editor's existing pack-update and rename behavior. + +Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`, +`sourcePackId`, `sourcePackVersion` and version-4 `move`. Scene IDs are unique within the project; +shot IDs are unique within their scene. Existing IDs and pack bindings survive +migration. Missing IDs are generated once and persisted by the next save/export. +Missing titles and optional camera/visual fields receive the editor's defaults. +Empty scenes and intentionally empty projects remain empty. + +Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses +meters above the WGS84 ellipsoid. Import preserves finite heights below +100 meters and zero pitch, which previously fell through to defaults. Anchors and explicit moves require the `ellipsoid` altitude reference; +terrain-relative inputs are rejected. See the camera contract for exact semantics. +Flight durations retain the editor's minimum of 0.2 seconds and its 4-second +fallback for zero; holds may be zero. + +Visual fields are `style`, `mapStack`, `styleParams`, `bloom`, `sharpen`, `hud`, and +`detection`, and `scope`. Bloom has `enabled`, `intensity`, `version`; sharpen has `enabled`, +`intensity`; HUD has `visible`, `variant`; detection has `mode`, `density`, +`allocation`, `fadePct` and `outsideOpacityPct`. Scope has `enabled`, `featherPct`. +The latter settings were written by Update Shot but previously lost during +normalization; they now survive import and capture. +Version-1/2 bloom without an explicit scale version uses the original inverted +scale and migrates once to scale 2. The scene document and bloom scale versions +are separate. Legacy numeric strings are accepted for numeric fields. + +Layer entries accept a boolean shorthand or `{ "enabled": true, "params": {} }`. +Parameters and style parameters retain nested JSON data. They are interpreted by +already registered components; importing a file does not fetch its URLs, register +modules, evaluate code or apply its layers. Unsupported structural fields are +rejected instead of silently dropping potential edits. Map-stack selection +retains the existing registered-stack behavior. + +## Validation limits + +`gods-eye-view/director` exports `parseSceneDocument(text)`, +`validateSceneDocument(project)`, `stringifySceneDocument(project)`, +`SceneDocumentError`, `SCENE_DOCUMENT_VERSION` and `SCENE_DOCUMENT_LIMITS`. +Validation is independent of the renderer, storage and recipes. Parsing checks +UTF-8 size before JSON decoding; the file importer also checks file size before +reading it. Migration/defaults live separately in `src/scenes/project.js`. + +Limits are 5 MiB, 256 scenes, 10,000 total shots, 10,000 entries per collection, +24 levels of nesting, 200,000 visited values, 65,536 characters per JSON string, +256 characters per field/ID, and 4,096 characters per title. Numeric values must +be finite. Camera limits are latitude ±90, longitude ±180, altitude −12,000 to +1 billion meters, heading/roll ±360 and pitch ±90; durations/holds are at most +86,400 seconds. Post-processing values are bounded and normalized to the existing +slider ranges. Object prototype keys are refused at every depth. + +An invalid import leaves the current project and playback untouched. A valid +import cancels and settles pending playback before replacing the project. If +multiple files are read concurrently, only the newest request can replace it. +Disposal prevents a pending import from publishing. + +The browser storage key remains `godsEyeView.sceneProject.v2` for compatibility; +that key's suffix is not the document version. An unreadable saved project stays +in storage. The app supplies temporary defaults and a visible warning, but blocks +saving over the original bytes until a valid file is explicitly imported. Export +can preserve temporary edits. Storage writes use the same validation before replacing saved bytes; invalid +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. 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 bea4c0dba..0a76c8737 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -735,5 +735,16 @@ "src/sources/overpassFeatures.test.mjs", "src/data/cctvHlsStream.test.mjs", "src/layers/cctv/videoPlayback.test.mjs", - "src/ui/cctvVideo.test.mjs" + "src/ui/cctvVideo.test.mjs", + "src/director/clock.test.mjs", + "src/director/timeline.test.mjs", + "src/scenes/packs/registry.test.mjs", + "scripts/qa-director-timing.mjs", + "src/director/document.test.mjs", + "src/director/camera.test.mjs", + "src/scenes/cameraMotion.test.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 553f2bbc0..bb162beb8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -623,7 +623,19 @@ }, "director": { "exports": ["./director"], - "modules": ["src/director/playback.js"], + "modules": [ + "src/director/playback.js", + "src/director/clock.js", + "src/director/timeline.js", + "src/director/document.js", + "src/director/documentFields.js", + "src/director/cameraDocument.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": [] }, "scenes": { @@ -644,7 +656,26 @@ "src/ui/scenes.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", - "src/contextModePolicy.js" + "src/contextModePolicy.js", + "src/director/clock.js", + "src/director/timeline.js", + "src/scenes/shotPresentation.js", + "src/scenes/packs/registry.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js", + "src/director/document.js", + "src/scenes/project.js", + "src/director/documentFields.js", + "src/director/cameraDocument.js", + "src/director/camera.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"] }, @@ -1757,7 +1788,26 @@ "src/voice/realtimeFacade.js", "src/voice/realtimeRadio.js", "src/voice/realtimeTurns.js", - "src/voice/realtimeConnection.js" + "src/voice/realtimeConnection.js", + "src/director/clock.js", + "src/director/timeline.js", + "src/scenes/shotPresentation.js", + "src/scenes/packs/registry.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js", + "src/director/document.js", + "src/scenes/project.js", + "src/director/documentFields.js", + "src/director/cameraDocument.js", + "src/director/camera.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": [ "hls.js", @@ -2230,7 +2280,11 @@ }, "ui-navigation": { "exports": ["./ui/navigation"], - "modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"], + "modules": [ + "src/navigationPolicy.js", + "src/ui/navigationController.js", + "src/app/stateChannel.js" + ], "external": ["cesium"] }, "ui-share-restoration": { diff --git a/scripts/qa-director-camera.mjs b/scripts/qa-director-camera.mjs new file mode 100644 index 000000000..62f0af5ff --- /dev/null +++ b/scripts/qa-director-camera.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** Verify imported camera directions through real rendering and camera authority handoffs. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const output = path.resolve('qa-shots/director-camera'); +fs.mkdirSync(output, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failed = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failed++; +} +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 }, + ); + const result = await page.evaluate(async () => { + const { sceneDirector: d, styleManager: style } = window.__godsEyeView; + const file = { + version: 4, + scenes: [ + { + id: 'directions', + title: 'Camera directions', + anchors: [ + { + id: 'start', + title: 'Austin west', + lat: 30.2672, + lon: -97.76, + alt: 6000, + altitudeReference: 'ellipsoid', + }, + { + id: 'end', + title: 'Austin east', + lat: 30.2672, + lon: -97.73, + alt: 7000, + altitudeReference: 'ellipsoid', + }, + ], + shots: [ + { + id: 'move', + title: 'Across Austin', + durationSec: 1, + holdSec: 0.2, + camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 }, + move: { + from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 }, + easing: 'linear', + }, + layers: {}, + visual: { style: 'normal' }, + }, + ], + }, + ], + }; + await d.importProjectFile( + new File([JSON.stringify(file)], 'directions.json'), + ); + const scene = d._project.scenes[0]; + const shot = scene.shots[0]; + const imported = + scene.anchors.length === 2 && shot.camera.anchorId === 'end'; + await d.seekScene(scene.id, 0.5 / 1.2); + const middle = style.getCameraState(); + const seekMidpoint = + Math.abs(middle.lon - -97.745) < 1e-6 && + Math.abs(middle.alt - 6500) < 0.01; + await d.seekScene(scene.id, 0); + const back = style.getCameraState(); + const backward = Math.abs(back.lon - -97.76) < 1e-6; + const replay = await d.replayShot(scene.id, shot.id); + const end = style.getCameraState(); + const replayEnd = + replay?.started && + Math.abs(end.lon - -97.73) < 1e-6 && + Math.abs(end.alt - 7000) < 0.01; + d.stopScene(); + await d.startScene(scene.id, { single: true, preview: false }); + const completed = + !d.running && d.getPlaybackTimingState().activeTimers === 0; + const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6; + // Updating the target creates an explicit inline pose while retaining the authored start. + d.updateSelectedShot(); + const saved = JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + const updated = + saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' && + saved.scenes[0].shots[0].move.from.anchorId === 'start'; + shot.durationSec = 30; + const run = d.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while (!d._cameraMotion.active && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 20)); + const started = d._cameraMotion.active; + style.runImmediateNavigation('location', () => true); + await run; + const revoked = + started && + !d.running && + !d._cameraMotion.active && + d.getPlaybackTimingState().activeTimers === 0; + const stoppedPose = style.getCameraState(); + await new Promise((r) => setTimeout(r, 150)); + const stable = + Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7; + return { + imported, + seekMidpoint, + backward, + replayEnd, + completed, + settled, + updated, + revoked, + stable, + }; + }); + for (const [name, passed] of Object.entries(result)) check(name, passed); + await new Promise((r) => setTimeout(r, 6000)); + await page.screenshot({ path: path.join(output, 'austin.png') }); + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector; + const pose = d.styleManager.getCameraState(); + d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 }); + d.viewer.scene.requestRender(); + }); + await new Promise((r) => setTimeout(r, 3000)); + await page.screenshot({ path: path.join(output, 'angle.png') }); + const inputStarted = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes[0]; + window.__qaCameraRun = d.startScene(scene.id, { + single: true, + preview: false, + }); + const deadline = Date.now() + 10000; + while (!d._cameraMotion.active && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 20)); + return d._cameraMotion.active; + }); + const canvas = + (await page.$('canvas.cesium-widget-canvas')) || + (await page.$('.cesium-widget canvas')); + const rect = await canvas.boundingBox(); + await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); + await page.mouse.down(); + await page.mouse.up(); + const input = + inputStarted && + (await page.evaluate(async () => { + await window.__qaCameraRun; + const d = window.__godsEyeView.sceneDirector; + return !d.running && d.getPlaybackTimingState().activeTimers === 0; + })); + check('manual canvas input revokes the move and releases clocks', input); + const hold = await page.evaluate(async () => { + const { sceneDirector: d, styleManager: style } = window.__godsEyeView; + const scene = d._project.scenes[0]; + const shot = scene.shots[0]; + shot.durationSec = 0.2; + shot.holdSec = 30; + const run = d.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while ( + (!d._activeSceneTravel || !d.running || d._cameraMotion.active) && + Date.now() < deadline + ) + await new Promise((r) => setTimeout(r, 20)); + const holding = d.running && !d._cameraMotion.active; + const began = performance.now(); + style.runImmediateNavigation('location', () => true); + await run; + return ( + holding && + performance.now() - began < 1000 && + d.getPlaybackTimingState().activeTimers === 0 + ); + }); + check('navigation during an authored hold settles immediately', hold); + + if (errors.length) + console.error( + errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +if (failed) process.exitCode = 1; 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/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs new file mode 100644 index 000000000..1361a8dcb --- /dev/null +++ b/scripts/qa-director-timing.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const shots = path.resolve('qa-shots/director-timing'); +fs.mkdirSync(shots, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +} +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 }, + ); + const result = await page.evaluate(async () => { + const director = window.__godsEyeView.sceneDirector; + const camera = director.styleManager.getCameraState(); + const visual = director.styleManager.getVisualState(); + const scene = { + id: 'timing-qa', + title: 'Timing QA', + shots: [0, 1].map((index) => ({ + id: `timing-${index}`, + title: `Timing ${index}`, + durationSec: 0.4, + holdSec: 0.3, + camera: { ...camera, lon: camera.lon + index * 0.002 }, + visual, + layers: {}, + })), + }; + director._project.scenes.push(scene); + const load = await director.loadShot(scene.id, scene.shots[0].id, { + flyDuration: 0.3, + }); + const forward = await director.seekScene(scene.id, 0.9); + const forwardSnapshot = director.getPlaybackTimingState().snapshot; + const backward = await director.seekScene(scene.id, 0.1); + const backwardSnapshot = director.getPlaybackTimingState().snapshot; + const replay = await director.replayShot(scene.id, scene.shots[1].id); + director.stopScene(); + await director.startScene(scene.id, { single: true, preview: false }); + const completed = + !director.running && director.getPlaybackTimingState().activeTimers === 0; + scene.shots[0].holdSec = 60; + const run = director.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while ( + director.getPlaybackTimingState().activeTimers < 3 && + Date.now() < deadline + ) + await new Promise((resolve) => setTimeout(resolve, 25)); + const holding = director.getPlaybackTimingState().activeTimers >= 3; + const stoppedAt = performance.now(); + director.stopScene('QA Stop during hold'); + await run; + const stoppedPromptly = performance.now() - stoppedAt < 1000; + const noTimers = director.getPlaybackTimingState().activeTimers === 0; + director._project.scenes = director._project.scenes.filter( + ({ id }) => id !== scene.id, + ); + return { + loaded: load?.started, + forward: forward && forwardSnapshot.shotId === 'timing-1', + backward: backward && backwardSnapshot.shotId === 'timing-0', + replay: replay?.started, + completed, + holding, + stoppedPromptly, + noTimers, + }; + }); + for (const [name, passed] of Object.entries(result)) check(name, passed); + const content = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes.find(({ title }) => + /Nepal Flood Incident/i.test(title), + ); + if (!scene) return { exists: false }; + const shot = + scene.shots.find(({ title }) => /Regional Approach/i.test(title)) || + scene.shots[0]; + const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 }); + return { + exists: true, + shots: scene.shots.length, + loaded: loaded?.started === true, + }; + }); + check( + 'existing Nepal scene and authored shots load', + content.exists && content.shots >= 25 && content.loaded, + ); + await new Promise((resolve) => setTimeout(resolve, 2500)); + await page.screenshot({ path: path.join(shots, 'nepal-regional.png') }); + await page.evaluate(() => { + const g = window.__godsEyeView; + g.sceneDirector.stopScene(); + const pose = g.styleManager.getCameraState(); + g.sceneDirector._setCameraView({ + ...pose, + heading: pose.heading + 45, + pitch: -55, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page.screenshot({ path: path.join(shots, 'nepal-angle.png') }); + check( + 'no pending clocks after Stop', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getPlaybackTimingState() + .activeTimers === 0, + ), + ); + check('no browser exceptions', errors.length === 0); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index c4980f314..f56a69239 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -274,6 +274,40 @@ try { 'QA Scene', ), ); + const savedBefore = await page.evaluate(() => + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + const futureFile = path.join(shots, 'future.json'); + fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] })); + await input.uploadFile(futureFile); + await page.waitForFunction(() => + document.getElementById('scene-status').textContent.includes('$.version'), + ); + check( + 'Unsupported versions leave authored state and saved bytes unchanged', + await page.evaluate( + (saved) => + localStorage.getItem('godsEyeView.sceneProject.v2') === saved && + window.__godsEyeView.sceneDirector._getSelectedScene().title === + 'QA Scene', + savedBefore, + ), + ); + const malformed = structuredClone(fixture); + malformed.scenes[0].shots[0].camera.lat = 91; + const malformedFile = path.join(shots, 'malformed.json'); + fs.writeFileSync(malformedFile, JSON.stringify(malformed)); + await input.uploadFile(malformedFile); + await page.waitForFunction(() => + document.getElementById('scene-status').textContent.includes('camera.lat'), + ); + check( + 'Invalid camera field identifies its path without replacing the project', + await page.evaluate( + (saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved, + savedBefore, + ), + ); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); await page.screenshot({ path: path.join(shots, 'narrow.png') }); 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/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 450668e48..42ce6b919 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -5,6 +5,8 @@ import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKos import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import * as Cesium from 'cesium'; import { SceneDirector } from '../scenes/director.js'; +import { createPlaybackClock } from '../director/clock.js'; +import { createDefaultScenePacks } from '../scenes/packs/defaults.js'; import { anchoredCalloutPresentation, BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, @@ -503,6 +505,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { ...nonRunningAnimation(), }); const director = { + _clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }), + _scenePacks: createDefaultScenePacks(), dataManager: { layers: new Map([[layer.id, { module: layer }]]) }, viewer, _sceneSeekGeneration: 0, diff --git a/src/director/camera.js b/src/director/camera.js new file mode 100644 index 000000000..059c7a873 --- /dev/null +++ b/src/director/camera.js @@ -0,0 +1,54 @@ +/** Portable camera poses and authored motion. Angles are degrees; heights are ellipsoidal meters. */ + +/** Resolve an inline pose or a scene-local geographic anchor without modifying authored state. */ +export function resolveCameraPose(scene, camera) { + if (!camera) return null; + const position = camera.anchorId + ? scene?.anchors?.find(({ id }) => id === camera.anchorId) + : camera; + if (!position) throw new Error('Camera anchor is unavailable'); + return { + lat: position.lat, + lon: position.lon, + alt: position.alt, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? -35, + roll: camera.roll ?? 0, + }; +} + +/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */ +export function resolveCameraMove(scene, shot) { + if (!shot?.move) return null; + return { + from: resolveCameraPose(scene, shot.move.from), + to: resolveCameraPose(scene, shot.camera), + easing: shot.move.easing, + durationSec: shot.durationSec, + }; +} + +/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ +export function sampleCameraMove(move, progress) { + const t = Math.max(0, Math.min(1, Number(progress) || 0)); + if (t === 0) return { ...move.from }; + if (t === 1) return { ...move.to }; + const eased = + move.easing === 'linear' + ? t + : t < 0.5 + ? 4 * t ** 3 + : 1 - (-2 * t + 2) ** 3 / 2; + const lerp = (a, b) => a + (b - a) * eased; + const angle = (a, b) => + a + (((((b - a + 540) % 360) + 360) % 360) - 180) * eased; + const { from, to } = move; + return { + lat: lerp(from.lat, to.lat), + lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + alt: lerp(from.alt, to.alt), + heading: angle(from.heading, to.heading), + pitch: lerp(from.pitch, to.pitch), + roll: angle(from.roll, to.roll), + }; +} diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs new file mode 100644 index 000000000..f3ecb0fbc --- /dev/null +++ b/src/director/camera.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSceneDocument, stringifySceneDocument } from './document.js'; +import { normalizeProject } from '../scenes/project.js'; +import { resolveCameraMove, sampleCameraMove } from './camera.js'; +import { sceneSeekState } from './timeline.js'; + +const fixture = () => ({ + version: 4, + scenes: [ + { + id: 'scene', + title: 'Cross the dateline', + anchors: [ + { + id: 'west', + title: 'Start', + lat: 10, + lon: 179, + alt: 400, + altitudeReference: 'ellipsoid', + }, + { + id: 'east', + title: 'End', + lat: 12, + lon: -179, + alt: 800, + altitudeReference: 'ellipsoid', + }, + ], + shots: [ + { + id: 'move', + title: 'Cross', + durationSec: 4, + holdSec: 2, + camera: { anchorId: 'east', heading: 10, pitch: 0, roll: 0 }, + move: { + from: { anchorId: 'west', heading: 350, pitch: -40, roll: 0 }, + easing: 'linear', + }, + }, + ], + }, + ], +}); +test('version 4 retains anchor identity, references and explicit move edits through normalization', () => { + const raw = fixture(); + const project = normalizeProject(parseSceneDocument(JSON.stringify(raw))); + assert.deepEqual(project.scenes[0].anchors, raw.scenes[0].anchors); + assert.deepEqual( + project.scenes[0].shots[0].move, + raw.scenes[0].shots[0].move, + ); + assert.deepEqual( + project.scenes[0].shots[0].camera, + raw.scenes[0].shots[0].camera, + ); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(project))), + project, + ); +}); +test('v3 unversioned bloom already uses scale 2 and is not migrated again in later formats', () => { + const project = normalizeProject( + parseSceneDocument( + JSON.stringify({ + version: 3, + scenes: [ + { shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] }, + ], + }), + ), + ); + 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', () => { + const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture()))) + .scenes[0]; + const move = resolveCameraMove(scene, scene.shots[0]); + const midpoint = sampleCameraMove(move, 0.5); + assert.equal(midpoint.lon, -180); + assert.equal(midpoint.heading, 360); + assert.equal(midpoint.lat, 11); + assert.equal(midpoint.alt, 600); + assert.equal(midpoint.pitch, -20); + const seek = (p) => + sceneSeekState( + scene, + p, + (_, shot) => shot.durationSec + shot.holdSec, + (_, shot) => shot.holdSec, + ); + assert.deepEqual(seek(1 / 3).camera, midpoint); + assert.deepEqual(seek(0).camera, move.from); + assert.deepEqual(seek(5 / 6).camera, move.to); + assert.equal(seek(5 / 6).holdElapsedSec, 1); + assert.deepEqual( + seek(1 / 3).camera, + midpoint, + 'backward seek is independent of previous viewport state', + ); + assert.equal( + sampleCameraMove({ ...move, easing: 'cubic-in-out' }, 0.25).lat, + 10.125, + ); + assert.deepEqual(sampleCameraMove(move, 1), move.to); +}); +test('unknown anchors, duplicate IDs, mixed references, bad easing and unspecified altitude are rejected', () => { + for (const mutate of [ + (s) => { + s.shots[0].camera.anchorId = 'missing'; + }, + (s) => { + s.anchors[1].id = 'west'; + }, + (s) => { + s.anchors[0].altitudeReference = 'terrain'; + }, + (s) => { + delete s.anchors[0].altitudeReference; + }, + (s) => { + s.shots[0].camera.lon = 1; + }, + (s) => { + s.shots[0].move.easing = 'bounce'; + }, + (s) => { + s.shots[0].move.from = { lat: 1, lon: 2, alt: 3 }; + }, + (s) => { + s.shots[0].durationSec = 0; + }, + (s) => { + delete s.shots[0].holdSec; + }, + ]) { + const project = fixture(); + mutate(project.scenes[0]); + assert.throws(() => parseSceneDocument(JSON.stringify(project)), { + name: 'SceneDocumentError', + }); + } + const old = fixture(); + old.version = 3; + assert.throws( + () => parseSceneDocument(JSON.stringify(old)), + /unsupported field/, + ); +}); diff --git a/src/director/cameraDocument.js b/src/director/cameraDocument.js new file mode 100644 index 000000000..1579d54a8 --- /dev/null +++ b/src/director/cameraDocument.js @@ -0,0 +1,88 @@ +import { + fields, + string, + number, + optional, + array, + fail, + uniqueId, +} from './documentFields.js'; + +const POSITION = { lat: [-90, 90], lon: [-180, 180], alt: [-12000, 1e9] }; +const ORIENTATION = { + heading: [-360, 360], + pitch: [-90, 90], + roll: [-360, 360], +}; +function coordinates(value, path, bounds, required, legacy) { + for (const [key, range] of Object.entries(bounds)) { + if (required || Object.hasOwn(value, key)) + number(value[key], `${path}.${key}`, ...range, legacy); + } +} +function reference(value, path) { + if (value !== 'ellipsoid') + fail( + path, + 'expected ellipsoid height in meters; terrain-relative heights are not supported', + ); +} +function pose(value, path, version, anchorIds, explicit = false) { + const anchored = version >= 4 && Object.hasOwn(value || {}, 'anchorId'); + fields(value, path, [ + ...Object.keys(ORIENTATION), + ...(anchored + ? ['anchorId'] + : [ + ...Object.keys(POSITION), + ...(version >= 4 ? ['altitudeReference'] : []), + ]), + ]); + coordinates(value, path, ORIENTATION, false, version < 3); + if (anchored) { + string(value.anchorId, `${path}.anchorId`); + if (!anchorIds.has(value.anchorId)) + fail(`${path}.anchorId`, 'unknown scene anchor'); + } else { + coordinates(value, path, POSITION, explicit, version < 3); + if (explicit || Object.hasOwn(value, 'altitudeReference')) + reference(value.altitudeReference, `${path}.altitudeReference`); + } +} + +/** Validate scene-local anchor IDs, camera references and explicit motion without reading terrain or applying state. */ +export function validateSceneCameras(scene, path, version) { + const anchorIds = new Set(); + optional(scene, 'anchors', path, (anchors, field) => { + array(anchors, field, 1024); + anchors.forEach((anchor, i) => { + const at = `${field}[${i}]`; + fields(anchor, at, [ + 'id', + 'title', + ...Object.keys(POSITION), + 'altitudeReference', + ]); + string(anchor.id, `${at}.id`); + uniqueId(anchor, at, anchorIds); + optional(anchor, 'title', at, (v, p) => string(v, p, 4096)); + coordinates(anchor, at, POSITION, true, false); + reference(anchor.altitudeReference, `${at}.altitudeReference`); + }); + }); + scene.shots.forEach((shot, index) => { + const at = `${path}.shots[${index}]`; + optional(shot, 'move', at, (move, field) => { + fields(move, field, ['from', 'easing']); + if (!['linear', 'cubic-in-out'].includes(move.easing)) + fail(`${field}.easing`, 'expected linear or cubic-in-out'); + pose(move.from, `${field}.from`, version, anchorIds, true); + pose(shot.camera, `${at}.camera`, version, anchorIds, true); + number(shot.durationSec, `${at}.durationSec`, 0.2, 86400, false); + number(shot.holdSec, `${at}.holdSec`, 0, 86400, false); + }); + optional(shot, 'camera', at, (camera, field) => + pose(camera, field, version, anchorIds), + ); + }); +} diff --git a/src/director/clock.js b/src/director/clock.js new file mode 100644 index 000000000..fa83047a3 --- /dev/null +++ b/src/director/clock.js @@ -0,0 +1,301 @@ +const clamp01 = (value) => Math.max(0, Math.min(1, value)); + +/** Own playback timers and scene-clock subscriptions independently of renderer and editor state. */ +export function createPlaybackClock(options) { + return new PlaybackClock(options); +} + +class PlaybackClock { + constructor({ + isRunning, + timingForShot, + onProgress, + now = () => Date.now(), + schedule = (callback, ms) => setInterval(callback, ms), + cancel = (timer) => clearInterval(timer), + }) { + this.isRunning = isRunning; + this.timingForShot = timingForShot; + this.onProgress = onProgress; + this.now = now; + this.schedule = schedule; + this.cancel = cancel; + this._progressTimer = null; + this._shotProgressTimer = null; + this._sceneClockTimer = null; + this._sceneClockListeners = new Set(); + this._sceneClockSnapshot = null; + this._destroyed = false; + this._waitGeneration = 0; + this._shotGeneration = 0; + this._sceneGeneration = 0; + this._waiters = new Set(); + } + + get snapshot() { + return this._sceneClockSnapshot ? { ...this._sceneClockSnapshot } : null; + } + get activeTimers() { + return ( + [ + this._progressTimer, + this._shotProgressTimer, + this._sceneClockTimer, + ].filter((timer) => timer !== null).length + this._waiters.size + ); + } + + stopShot() { + this._shotGeneration++; + this.cancel(this._shotProgressTimer); + this._shotProgressTimer = null; + } + stopSceneTimer() { + this._sceneGeneration++; + this.cancel(this._sceneClockTimer); + this._sceneClockTimer = null; + } + finish() { + this.cancel(this._progressTimer); + this._progressTimer = null; + this.stopSceneTimer(); + } + stop() { + this.cancelWaits(); + this.stopShot(); + this.finish(); + if ( + !this._sceneClockSnapshot || + this._sceneClockSnapshot.stopped || + this._destroyed + ) + return; + this._sceneClockSnapshot = { + ...this._sceneClockSnapshot, + running: false, + stopped: true, + }; + for (const listener of this._sceneClockListeners) { + try { + listener(this.snapshot); + } catch { + /* Observers cannot block cancellation. */ + } + } + } + destroy() { + this.cancelWaits(); + this._destroyed = true; + this.stopShot(); + this.finish(); + this._sceneClockListeners.clear(); + this._sceneClockSnapshot = null; + } + + cancelWaits() { + this._waitGeneration++; + for (const finish of [...this._waiters]) finish(); + } + + /** Own cancellable hold delays; Stop and destroy settle pending waiters immediately. */ + async wait(ms, token) { + const generation = this._waitGeneration; + const endAt = this.now() + ms; + const current = () => + !this._destroyed && + generation === this._waitGeneration && + !token.cancelled && + !token.signal?.aborted; + while (current() && this.now() < endAt) { + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + token.signal?.removeEventListener('abort', finish); + this._waiters.delete(finish); + resolve(); + }; + const timer = setTimeout(finish, 70); + this._waiters.add(finish); + token.signal?.addEventListener('abort', finish, { once: true }); + }); + } + } + + subscribe(listener) { + if (this._destroyed || typeof listener !== 'function') return () => {}; + this._sceneClockListeners.add(listener); + if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + return () => this._sceneClockListeners.delete(listener); + } + + publish( + scene, + shot, + sceneElapsedSec, + { running = this.isRunning(), seeking = false } = {}, + ) { + if (this._destroyed || !scene || !shot) return; + const timing = this.timingForShot(scene, shot); + const elapsedSec = Math.max( + 0, + Math.min(timing.totalSec, Number(sceneElapsedSec) || 0), + ); + this._sceneClockSnapshot = { + sceneId: scene.id, + shotId: shot.id, + shotIndex: timing.shotIndex, + shotCount: scene.shots.length, + sceneElapsedSec: elapsedSec, + sceneDurationSec: timing.totalSec, + sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0, + running: Boolean(running), + seeking: Boolean(seeking), + }; + for (const listener of this._sceneClockListeners) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } + } + + startRunProgress(totalSec) { + this.cancel(this._progressTimer); + if (this._destroyed) return; + const startMs = this.now(); + const totalMs = Math.max(1000, totalSec * 1000); + const timer = this.schedule(() => { + if (this._destroyed || this._progressTimer !== timer || !this.isRunning()) + return; + const elapsedMs = this.now() - startMs; + this.onProgress(elapsedMs / totalMs); + }, 100); + this._progressTimer = timer; + } + + startShotProgress(token, seconds, from, to, sceneClock = null) { + this.stopShot(); + const generation = this._shotGeneration; + if ( + generation !== this._shotGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted || + this.isRunning() + ) + return; + this.onProgress(from); + if (generation !== this._shotGeneration || this._destroyed) return; + if (sceneClock) { + this.publish( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedFrom, + { running: false }, + ); + } + if ( + generation !== this._shotGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted || + this.isRunning() + ) + return; + if (seconds <= 0) { + this.onProgress(to); + if (generation !== this._shotGeneration || this._destroyed) return; + if (sceneClock) { + this.publish( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedTo, + { running: false }, + ); + } + return; + } + const startedAt = this.now(); + const timer = this.schedule(() => { + if (this._shotProgressTimer !== timer) return; + if ( + generation !== this._shotGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted || + this.isRunning() + ) { + this.cancel(timer); + if (this._shotProgressTimer === timer) this._shotProgressTimer = null; + return; + } + const fraction = clamp01((this.now() - startedAt) / (seconds * 1000)); + this.onProgress(from + (to - from) * fraction); + if (generation !== this._shotGeneration || this._destroyed) return; + if (sceneClock) { + this.publish( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedFrom + + (sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) * + fraction, + { running: false }, + ); + } + if (fraction >= 1) { + this.cancel(timer); + if (this._shotProgressTimer === timer) this._shotProgressTimer = null; + } + }, 100); + timer.unref?.(); + this._shotProgressTimer = timer; + } + + startScene(scene, shot, token) { + this.stopSceneTimer(); + const generation = this._sceneGeneration; + const timing = this.timingForShot(scene, shot); + if ( + generation !== this._sceneGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted + ) + return timing; + const startedAt = this.now(); + this.publish(scene, shot, timing.startElapsedSec, { + running: true, + }); + if ( + generation !== this._sceneGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted + ) + return timing; + const timer = this.schedule(() => { + if (this._sceneClockTimer !== timer) return; + if ( + this._destroyed || + token.cancelled || + token.signal?.aborted || + !this.isRunning() + ) { + this.cancel(timer); + if (this._sceneClockTimer === timer) this._sceneClockTimer = null; + return; + } + const shotElapsedSec = Math.min( + timing.durationSec, + (this.now() - startedAt) / 1000, + ); + this.publish(scene, shot, timing.startElapsedSec + shotElapsedSec, { + running: true, + }); + }, 50); + timer.unref?.(); + this._sceneClockTimer = timer; + return timing; + } +} diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs new file mode 100644 index 000000000..c259c10ec --- /dev/null +++ b/src/director/clock.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPlaybackClock } from './clock.js'; + +const scene = { id: 'scene', shots: [{ id: 'shot' }] }; +const shot = scene.shots[0]; +const timing = { + shotIndex: 0, + totalSec: 4, + durationSec: 4, + startElapsedSec: 0, +}; +function fixture() { + const timers = new Map(); + let id = 0, + now = 0, + running = true; + const progress = []; + const clock = createPlaybackClock({ + isRunning: () => running, + timingForShot: () => timing, + onProgress: (value) => progress.push(value), + now: () => now, + schedule: (callback) => { + timers.set(++id, callback); + return id; + }, + cancel: (handle) => timers.delete(handle), + }); + return { + clock, + timers, + progress, + time: (value) => { + now = value; + }, + running: (value) => { + running = value; + }, + }; +} + +test('Stop releases every clock timer and queued callbacks cannot publish later', () => { + const f = fixture(); + const observed = []; + f.clock.subscribe((snapshot) => observed.push(snapshot)); + f.clock.startRunProgress(4); + f.clock.startScene(scene, shot, { cancelled: false }); + f.time(2000); + for (const tick of f.timers.values()) tick(); + assert.equal(f.clock.snapshot.sceneElapsedSec, 2); + const stale = [...f.timers.values()]; + f.clock.stop(); + assert.equal(f.clock.activeTimers, 0); + assert.equal(f.timers.size, 0); + assert.equal(f.clock.snapshot.stopped, true); + const count = observed.length, + progressCount = f.progress.length; + f.time(3000); + for (const tick of stale) tick(); + assert.equal(observed.length, count); + assert.equal(f.progress.length, progressCount); +}); + +test('replacing a shot clock rejects the old callback without clearing the replacement', () => { + const f = fixture(); + f.clock.startScene(scene, shot, {}); + const stale = [...f.timers.values()][0]; + f.time(1000); + f.clock.startScene(scene, shot, {}); + f.time(2000); + stale(); + assert.equal(f.clock.snapshot.sceneElapsedSec, 0); + assert.equal(f.clock.activeTimers, 1); + [...f.timers.values()][0](); + assert.equal(f.clock.snapshot.sceneElapsedSec, 1); + f.clock.destroy(); +}); + +test('direct-load progress finishes once and snapshots cannot mutate the clock', () => { + const f = fixture(); + f.running(false); + f.clock.startShotProgress({}, 2, 0.25, 0.75, { + scene, + shot, + sceneElapsedFrom: 1, + sceneElapsedTo: 3, + }); + const tick = [...f.timers.values()][0]; + f.time(1000); + tick(); + assert.equal(f.progress.at(-1), 0.5); + assert.equal(f.clock.snapshot.sceneElapsedSec, 2); + const copy = f.clock.snapshot; + copy.sceneElapsedSec = 99; + assert.equal(f.clock.snapshot.sceneElapsedSec, 2); + f.time(2000); + tick(); + assert.equal(f.progress.at(-1), 0.75); + assert.equal(f.clock.activeTimers, 0); + const count = f.progress.length; + tick(); + assert.equal(f.progress.length, count); +}); + +test('aborted starts and destruction during initial notification leave no timers or listeners', () => { + const f = fixture(); + const controller = new AbortController(); + controller.abort(); + f.clock.startScene(scene, shot, { signal: controller.signal }); + assert.equal(f.timers.size, 0); + let calls = 0; + f.clock.subscribe(() => { + calls++; + f.clock.destroy(); + }); + f.clock.startScene(scene, shot, {}); + assert.equal(calls, 1); + assert.equal(f.clock.activeTimers, 0); + f.clock.startRunProgress(4); + f.clock.publish(scene, shot, 1); + f.clock.subscribe(() => calls++); + assert.equal(calls, 1); + assert.equal(f.timers.size, 0); +}); + +test('Stop, abort and destroy settle long holds immediately and release their deadlines', async () => { + for (const action of ['stop', 'abort', 'destroy']) { + const clock = createPlaybackClock({ + isRunning: () => true, + timingForShot: () => timing, + onProgress() {}, + }); + const abort = new AbortController(); + const wait = clock.wait(60000, { signal: abort.signal }); + assert.equal(clock.activeTimers, 1); + if (action === 'abort') abort.abort(); + else clock[action](); + await wait; + assert.equal(clock.activeTimers, 0); + clock.destroy(); + } +}); + +test('a subscriber can Stop or replace the initial clock without the old start acquiring a timer', () => { + for (const action of ['stop', 'replace']) { + const f = fixture(); + let first = true; + f.clock.subscribe(() => { + if (!first) return; + first = false; + if (action === 'stop') f.clock.stop(); + else f.clock.startScene(scene, { id: 'replacement' }, {}); + }); + f.clock.startScene(scene, shot, {}); + assert.equal(f.timers.size, action === 'stop' ? 0 : 1); + assert.equal( + f.clock.snapshot.shotId, + action === 'stop' ? shot.id : 'replacement', + ); + f.clock.destroy(); + } + const f = fixture(); + f.running(false); + f.clock.subscribe(() => f.clock.stop()); + f.clock.startShotProgress({}, 10, 0, 1, { + scene, + shot, + sceneElapsedFrom: 0, + sceneElapsedTo: 4, + }); + assert.equal(f.timers.size, 0); +}); diff --git a/src/director/document.js b/src/director/document.js new file mode 100644 index 000000000..0def9ff4d --- /dev/null +++ b/src/director/document.js @@ -0,0 +1,183 @@ +/** Bounded, renderer-independent validation for authored scene documents. */ +import { + SCENE_DOCUMENT_LIMITS, + fail, + object, + fields, + string, + number, + optional, + array, + ids, + 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 = 5; + +function visual(value, path, legacy) { + fields(value, path, [ + 'style', + 'bloom', + 'sharpen', + 'hud', + 'detection', + 'scope', + 'mapStack', + 'styleParams', + ]); + for (const key of ['style', 'mapStack']) optional(value, key, path, string); + optional(value, 'styleParams', path, object); + const specs = { + bloom: { enabled: 'boolean', intensity: [-100, 10000], version: [1, 100] }, + sharpen: { enabled: 'boolean', intensity: [0, 100] }, + hud: { visible: 'boolean', variant: 'string' }, + detection: { + mode: 'string', + density: [0, 100], + allocation: 'string', + fadePct: [0, 100], + outsideOpacityPct: [0, 100], + }, + scope: { enabled: 'boolean', featherPct: [0, 100] }, + }; + for (const [key, spec] of Object.entries(specs)) + optional(value, key, path, (entry, field) => { + fields(entry, field, Object.keys(spec)); + for (const [name, type] of Object.entries(spec)) + optional(entry, name, field, (item, at) => { + if (Array.isArray(type)) number(item, at, ...type, legacy); + else if (type === 'string') string(item, at); + else if (typeof item !== 'boolean') fail(at, 'expected a boolean'); + }); + }); +} + +/** Validate a parsed scene project without mutating it or applying any state. */ +export function validateSceneDocument(project) { + jsonTree(project, '$', { nodes: 0 }); + fields(project, '$', [ + 'version', + 'createdAt', + 'updatedAt', + 'installedBuiltInSceneIds', + 'scenes', + ]); + const version = Object.hasOwn(project, 'version') ? project.version : 1; + if (![1, 2, 3, 4, 5].includes(version)) + fail('$.version', 'unsupported scene project version'); + const legacy = version < 3; + for (const key of ['createdAt', 'updatedAt']) + optional(project, key, '$', string); + optional(project, 'installedBuiltInSceneIds', '$', ids); + array(project.scenes, '$.scenes', SCENE_DOCUMENT_LIMITS.scenes); + const sceneIds = new Set(); + let shotCount = 0; + project.scenes.forEach((scene, index) => { + const path = `$.scenes[${index}]`; + fields(scene, path, [ + 'id', + 'title', + 'releaseLayerIds', + 'appliedShotPacks', + 'shots', + ...(version >= 4 ? ['anchors'] : []), + ...(version >= 5 ? ['dataPacks'] : []), + ]); + uniqueId(scene, path, sceneIds); + optional(scene, 'title', path, (v, p) => string(v, p, 4096)); + optional(scene, 'releaseLayerIds', path, ids); + optional(scene, 'appliedShotPacks', path, (packs, field) => { + array(packs, field); + const packIds = new Set(); + packs.forEach((pack, i) => { + const at = `${field}[${i}]`; + fields(pack, at, ['id', 'version', 'shotBindings']); + string(pack.id, `${at}.id`); + uniqueId(pack, at, packIds); + optional(pack, 'version', at, (v, p) => + number(v, p, 1, 1000000, legacy), + ); + optional(pack, 'shotBindings', at, (bindings, p) => { + object(bindings, p); + for (const [title, id] of Object.entries(bindings)) + string(id, `${p}.${title}`); + }); + }); + }); + array(scene.shots, `${path}.shots`); + shotCount += scene.shots.length; + if (shotCount > SCENE_DOCUMENT_LIMITS.shots) + fail(`${path}.shots`, 'too many shots in project'); + const shotIds = new Set(); + scene.shots.forEach((shot, i) => { + const at = `${path}.shots[${i}]`; + fields(shot, at, [ + 'id', + 'title', + 'durationSec', + 'holdSec', + 'camera', + 'visual', + 'layers', + 'sourcePackId', + 'sourcePackVersion', + ...(version >= 4 ? ['move'] : []), + ...(version >= 5 ? ['dataPackIds'] : []), + ]); + uniqueId(shot, at, shotIds); + optional(shot, 'title', at, (v, p) => string(v, p, 4096)); + optional(shot, 'sourcePackId', at, string); + optional(shot, 'sourcePackVersion', at, (v, p) => + number(v, p, 1, 1000000, legacy), + ); + optional(shot, 'durationSec', at, (v, p) => + number(v, p, 0, 86400, legacy), + ); + optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy)); + optional(shot, 'visual', at, (v, p) => visual(v, p, legacy)); + optional(shot, 'layers', at, (layers, field) => { + object(layers, field); + for (const [id, entry] of Object.entries(layers)) { + string(id, field); + if (typeof entry === 'boolean') continue; + const p = `${field}.${id}`; + fields(entry, p, ['enabled', 'params']); + if (typeof entry.enabled !== 'boolean') + fail(`${p}.enabled`, 'expected a boolean'); + optional(entry, 'params', p, object); + } + }); + }); + validateSceneCameras(scene, path, version); + validateSceneDataPacks(scene, path); + }); + return project; +} + +/** Read bounded JSON. No URLs, modules or actions are executed during import. */ +export function parseSceneDocument(text) { + if ( + typeof text !== 'string' || + text.length > SCENE_DOCUMENT_LIMITS.bytes || + new TextEncoder().encode(text).byteLength > SCENE_DOCUMENT_LIMITS.bytes + ) + fail('$', 'file exceeds 5 MiB'); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + fail('$', 'invalid JSON'); + } + return validateSceneDocument(parsed); +} + +/** Export authored project fields only; callers pass the project, never run state. */ +export function stringifySceneDocument(project) { + // Round-trip drops optional undefined fields created by the editor. + const text = JSON.stringify(project, null, 2); + parseSceneDocument(text); + return text; +} diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs new file mode 100644 index 000000000..18f4f017a --- /dev/null +++ b/src/director/document.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseSceneDocument, + stringifySceneDocument, + validateSceneDocument, + SCENE_DOCUMENT_LIMITS, +} from './document.js'; +import { createDefaultProject, normalizeProject } from '../scenes/project.js'; + +const fixture = () => ({ + version: 3, + scenes: [ + { + id: 'scene', + title: 'My scene', + shots: [ + { + id: 'shot', + title: 'My shot', + durationSec: 3, + holdSec: 0, + camera: { lat: 1, lon: 2, alt: 42, pitch: 0 }, + layers: { + traffic: { enabled: true, params: { nested: [1, 'two', null] } }, + }, + }, + ], + }, + ], +}); +test('all built-in authored content exports and normalizes without edit loss', () => { + const project = createDefaultProject(); + const migrated = normalizeProject( + parseSceneDocument(stringifySceneDocument(project)), + ); + assert.deepEqual( + JSON.parse(stringifySceneDocument(migrated)), + JSON.parse(stringifySceneDocument(project)), + ); + const edited = normalizeProject(fixture()); + edited.scenes[0].appliedShotPacks = [ + { id: 'pack', version: 3, shotBindings: { Original: 'shot' } }, + ]; + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(edited))), + edited, + ); + assert.equal(edited.scenes[0].shots[0].camera.pitch, 0); + assert.equal(edited.scenes[0].shots[0].camera.alt, 42); +}); +test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds survive', () => { + for (const version of [undefined, 1, 2]) { + const project = fixture(); + project.version = version; + project.scenes[0].shots[0].visual = { + bloom: { enabled: true, intensity: 25 }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.equal(migrated.version, 5); + assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150); + assert.equal(migrated.scenes[0].shots[0].id, 'shot'); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); + } +}); +test('an intentionally empty project stays empty', () => { + assert.deepEqual( + normalizeProject(parseSceneDocument('{"version":3,"scenes":[]}')).scenes, + [], + ); +}); +test('missing legacy IDs become stable after the first saved migration', () => { + const project = { scenes: [{ shots: [{}] }] }; + const migrated = normalizeProject(validateSceneDocument(project)); + assert.ok(migrated.scenes[0].id); + assert.ok(migrated.scenes[0].shots[0].id); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); +}); +test('invalid shapes, versions, unknown fields and unsafe keys fail with field paths', () => { + for (const text of [ + 'null', + '[]', + '{}', + '{"version":99,"scenes":[]}', + '{"version":3,"scenes":[],"run":{}}', + '{"scenes":[],"__proto__":{}}', + ]) { + assert.throws(() => parseSceneDocument(text), { + name: 'SceneDocumentError', + }); + } + const project = fixture(); + project.scenes[0].shots[0].camera.lat = 91; + assert.throws( + () => validateSceneDocument(project), + /\$\.scenes\[0\]\.shots\[0\]\.camera.lat/, + ); + project.scenes[0].shots[0].camera.lat = 1; + project.scenes[0].shots.push(structuredClone(project.scenes[0].shots[0])); + assert.throws(() => validateSceneDocument(project), /duplicate ID/); +}); +test('document byte, nesting, collection, finite-number and string bounds are enforced', () => { + assert.throws( + () => parseSceneDocument(' '.repeat(SCENE_DOCUMENT_LIMITS.bytes + 1)), + /5 MiB/, + ); + assert.throws( + () => + parseSceneDocument( + JSON.stringify({ + scenes: [], + createdAt: 'é'.repeat(SCENE_DOCUMENT_LIMITS.bytes / 2), + }), + ), + /5 MiB/, + ); + const project = fixture(); + project.scenes[0].shots[0].camera.lat = Infinity; + assert.throws(() => validateSceneDocument(project), /JSON value/); + project.scenes[0].shots[0].camera.lat = 1; + let nested = project.scenes[0].shots[0].layers.traffic.params; + for (let i = 0; i < 30; i++) nested = nested.child = {}; + assert.throws(() => validateSceneDocument(project), /complexity/); + assert.throws( + () => validateSceneDocument({ scenes: Array(257).fill({ shots: [] }) }), + /256/, + ); +}); + +test('captured scope and extended detection edits survive migration', () => { + const project = fixture(); + project.scenes[0].shots[0].visual = { + scope: { enabled: true, featherPct: 11 }, + detection: { + mode: 'DENSE', + density: 75, + allocation: 'ELASTIC', + fadePct: 7, + outsideOpacityPct: 1, + }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.scope, + project.scenes[0].shots[0].visual.scope, + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.detection, + project.scenes[0].shots[0].visual.detection, + ); +}); diff --git a/src/director/documentFields.js b/src/director/documentFields.js new file mode 100644 index 000000000..681cc6482 --- /dev/null +++ b/src/director/documentFields.js @@ -0,0 +1,94 @@ +/** Shared bounded JSON and field checks for scene formats. */ +export const SCENE_DOCUMENT_LIMITS = Object.freeze({ + bytes: 5 * 1024 * 1024, + scenes: 256, + shots: 10000, + collection: 10000, + depth: 24, + nodes: 200000, + string: 65536, +}); + +/** A project error identifies a field without echoing its supplied value. */ +export class SceneDocumentError extends Error { + constructor(path, reason) { + super(`${path}: ${reason}`); + this.name = 'SceneDocumentError'; + this.path = path; + } +} +export const fail = (path, reason) => { + throw new SceneDocumentError(path, reason); +}; +export const object = (value, path) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(path, 'expected an object'); +}; +export function fields(value, path, allowed) { + object(value, path); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field'); + } +} +export function string(value, path, max = 256) { + if (typeof value !== 'string' || !value.trim() || value.length > max) + fail(path, `expected nonempty text, at most ${max} characters`); +} +export function number(value, path, min, max, legacy) { + const numeric = + legacy && typeof value === 'string' && value.trim() ? Number(value) : value; + if ( + typeof numeric !== 'number' || + !Number.isFinite(numeric) || + numeric < min || + numeric > max + ) + fail(path, `expected a number from ${min} to ${max}`); +} +export function optional(value, key, path, check) { + if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`); +} +export function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) { + if (!Array.isArray(value) || value.length > max) + fail(path, `expected an array of at most ${max} entries`); +} +export function ids(value, path) { + array(value, path); + value.forEach((id, index) => string(id, `${path}[${index}]`)); +} +export function uniqueId(value, path, seen) { + if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration. + string(value.id, `${path}.id`); + if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID'); + seen.add(value.id); +} +export function jsonTree(value, path, budget, depth = 0) { + if ( + ++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes || + depth > SCENE_DOCUMENT_LIMITS.depth + ) + fail(path, 'project complexity limit exceeded'); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.length > SCENE_DOCUMENT_LIMITS.string) + fail(path, 'text is too long'); + return; + } + if (typeof value === 'number' && Number.isFinite(value)) return; + if (!value || typeof value !== 'object') fail(path, 'expected a JSON value'); + if ( + Object.getPrototypeOf(value) !== Object.prototype && + !Array.isArray(value) && + Object.getPrototypeOf(value) !== null + ) + fail(path, 'expected a JSON object'); + const entries = Object.entries(value); + if (entries.length > SCENE_DOCUMENT_LIMITS.collection) + fail(path, 'too many entries'); + for (const [key, child] of entries) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) + fail(path, 'unsafe object key'); + if (key.length > 256) fail(path, 'field name is too long'); + jsonTree(child, `${path}.${key}`, budget, depth + 1); + } +} 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..f31d65090 --- /dev/null +++ b/src/director/packs/packs.test.mjs @@ -0,0 +1,365 @@ +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: '