mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Prepare community integration base for the weather draft
This commit is contained in:
commit
fa69852323
48 files changed
+5028
-900
No files matched your search
@@ -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
|
||||
|
||||
+23
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+47
-30
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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') });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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: '<script>', marker: 'https://bad.example/x' },
|
||||
geometry: { type: 'Point', coordinates: [-97, 30, 300] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const decode = () =>
|
||||
decodePackGeoJSON(new TextEncoder().encode(JSON.stringify(data)));
|
||||
assert.deepEqual(decode(), [
|
||||
{ id: 'p', type: 'Point', coordinates: [-97, 30, 300] },
|
||||
]);
|
||||
data.features.push(data.features[0]);
|
||||
assert.throws(decode, /IDs/);
|
||||
data.features.pop();
|
||||
data.features[0].geometry.coordinates[1] = 91;
|
||||
assert.throws(decode, /position/);
|
||||
data.features[0].geometry = {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
],
|
||||
],
|
||||
};
|
||||
assert.throws(decode, /ring/);
|
||||
});
|
||||
|
||||
test('pack session removes presentations and cancels the transport on Stop', async () => {
|
||||
let disposed = 0,
|
||||
signal;
|
||||
const session = createDataPackSession({
|
||||
sources: {
|
||||
assets: async (options) => {
|
||||
signal = options.signal;
|
||||
return asset();
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
geojson: async () => ({
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await session.load([pack()]);
|
||||
assert.deepEqual(session.getState(), { status: 'ready', count: 1 });
|
||||
session.clear();
|
||||
session.destroy();
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(session.getState().count, 0);
|
||||
});
|
||||
|
||||
test('replacing a pending source settles promptly and ignores its late bytes', async () => {
|
||||
const first = deferred();
|
||||
let mounted = 0,
|
||||
calls = 0;
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: () => (++calls === 1 ? first.promise : asset()) },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
mounted++;
|
||||
return {
|
||||
dispose() {
|
||||
mounted--;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const old = session.load([pack()]);
|
||||
await session.load([pack()]);
|
||||
assert.equal(await old, false);
|
||||
first.resolve(asset());
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(mounted, 1);
|
||||
session.destroy();
|
||||
assert.equal(mounted, 0);
|
||||
});
|
||||
|
||||
test('late renderer resources are disposed after cancellation without mutating a replacement', async () => {
|
||||
const rendering = deferred();
|
||||
let disposed = 0;
|
||||
const entered = deferred();
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: asset },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
entered.resolve();
|
||||
return rendering.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
const load = session.load([pack()]);
|
||||
await entered.promise;
|
||||
session.clear();
|
||||
assert.equal(await load, false);
|
||||
rendering.resolve({
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
|
||||
test('an abort between renderer settlement and continuation cannot leak the returned resource', async () => {
|
||||
const controller = new AbortController();
|
||||
let disposed = 0;
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: asset },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
queueMicrotask(() => controller.abort());
|
||||
return {
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await session.load([pack()], { signal: controller.signal }),
|
||||
false,
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(session.getState().count, 0);
|
||||
});
|
||||
|
||||
test('timeout settles an uncooperative adapter and failed packs roll back earlier resources', async () => {
|
||||
const session = createDataPackSession({
|
||||
timeoutMs: 10,
|
||||
sources: { assets: () => new Promise(() => {}) },
|
||||
adapters: { geojson: () => assert.fail() },
|
||||
});
|
||||
await assert.rejects(session.load([pack()]), /could not load/);
|
||||
assert.equal(session.getState().status, 'idle');
|
||||
let disposed = 0,
|
||||
called = 0;
|
||||
const rollback = createDataPackSession({
|
||||
sources: {
|
||||
assets: () => {
|
||||
if (++called === 2) throw new Error('secret URL');
|
||||
return asset();
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
geojson: () => ({
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
rollback.load([pack(), { ...pack(), id: 'second' }]),
|
||||
(e) => !e.message.includes('secret') && /could not load/.test(e.message),
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(rollback.getState().count, 0);
|
||||
});
|
||||
|
||||
test('byte and integrity checks run before rendering; adapter names never resolve inherited properties', async () => {
|
||||
let mounted = 0;
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: asset },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
mounted++;
|
||||
return { dispose() {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const overrides of [
|
||||
{ byteLength: 2 },
|
||||
{ sha256: '0'.repeat(64) },
|
||||
{ source: { adapter: 'toString', path: 'a' } },
|
||||
])
|
||||
await assert.rejects(session.load([{ ...pack(), ...overrides }]));
|
||||
assert.equal(mounted, 0);
|
||||
const digest = await crypto.subtle.digest('SHA-256', asset().bytes);
|
||||
const sha256 = Buffer.from(digest).toString('hex');
|
||||
await session.load([{ ...pack(), byteLength: 3, sha256 }]);
|
||||
assert.equal(mounted, 1);
|
||||
session.destroy();
|
||||
});
|
||||
|
||||
test('failed responses release their body and an already-cancelled source sends no request', async () => {
|
||||
let cancelled = 0,
|
||||
requests = 0;
|
||||
const source = createAssetDirectorySource({
|
||||
baseUrl: 'https://example.org/packs/',
|
||||
fetchImpl: async () => {
|
||||
requests++;
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
cancel() {
|
||||
cancelled++;
|
||||
},
|
||||
}),
|
||||
{ status: 403 },
|
||||
);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source({ path: 'a.json' }), /unavailable/);
|
||||
assert.equal(cancelled, 1);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(source({ path: 'a.json', signal: controller.signal }));
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { PACK_LIMITS, validateDataPack } from './manifest.js';
|
||||
|
||||
// Also settle adapters that fail to observe abort; dispose any late resource they return.
|
||||
function untilAbort(promise, signal, late = () => {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let ended = false;
|
||||
const abort = () => {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
reject(signal.reason);
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
Promise.resolve(promise).then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (ended) late(value);
|
||||
else {
|
||||
ended = true;
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Own bounded pack acquisition and disposable presentations for a single shot. */
|
||||
export function createDataPackSession({
|
||||
sources = {},
|
||||
adapters = {},
|
||||
timeoutMs = 15000,
|
||||
} = {}) {
|
||||
const sourceMap = new Map(Object.entries(sources));
|
||||
const adapterMap = new Map(Object.entries(adapters));
|
||||
let active = null,
|
||||
disposed = false;
|
||||
function clear() {
|
||||
const run = active;
|
||||
if (!run) return;
|
||||
active = null;
|
||||
run.controller.abort();
|
||||
run.detach();
|
||||
clearTimeout(run.timer);
|
||||
for (const handle of run.handles.splice(0).reverse()) handle.dispose();
|
||||
}
|
||||
return {
|
||||
clear,
|
||||
destroy() {
|
||||
disposed = true;
|
||||
clear();
|
||||
},
|
||||
getState() {
|
||||
return {
|
||||
status: active?.status || 'idle',
|
||||
count: active?.handles.length || 0,
|
||||
};
|
||||
},
|
||||
async load(packs, { anchors = [], signal } = {}) {
|
||||
clear();
|
||||
if (!Array.isArray(packs) || packs.length > PACK_LIMITS.packs)
|
||||
throw new Error('Too many data packs');
|
||||
const anchorIds = new Set(anchors.map((anchor) => anchor.id));
|
||||
packs.forEach((pack, i) =>
|
||||
validateDataPack(pack, `packs[${i}]`, anchorIds),
|
||||
);
|
||||
if (disposed || signal?.aborted) return false;
|
||||
if (!packs.length) return true;
|
||||
const controller = new AbortController();
|
||||
const run = {
|
||||
controller,
|
||||
handles: [],
|
||||
status: 'loading',
|
||||
detach: () => signal?.removeEventListener('abort', cancel),
|
||||
};
|
||||
const cancel = () => {
|
||||
if (active === run) clear();
|
||||
};
|
||||
active = run;
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
run.timer = setTimeout(
|
||||
() => controller.abort(new Error('Asset load timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
let total = 0;
|
||||
try {
|
||||
for (const pack of packs) {
|
||||
const source = sourceMap.get(pack.source.adapter),
|
||||
adapter = adapterMap.get(pack.format);
|
||||
if (!source || !adapter)
|
||||
throw new Error('Data pack adapter unavailable');
|
||||
const asset = await untilAbort(
|
||||
source({
|
||||
path: pack.source.path,
|
||||
signal: controller.signal,
|
||||
maxBytes: pack.byteLength || PACK_LIMITS.bytes,
|
||||
}),
|
||||
controller.signal,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
const { bytes } = asset;
|
||||
if (
|
||||
!(bytes instanceof Uint8Array) ||
|
||||
!bytes.length ||
|
||||
bytes.length > PACK_LIMITS.bytes ||
|
||||
(pack.byteLength && bytes.length !== pack.byteLength)
|
||||
)
|
||||
throw new Error('Asset byte length invalid');
|
||||
total += bytes.length;
|
||||
if (total > PACK_LIMITS.totalBytes)
|
||||
throw new Error('Shot assets exceed byte limit');
|
||||
if (pack.sha256) {
|
||||
const digest = await untilAbort(
|
||||
crypto.subtle.digest('SHA-256', bytes),
|
||||
controller.signal,
|
||||
);
|
||||
const hex = Array.from(new Uint8Array(digest), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
if (hex !== pack.sha256)
|
||||
throw new Error('Asset integrity mismatch');
|
||||
}
|
||||
const handle = await untilAbort(
|
||||
adapter({ pack, asset, anchors, signal: controller.signal }),
|
||||
controller.signal,
|
||||
(late) => late?.dispose(),
|
||||
);
|
||||
if (!handle || typeof handle.dispose !== 'function')
|
||||
throw new Error('Data pack adapter requires disposal');
|
||||
if (active !== run || controller.signal.aborted) {
|
||||
handle.dispose();
|
||||
controller.signal.throwIfAborted();
|
||||
return false;
|
||||
}
|
||||
run.handles.push(handle);
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
clearTimeout(run.timer);
|
||||
run.status = 'ready';
|
||||
return true;
|
||||
} catch (error) {
|
||||
const superseded = active !== run || signal?.aborted || disposed;
|
||||
if (active === run) clear();
|
||||
if (superseded) return false;
|
||||
// Source errors may include URLs; only stable messages reach the UI.
|
||||
throw new Error(
|
||||
'Data pack could not load: check its source, format, size or integrity',
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { PACK_LIMITS, validateAssetPath } from './manifest.js';
|
||||
|
||||
/** Register a fixed asset directory. Scene files cannot replace its origin or escape its path. */
|
||||
export function createAssetDirectorySource({
|
||||
baseUrl,
|
||||
fetchImpl = globalThis.fetch,
|
||||
}) {
|
||||
const base = new URL(baseUrl);
|
||||
if (
|
||||
!['https:', 'http:'].includes(base.protocol) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash ||
|
||||
!base.pathname.endsWith('/')
|
||||
)
|
||||
throw new TypeError(
|
||||
'Asset source requires an explicit HTTP(S) directory URL',
|
||||
);
|
||||
return async ({ path, signal, maxBytes = PACK_LIMITS.bytes }) => {
|
||||
validateAssetPath(path);
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(new URL(path, base).href, {
|
||||
signal,
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error('Asset unavailable');
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Asset stream unavailable');
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
try {
|
||||
if (Number(response.headers.get('content-length')) > maxBytes)
|
||||
throw new Error('Asset exceeds byte limit');
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
length += value.byteLength;
|
||||
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return {
|
||||
bytes,
|
||||
mimeType: (response.headers.get('content-type') || '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -96,3 +96,29 @@ const SHOT_PHASES = [
|
||||
'hold',
|
||||
'completeShot',
|
||||
];
|
||||
|
||||
export {
|
||||
sceneTimingForShot,
|
||||
sceneSeekState,
|
||||
cameraAtProgress,
|
||||
} from './timeline.js';
|
||||
export { createPlaybackClock } from './clock.js';
|
||||
|
||||
export {
|
||||
parseSceneDocument,
|
||||
validateSceneDocument,
|
||||
stringifySceneDocument,
|
||||
SceneDocumentError,
|
||||
SCENE_DOCUMENT_VERSION,
|
||||
SCENE_DOCUMENT_LIMITS,
|
||||
} from './document.js';
|
||||
|
||||
export {
|
||||
resolveCameraPose,
|
||||
resolveCameraMove,
|
||||
sampleCameraMove,
|
||||
} from './camera.js';
|
||||
|
||||
export { createAssetDirectorySource } from './packs/source.js';
|
||||
export { createDataPackSession } from './packs/session.js';
|
||||
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
resolveCameraPose,
|
||||
resolveCameraMove,
|
||||
sampleCameraMove,
|
||||
} from './camera.js';
|
||||
|
||||
const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||
|
||||
/** Calculate one shot's boundaries using the supplied content duration resolver. */
|
||||
export function sceneTimingForShot(scene, shot, durationForShot) {
|
||||
const durations =
|
||||
scene?.shots?.map((item) => durationForShot(scene, item)) || [];
|
||||
const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
|
||||
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
|
||||
const startElapsedSec = durations
|
||||
.slice(0, Math.max(0, shotIndex))
|
||||
.reduce((sum, duration) => sum + duration, 0);
|
||||
const durationSec = shotIndex >= 0 ? durations[shotIndex] : 0;
|
||||
const endElapsedSec = Math.min(totalSec, startElapsedSec + durationSec);
|
||||
return {
|
||||
shotIndex,
|
||||
totalSec,
|
||||
durationSec,
|
||||
startElapsedSec,
|
||||
endElapsedSec,
|
||||
startProgress: totalSec > 0 ? startElapsedSec / totalSec : 0,
|
||||
endProgress: totalSec > 0 ? endElapsedSec / totalSec : 1,
|
||||
durationProgress: totalSec > 0 ? durationSec / totalSec : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Interpolate the existing cubic camera pose, including shortest-angle orientation. */
|
||||
export function cameraAtProgress(fromCamera, toCamera, progress) {
|
||||
const target = toCamera || fromCamera;
|
||||
const source = fromCamera || target;
|
||||
if (!source || !target) return target || source || null;
|
||||
const t = clamp01(Number(progress) || 0);
|
||||
const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
|
||||
const lerpAngle = (from, to) => {
|
||||
const start = Number(from) || 0;
|
||||
const delta = ((Number(to) - start + 540) % 360) - 180;
|
||||
return start + delta * eased;
|
||||
};
|
||||
return {
|
||||
lat: lerp(source.lat, target.lat),
|
||||
lon: lerp(source.lon, target.lon),
|
||||
alt: lerp(source.alt, target.alt),
|
||||
heading: lerpAngle(source.heading, target.heading),
|
||||
pitch: lerp(source.pitch, target.pitch),
|
||||
roll: lerpAngle(source.roll, target.roll),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a normalized scene time to a shot, flight/hold phase and camera pose. */
|
||||
export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
|
||||
if (!scene?.shots?.length) return null;
|
||||
const normalized = clamp01(Number(progress) || 0);
|
||||
const durations = scene.shots.map((shot) => durationForShot(scene, shot));
|
||||
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
|
||||
const targetSec = normalized * Math.max(0, totalSec);
|
||||
let startElapsedSec = 0;
|
||||
let shotIndex = scene.shots.length - 1;
|
||||
for (let index = 0; index < scene.shots.length; index += 1) {
|
||||
const endElapsedSec = startElapsedSec + durations[index];
|
||||
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
|
||||
shotIndex = index;
|
||||
break;
|
||||
}
|
||||
startElapsedSec = endElapsedSec;
|
||||
}
|
||||
const shot = scene.shots[shotIndex];
|
||||
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
|
||||
const holdDurationSec = holdForShot(scene, shot);
|
||||
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
|
||||
const shotElapsedSec = Math.max(
|
||||
0,
|
||||
Math.min(shotDurationSec, targetSec - startElapsedSec),
|
||||
);
|
||||
const cameraProgress = clamp01(
|
||||
shotElapsedSec / Math.max(0.001, flightDurationSec),
|
||||
);
|
||||
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
|
||||
const holdProgress =
|
||||
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
|
||||
const targetCamera = resolveCameraPose(scene, shot.camera);
|
||||
const previousCamera = resolveCameraPose(
|
||||
scene,
|
||||
scene.shots[shotIndex - 1]?.camera || shot.camera,
|
||||
);
|
||||
const move = resolveCameraMove(scene, shot);
|
||||
return {
|
||||
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
|
||||
sceneElapsedSec: targetSec,
|
||||
sceneDurationSec: totalSec,
|
||||
shotIndex,
|
||||
shot,
|
||||
shotElapsedSec,
|
||||
shotProgress: clamp01(shotElapsedSec / shotDurationSec),
|
||||
flightDurationSec,
|
||||
holdDurationSec,
|
||||
holdElapsedSec,
|
||||
cameraProgress,
|
||||
holdProgress,
|
||||
camera: move
|
||||
? sampleCameraMove(move, cameraProgress)
|
||||
: cameraAtProgress(previousCamera, targetCamera, cameraProgress),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
sceneTimingForShot,
|
||||
sceneSeekState,
|
||||
cameraAtProgress,
|
||||
} from './timeline.js';
|
||||
const camera = (lon, heading) => ({
|
||||
lat: 10,
|
||||
lon,
|
||||
alt: 1000,
|
||||
heading,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
});
|
||||
const scene = {
|
||||
shots: [
|
||||
{ id: 'a', durationSec: 2, holdSec: 1, camera: camera(20, 350) },
|
||||
{ id: 'b', durationSec: 4, holdSec: 3, camera: camera(24, 10) },
|
||||
],
|
||||
};
|
||||
const duration = (_, shot) => shot.durationSec + shot.holdSec;
|
||||
const hold = (_, shot) => shot.holdSec;
|
||||
|
||||
test('scene time accounts for flight and hold; exact boundaries select the next shot', () => {
|
||||
const time = sceneTimingForShot(scene, scene.shots[1], duration);
|
||||
assert.equal(time.startElapsedSec, 3);
|
||||
assert.equal(time.endElapsedSec, 10);
|
||||
const seek = (progress) => sceneSeekState(scene, progress, duration, hold);
|
||||
assert.equal(seek(-1).shot.id, 'a');
|
||||
assert.equal(seek(0.3).shot.id, 'b');
|
||||
assert.equal(seek(0.3).cameraProgress, 0);
|
||||
assert.equal(seek(0.5).cameraProgress, 0.5);
|
||||
assert.equal(seek(0.5).camera.lon, 22);
|
||||
assert.equal(seek(0.8).holdElapsedSec, 1);
|
||||
assert.equal(seek(2).holdProgress, 1);
|
||||
assert.equal(seek(0).sceneElapsedSec, 0);
|
||||
assert.equal(sceneSeekState({ shots: [] }, 1, duration, hold), null);
|
||||
});
|
||||
|
||||
test('camera seeking preserves cubic easing and shortest-angle orientation', () => {
|
||||
assert.equal(
|
||||
cameraAtProgress(camera(20, 350), camera(24, 10), 0.5).heading,
|
||||
360,
|
||||
);
|
||||
assert.equal(
|
||||
cameraAtProgress(camera(20, 350), camera(24, 10), 0.25).lon,
|
||||
20.25,
|
||||
);
|
||||
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { sampleCameraMove } from '../director/camera.js';
|
||||
|
||||
/** Own one authored camera animation and settle it immediately on cancellation or replacement. */
|
||||
export function createCameraMotion({
|
||||
applyPose,
|
||||
now = () => performance.now(),
|
||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame = (handle) => cancelAnimationFrame(handle),
|
||||
} = {}) {
|
||||
let active = null;
|
||||
let disposed = false;
|
||||
const cancel = () => active?.finish(false);
|
||||
return {
|
||||
get active() {
|
||||
return active !== null;
|
||||
},
|
||||
cancel,
|
||||
destroy() {
|
||||
disposed = true;
|
||||
cancel();
|
||||
},
|
||||
play(move, token) {
|
||||
cancel();
|
||||
if (disposed || token?.cancelled || token?.signal?.aborted)
|
||||
return Promise.resolve(false);
|
||||
if (!Number.isFinite(move.durationSec) || move.durationSec <= 0)
|
||||
throw new RangeError('Camera move duration must be positive');
|
||||
return new Promise((resolve, reject) => {
|
||||
const run = { handle: null, finish: null };
|
||||
const startedAt = now();
|
||||
const onAbort = () => run.finish(false);
|
||||
run.finish = (completed, error) => {
|
||||
if (active !== run) return;
|
||||
active = null;
|
||||
if (run.handle !== null) cancelFrame(run.handle);
|
||||
token?.signal?.removeEventListener('abort', onAbort);
|
||||
if (error) reject(error);
|
||||
else resolve(completed);
|
||||
};
|
||||
active = run;
|
||||
token?.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const tick = () => {
|
||||
if (active !== run) return;
|
||||
run.handle = null;
|
||||
if (disposed || token?.cancelled || token?.signal?.aborted) {
|
||||
run.finish(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const progress = Math.min(
|
||||
1,
|
||||
Math.max(0, (now() - startedAt) / (move.durationSec * 1000)),
|
||||
);
|
||||
if (applyPose(sampleCameraMove(move, progress)) === false) {
|
||||
run.finish(false);
|
||||
return;
|
||||
}
|
||||
// Applying a pose can synchronously transfer camera ownership.
|
||||
if (active !== run) return;
|
||||
if (progress >= 1) run.finish(true);
|
||||
else run.handle = requestFrame(tick);
|
||||
} catch (error) {
|
||||
run.finish(false, error);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCameraMotion } from './cameraMotion.js';
|
||||
const move = {
|
||||
durationSec: 2,
|
||||
easing: 'linear',
|
||||
from: { lat: 0, lon: 0, alt: 100, heading: 0, pitch: 0, roll: 0 },
|
||||
to: { lat: 2, lon: 2, alt: 200, heading: 0, pitch: 0, roll: 0 },
|
||||
};
|
||||
function harness(apply) {
|
||||
let time = 0;
|
||||
let next = 0;
|
||||
const frames = new Map();
|
||||
const poses = [];
|
||||
const owner = createCameraMotion({
|
||||
now: () => time,
|
||||
requestFrame: (cb) => {
|
||||
const id = ++next;
|
||||
frames.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
cancelFrame: (id) => frames.delete(id),
|
||||
applyPose: (pose) => {
|
||||
poses.push(pose);
|
||||
return apply?.(pose);
|
||||
},
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
frames,
|
||||
poses,
|
||||
tick(ms) {
|
||||
time = ms;
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
callbacks.forEach((cb) => cb());
|
||||
},
|
||||
};
|
||||
}
|
||||
test('motion uses the authored curve and releases the frame on completion', async () => {
|
||||
const h = harness();
|
||||
const done = h.owner.play(move, { cancelled: false });
|
||||
assert.deepEqual(h.poses[0], move.from);
|
||||
h.tick(1000);
|
||||
assert.equal(h.poses.at(-1).alt, 150);
|
||||
h.tick(2000);
|
||||
assert.equal(await done, true);
|
||||
assert.deepEqual(h.poses.at(-1), move.to);
|
||||
assert.equal(h.frames.size, 0);
|
||||
assert.equal(h.owner.active, false);
|
||||
});
|
||||
test('abort and replacement settle immediately and revoke already queued callbacks', async () => {
|
||||
const h = harness();
|
||||
const abort = new AbortController();
|
||||
const first = h.owner.play(move, { signal: abort.signal });
|
||||
const stale = [...h.frames.values()][0];
|
||||
abort.abort();
|
||||
assert.equal(await first, false);
|
||||
const second = h.owner.play(move, {});
|
||||
const count = h.poses.length;
|
||||
stale();
|
||||
assert.equal(h.poses.length, count);
|
||||
h.owner.destroy();
|
||||
assert.equal(await second, false);
|
||||
assert.equal(h.frames.size, 0);
|
||||
assert.equal(await h.owner.play(move, {}), false);
|
||||
});
|
||||
test('synchronous cancellation from pose application schedules no abandoned frame', async () => {
|
||||
let owner;
|
||||
const h = harness(() => owner.cancel());
|
||||
owner = h.owner;
|
||||
assert.equal(await owner.play(move, {}), false);
|
||||
assert.equal(h.frames.size, 0);
|
||||
});
|
||||
test('failed pose application settles and invalid durations cannot schedule a loop', async () => {
|
||||
const h = harness(() => {
|
||||
throw new Error('camera unavailable');
|
||||
});
|
||||
await assert.rejects(h.owner.play(move, {}), /camera unavailable/);
|
||||
assert.equal(h.frames.size, 0);
|
||||
assert.throws(
|
||||
() => h.owner.play({ ...move, durationSec: 0 }, {}),
|
||||
/duration/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createDataPackSession } from '../../director/packs/session.js';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
|
||||
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
|
||||
const session = createDataPackSession({
|
||||
sources,
|
||||
adapters: createPackPresentations(viewer),
|
||||
});
|
||||
return {
|
||||
...session,
|
||||
apply(scene, shot, token) {
|
||||
const wanted = new Set(shot.dataPackIds || []);
|
||||
return session.load(
|
||||
(scene.dataPacks || []).filter((pack) => wanted.has(pack.id)),
|
||||
{ anchors: scene.anchors, signal: token?.signal },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
/** Render a bounded geometry pack in owned batches; no asynchronous entity geometry or update loop. */
|
||||
export function renderPackGeometry({ asset, pack }, handle) {
|
||||
if (!['application/json', 'application/geo+json'].includes(asset.mimeType))
|
||||
throw new Error('Expected GeoJSON');
|
||||
const features = decodePackGeoJSON(asset.bytes);
|
||||
const polygons = [];
|
||||
let lines;
|
||||
for (const feature of features) {
|
||||
const id = { packId: pack.id, featureId: feature.id };
|
||||
if (feature.type === 'Point')
|
||||
handle.add({
|
||||
name: `${pack.id} / ${feature.id}`,
|
||||
position: xyz(feature.coordinates),
|
||||
point: {
|
||||
pixelSize: 12,
|
||||
color: Cesium.Color.CYAN,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
});
|
||||
else if (feature.type === 'LineString') {
|
||||
if (!lines) {
|
||||
lines = new Cesium.PolylineCollection();
|
||||
handle.primitive(lines);
|
||||
}
|
||||
lines.add({
|
||||
id,
|
||||
positions: feature.coordinates.map(xyz),
|
||||
width: 4,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.CYAN,
|
||||
}),
|
||||
});
|
||||
} else
|
||||
polygons.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id,
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
feature.coordinates[0].map(xyz),
|
||||
feature.coordinates
|
||||
.slice(1)
|
||||
.map((ring) => new Cesium.PolygonHierarchy(ring.map(xyz))),
|
||||
),
|
||||
perPositionHeight: true,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.CYAN.withAlpha(0.35),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (polygons.length)
|
||||
handle.primitive(
|
||||
new Cesium.Primitive({
|
||||
asynchronous: false,
|
||||
geometryInstances: polygons,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
translucent: true,
|
||||
flat: true,
|
||||
closed: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderPackGeometry } from './geometry.js';
|
||||
|
||||
function caption(viewer, pack) {
|
||||
const card = document.createElement('section');
|
||||
card.dataset.directorPack = pack.id;
|
||||
Object.assign(card.style, {
|
||||
color: '#e3faff',
|
||||
background: '#08141eed',
|
||||
border: '1px solid #2491a8',
|
||||
padding: '8px',
|
||||
margin: '4px',
|
||||
maxWidth: '260px',
|
||||
font: '12px sans-serif',
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
|
||||
card.append(title);
|
||||
if (pack.attribution.url) {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = 'Source';
|
||||
link.href = pack.attribution.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.style.color = '#6eeaff';
|
||||
card.append(link);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
/** Create rendering adapters; data acquisition and authored placement stay independent. */
|
||||
export function createPackPresentations(viewer) {
|
||||
let panel;
|
||||
function addCard(pack) {
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.dataset.directorPacks = '';
|
||||
Object.assign(panel.style, {
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
bottom: '70px',
|
||||
maxHeight: '40vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: '30',
|
||||
});
|
||||
viewer.container.append(panel);
|
||||
}
|
||||
const card = caption(viewer, pack);
|
||||
panel.append(card);
|
||||
return card;
|
||||
}
|
||||
function resource(pack) {
|
||||
const entities = [],
|
||||
primitives = [],
|
||||
materials = [],
|
||||
urls = [];
|
||||
let card,
|
||||
destroyed = false;
|
||||
return {
|
||||
add(spec) {
|
||||
const entity = viewer.entities.add(spec);
|
||||
entities.push(entity);
|
||||
viewer.scene.requestRender();
|
||||
return entity;
|
||||
},
|
||||
primitive(value) {
|
||||
primitives.push(viewer.scene.primitives.add(value));
|
||||
if (value.appearance?.material)
|
||||
materials.push(value.appearance.material);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
card() {
|
||||
return (card ||= addCard(pack));
|
||||
},
|
||||
url(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
urls.push(url);
|
||||
return url;
|
||||
},
|
||||
dispose() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const media of card?.querySelectorAll('video,audio') || []) {
|
||||
media.pause();
|
||||
media.removeAttribute('src');
|
||||
media.load();
|
||||
}
|
||||
card?.remove();
|
||||
if (panel && !panel.childElementCount) {
|
||||
panel.remove();
|
||||
panel = null;
|
||||
}
|
||||
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
|
||||
for (const primitive of primitives.splice(0))
|
||||
viewer.scene.primitives.remove(primitive);
|
||||
for (const material of materials.splice(0)) {
|
||||
if (!material.isDestroyed()) material.destroy();
|
||||
}
|
||||
for (const url of urls.splice(0)) URL.revokeObjectURL(url);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
};
|
||||
}
|
||||
function guarded(render) {
|
||||
return async (context) => {
|
||||
const handle = resource(context.pack);
|
||||
const abort = () => handle.dispose();
|
||||
context.signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
context.signal.throwIfAborted();
|
||||
await render(context, handle);
|
||||
context.signal.throwIfAborted();
|
||||
handle.card();
|
||||
return handle;
|
||||
} catch (error) {
|
||||
handle.dispose();
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal.removeEventListener('abort', abort);
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
geojson: guarded(renderPackGeometry),
|
||||
image: guarded(async ({ asset, pack, signal }, handle) => {
|
||||
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
|
||||
const bytes = asset.bytes;
|
||||
if (
|
||||
bytes.length < 24 ||
|
||||
![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].every(
|
||||
(v, i) => bytes[i] === v,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid PNG header');
|
||||
const header = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength,
|
||||
);
|
||||
const width = header.getUint32(16),
|
||||
height = header.getUint32(20);
|
||||
if (!width || !height || width > 4096 || height > 4096)
|
||||
throw new Error('Image dimensions exceed limit');
|
||||
const image = new Image();
|
||||
image.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
|
||||
const abort = () => {
|
||||
image.src = '';
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
await image.decode();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
|
||||
throw new Error('Image dimensions exceed limit');
|
||||
handle.primitive(
|
||||
new Cesium.Primitive({
|
||||
asynchronous: false,
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
id: { packId: pack.id },
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds),
|
||||
height: pack.placement.height,
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material: Cesium.Material.fromType('Image', { image }),
|
||||
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
|
||||
flat: true,
|
||||
faceForward: true,
|
||||
translucent: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
media: guarded(({ asset, pack, anchors }, handle) => {
|
||||
if (
|
||||
![
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
].includes(asset.mimeType)
|
||||
)
|
||||
throw new Error('Unsupported media');
|
||||
const anchor = anchors.find(
|
||||
(item) => item.id === pack.placement.anchorId,
|
||||
);
|
||||
if (!anchor) throw new Error('Missing media anchor');
|
||||
handle.add({
|
||||
name: pack.id,
|
||||
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
|
||||
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
|
||||
label: {
|
||||
text: pack.id,
|
||||
font: '14px sans-serif',
|
||||
pixelOffset: new Cesium.Cartesian2(0, -24),
|
||||
},
|
||||
});
|
||||
const media = document.createElement(
|
||||
asset.mimeType.startsWith('video/') ? 'video' : 'audio',
|
||||
);
|
||||
media.controls = true;
|
||||
media.preload = 'none';
|
||||
media.style.width = '240px';
|
||||
media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
|
||||
handle.card().append(media);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
test('abort releases an in-flight image URL before the decoder settles or the viewer is destroyed', async () => {
|
||||
let settle;
|
||||
const pending = new Promise((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const priorImage = globalThis.Image;
|
||||
const priorRevoke = URL.revokeObjectURL;
|
||||
const revoked = [];
|
||||
globalThis.Image = class {
|
||||
decode() {
|
||||
return pending;
|
||||
}
|
||||
};
|
||||
URL.revokeObjectURL = (url) => {
|
||||
revoked.push(url);
|
||||
priorRevoke(url);
|
||||
};
|
||||
const viewer = {
|
||||
scene: { requestRender() {} },
|
||||
entities: {
|
||||
remove() {
|
||||
assert.fail('No entity should be acquired');
|
||||
},
|
||||
},
|
||||
};
|
||||
const signalOwner = new AbortController();
|
||||
const bytes = new Uint8Array(24);
|
||||
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
|
||||
const header = new DataView(bytes.buffer);
|
||||
header.setUint32(16, 8);
|
||||
header.setUint32(20, 8);
|
||||
try {
|
||||
const rendering = createPackPresentations(viewer).image({
|
||||
pack: {},
|
||||
asset: { bytes, mimeType: 'image/png' },
|
||||
signal: signalOwner.signal,
|
||||
});
|
||||
const rejected = assert.rejects(rendering, /abort/i);
|
||||
signalOwner.abort();
|
||||
assert.equal(
|
||||
revoked.length,
|
||||
1,
|
||||
'object URL must be released synchronously with cancellation',
|
||||
);
|
||||
settle();
|
||||
await rejected;
|
||||
assert.equal(revoked.length, 1, 'late decode cannot dispose twice');
|
||||
} finally {
|
||||
settle();
|
||||
globalThis.Image = priorImage;
|
||||
URL.revokeObjectURL = priorRevoke;
|
||||
}
|
||||
});
|
||||
+241
-855
File diff suppressed because it is too large.
Load diff
@@ -614,10 +614,11 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
|
||||
removeEventListener() {},
|
||||
body: { classList: noopClassList, appendChild() {} },
|
||||
};
|
||||
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify(project),
|
||||
setItem() {},
|
||||
removeItem() {},
|
||||
getItem: (key) => stored.get(key) ?? null,
|
||||
setItem: (key, value) => stored.set(key, value),
|
||||
removeItem: (key) => stored.delete(key),
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -1397,3 +1398,122 @@ test('Scene load outcomes exclude superseded and disposed completions', async ()
|
||||
assert.equal(seen.length, count);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('invalid and unsupported imports retain the current project, selection and saved bytes', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const project = director._project;
|
||||
const selected = director._selectedShotId;
|
||||
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
for (const value of ['{}', '{"version":99,"scenes":[]}', '{"scenes":[{"shots":"bad"}]}']) {
|
||||
await director.importProjectFile({ name: 'bad.json', text: async () => value });
|
||||
assert.equal(director._project, project);
|
||||
assert.equal(director._selectedShotId, selected);
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||
assert.match(director._presentation.status, /Import failed: \$/);
|
||||
}
|
||||
let read = false;
|
||||
await director.importProjectFile({ size: 6 * 1024 * 1024, text: async () => { read = true; } });
|
||||
assert.equal(read, false);
|
||||
assert.equal(director._project, project);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('newer imports win delayed file reads, and an empty project is preserved', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
let release;
|
||||
const older = director.importProjectFile({ name: 'old.json', text: () => new Promise((resolve) => { release = resolve; }) });
|
||||
await director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
|
||||
release(JSON.stringify(PROJECT_FIXTURE));
|
||||
await older;
|
||||
assert.deepEqual(director._project.scenes, []);
|
||||
assert.equal(director._selectedSceneId, null);
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes, []);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('unsupported stored documents cannot be overwritten by fallback edits', async () => {
|
||||
const { director, restore } = makeDirector({ project: { version: 99, scenes: [] } });
|
||||
try {
|
||||
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
assert.ok(director._storageReadError);
|
||||
director._project.scenes[0].title = 'Fallback edit';
|
||||
director._saveProject();
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
||||
assert.equal(director._storageReadError, null);
|
||||
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 5);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('valid import settles a cancelled load before replacing the project', async () => {
|
||||
const { director, styleManager, restore } = makeDirector();
|
||||
let release;
|
||||
styleManager.applyVisualState = () => new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
const before = director._project;
|
||||
const load = director.loadShot('scene-1', 'shot-a');
|
||||
await settle();
|
||||
const importing = director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
|
||||
await settle();
|
||||
assert.equal(director._project, before, 'old work still owns cleanup');
|
||||
release(true);
|
||||
await Promise.all([load, importing]);
|
||||
assert.deepEqual(director._project.scenes, []);
|
||||
assert.equal(director._pendingWork.size, 0);
|
||||
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a delayed import cannot publish after disposal', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
let release;
|
||||
try {
|
||||
const before = director._project;
|
||||
const reading = director.importProjectFile({ name: 'empty.json', text: () => new Promise((resolve) => { release = resolve; }) });
|
||||
await director.destroy();
|
||||
release('{"version":3,"scenes":[]}');
|
||||
await reading;
|
||||
assert.equal(director._project, before);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('invalid authored edits cannot persist an unreadable project over a good save', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const before = localStorage.getItem('godsEyeView.sceneProject.v2');
|
||||
let notice;
|
||||
director._toastStorageError = (message) => { notice = message; };
|
||||
director._project.scenes[0].shots[0].camera.lat = 91;
|
||||
director._saveProject();
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before);
|
||||
assert.match(notice, /camera.lat/);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('zero camera pitch is preserved by both immediate placement and ordinary flight', async () => {
|
||||
const { director, viewer, restore } = makeDirector();
|
||||
try {
|
||||
let placed;
|
||||
viewer.camera.setView = (options) => { placed = options; };
|
||||
const pose = { lat: 10, lon: 20, alt: 500, heading: 0, pitch: 0, roll: 0 };
|
||||
director._setCameraView(pose);
|
||||
assert.equal(placed.orientation.pitch, 0);
|
||||
await director._flyCamera(pose, 0.2, { cancelled: false });
|
||||
assert.equal(viewer.flights.at(-1).orientation.pitch, 0);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('camera refusal starts no authored frame or playback clock', async () => {
|
||||
const { director, styleManager, viewer, restore } = makeDirector();
|
||||
try {
|
||||
const shot = director._project.scenes[0].shots[0];
|
||||
shot.move = { from: { ...shot.camera, altitudeReference: 'ellipsoid' }, easing: 'linear' };
|
||||
styleManager.runImmediateNavigation = () => false;
|
||||
assert.equal((await director.startScene('scene-1', { single: true })).reason, 'camera-unavailable');
|
||||
assert.equal(director._cameraMotion.active, false);
|
||||
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
|
||||
assert.deepEqual(viewer.flights, []);
|
||||
} finally { restore(); }
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SCENE_APPEND_RECIPES } from '../recipes.js';
|
||||
import { createScenePackRegistry } from './registry.js';
|
||||
import { nepalScenePresentation } from './nepal.js';
|
||||
|
||||
/** Compose the installed scene recipes and their trusted presentation rules. */
|
||||
export function createDefaultScenePacks() {
|
||||
return createScenePackRegistry({
|
||||
recipes: SCENE_APPEND_RECIPES,
|
||||
adapters: [nepalScenePresentation],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { INCIDENT_OVERVIEW_HOLD_SEC } from '../../data/bhoteKoshiIncidentPlaces.js';
|
||||
|
||||
/** Existing Nepal presentation rules; authored assets and saved shots stay untouched. */
|
||||
export const nepalScenePresentation = {
|
||||
minimumHoldSec(states) {
|
||||
const overview = states['bhote-koshi-locator'];
|
||||
return overview?.enabled &&
|
||||
overview.params?.presentation === 'bhote-koshi-incident-places'
|
||||
? INCIDENT_OVERVIEW_HOLD_SEC
|
||||
: 0;
|
||||
},
|
||||
resolveVisual(shot, visual, isMapStackAvailable) {
|
||||
const isNepalShot =
|
||||
shot?.layers?.['bhote-koshi-2026']?.enabled ||
|
||||
shot?.layers?.['bhote-koshi-locator']?.enabled;
|
||||
// A credential is not proof of a loaded tileset. Resolve actual capability at playback time.
|
||||
return isNepalShot &&
|
||||
visual.mapStack === 'photoreal' &&
|
||||
!isMapStackAvailable('photoreal')
|
||||
? { ...visual, mapStack: 'esri-imagery' }
|
||||
: visual;
|
||||
},
|
||||
cancelMotion(getLayerModule) {
|
||||
getLayerModule('bhote-koshi-locator')?.cancelSceneMotion?.();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Register trusted scene-pack recipes and presentation adapters at composition time. */
|
||||
export function createScenePackRegistry({ recipes = [], adapters = [] } = {}) {
|
||||
const byId = new Map(recipes.map((recipe) => [recipe.id, recipe]));
|
||||
const rules = [...adapters];
|
||||
return {
|
||||
recipeForShot: (shot) => byId.get(shot?.sourcePackId) || null,
|
||||
minimumHoldSec(states) {
|
||||
return Math.max(
|
||||
0,
|
||||
...rules.map((rule) => rule.minimumHoldSec?.(states) || 0),
|
||||
);
|
||||
},
|
||||
resolveVisual(shot, visual, isMapStackAvailable) {
|
||||
return rules.reduce(
|
||||
(result, rule) =>
|
||||
rule.resolveVisual?.(shot, result, isMapStackAvailable) || result,
|
||||
visual,
|
||||
);
|
||||
},
|
||||
cancelMotion(getLayerModule) {
|
||||
for (const rule of rules) rule.cancelMotion?.(getLayerModule);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createScenePackRegistry } from './registry.js';
|
||||
import {
|
||||
effectiveShotHoldSec,
|
||||
shotRuntimeDurationSec,
|
||||
layerStatesForShot,
|
||||
visualStateForShot,
|
||||
} from '../shotPresentation.js';
|
||||
|
||||
test('registered pack rules apply to another source without editing the Director or saved shots', () => {
|
||||
const recipe = {
|
||||
id: 'sample',
|
||||
runtimeHoldSecByBeat: { arrival: 6 },
|
||||
runtimeControlsByBeat: {
|
||||
arrival: { minimumHoldSec: 8, deferEvidenceUntilCameraSettled: true },
|
||||
},
|
||||
};
|
||||
let cancelled = 0;
|
||||
const packs = createScenePackRegistry({
|
||||
recipes: [recipe],
|
||||
adapters: [
|
||||
{
|
||||
resolveVisual: (_, visual) => ({ ...visual, mapStack: 'osm' }),
|
||||
cancelMotion: (getModule) => getModule('sample').cancelSceneMotion(),
|
||||
},
|
||||
],
|
||||
});
|
||||
const shot = {
|
||||
id: 'a',
|
||||
sourcePackId: 'sample',
|
||||
durationSec: 2,
|
||||
holdSec: 1,
|
||||
visual: { style: 'normal' },
|
||||
layers: {
|
||||
sample: {
|
||||
enabled: true,
|
||||
params: { presentation: 'scene-beat', beatId: 'arrival' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const scene = { id: 'scene', shots: [shot], releaseLayerIds: ['other'] };
|
||||
const before = structuredClone(shot);
|
||||
assert.equal(shotRuntimeDurationSec(scene, shot, packs), 10);
|
||||
assert.equal(effectiveShotHoldSec(scene, shot, packs), 8);
|
||||
const states = layerStatesForShot(scene, shot, packs, {
|
||||
cameraSettled: true,
|
||||
});
|
||||
assert.equal(states.sample.params.sceneControls.cameraSettled, true);
|
||||
assert.equal(states.sample.params.sceneContext.holdSec, 8);
|
||||
assert.equal(states.other.enabled, false);
|
||||
assert.equal(visualStateForShot(shot, packs, () => true).mapStack, 'osm');
|
||||
packs.cancelMotion(() => ({ cancelSceneMotion: () => cancelled++ }));
|
||||
assert.equal(cancelled, 1);
|
||||
assert.deepEqual(shot, before);
|
||||
});
|
||||
@@ -29,18 +29,25 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
{ isCurrent: () => !token.cancelled && !token.signal?.aborted },
|
||||
);
|
||||
},
|
||||
applyLayers({ scene, shot, token }) {
|
||||
return director._applyLayerStates(
|
||||
async applyLayers({ scene, shot, token }) {
|
||||
const result = await director._applyLayerStates(
|
||||
director._layerStatesForShot(scene, shot),
|
||||
token,
|
||||
);
|
||||
if (
|
||||
!token.cancelled &&
|
||||
!result.refused.length &&
|
||||
!(await director._applyDataPacks(scene, shot, token))
|
||||
)
|
||||
throw new Error('Data packs unavailable');
|
||||
return result;
|
||||
},
|
||||
travel({ scene, shot, token }) {
|
||||
director._loadedSceneId = scene.id;
|
||||
const duration = shot.durationSec || defaultShotDurationSec;
|
||||
timing = director._startSceneClockTicker(scene, shot, token);
|
||||
travel = director._beginShotTravel(scene, shot, duration);
|
||||
const flight = director._flyCamera(shot.camera, duration, token);
|
||||
const flight = director._flyShotCamera(scene, shot, duration, token);
|
||||
director._publishShotTravel(scene, shot, travel);
|
||||
return flight;
|
||||
},
|
||||
@@ -51,8 +58,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
return director._holdShot(scene, shot, token);
|
||||
},
|
||||
completeShot({ scene, shot, index }) {
|
||||
clearInterval(director._sceneClockTimer);
|
||||
director._sceneClockTimer = null;
|
||||
director._clock.stopSceneTimer();
|
||||
director._publishSceneClock(scene, shot, timing.endElapsedSec, {
|
||||
running: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/** Authored project defaults and legacy migrations; no editor or playback state. */
|
||||
import { SCENE_DOCUMENT_VERSION } from '../director/document.js';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
import { MAP_STACKS } from '../maps/catalog.js';
|
||||
import {
|
||||
BLOOM_INTENSITY_DEFAULT,
|
||||
BLOOM_SCALE_VERSION,
|
||||
decodeBloomIntensity,
|
||||
} from '../bloom.js';
|
||||
|
||||
/** @constant {number} Current schema version for project migration */
|
||||
export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION;
|
||||
/** @constant {number} Fallback camera flight duration per shot (seconds) */
|
||||
export const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||
/** @constant {number} Default hold/pause after a shot completes (seconds) */
|
||||
export const DEFAULT_HOLD_SEC = 0.9;
|
||||
const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id));
|
||||
|
||||
/**
|
||||
* Generate a short random identifier with the given prefix.
|
||||
* @param {string} prefix - e.g. 'shot', 'scene'
|
||||
* @returns {string} Identifier like "shot-a1b2c3d4"
|
||||
*/
|
||||
export function uid(prefix) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a JSON-serializable value via round-trip stringify/parse.
|
||||
* @param {*} value
|
||||
* @returns {*}
|
||||
*/
|
||||
export function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw layer state entry into a canonical { enabled, params? } shape.
|
||||
* Accepts both boolean shorthand and full object forms.
|
||||
* @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params })
|
||||
* @returns {{ enabled: boolean, params?: Object }}
|
||||
*/
|
||||
export function normalizeLayerEntry(entry) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
return {
|
||||
enabled: !!entry.enabled,
|
||||
params:
|
||||
entry.params && typeof entry.params === 'object'
|
||||
? deepClone(entry.params)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { enabled: !!entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw bloom post-processing state, migrating intensity values
|
||||
* across bloom scale versions so older saved projects render correctly.
|
||||
* @param {Object} rawBloom - Raw bloom state from storage or recipe
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.projectVersion] - Schema version of the source project
|
||||
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
|
||||
* @returns {{ enabled: boolean, intensity: number, version: number }}
|
||||
*/
|
||||
function normalizeBloomState(
|
||||
rawBloom = {},
|
||||
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||
) {
|
||||
// Determine which bloom scale the stored value was encoded under.
|
||||
// Older projects (version < 3) used scale version 1.
|
||||
const explicitVersion = Number(rawBloom.version);
|
||||
const bloomVersion = Number.isFinite(explicitVersion)
|
||||
? explicitVersion
|
||||
: projectVersion >= 3
|
||||
? BLOOM_SCALE_VERSION
|
||||
: 1;
|
||||
|
||||
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
|
||||
? Number(rawBloom.intensity)
|
||||
: fallbackIntensity;
|
||||
|
||||
return {
|
||||
enabled: !!rawBloom.enabled,
|
||||
intensity: decodeBloomIntensity(rawIntensity, bloomVersion),
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a static scene recipe (from recipes.js) into a mutable scene object
|
||||
* with fully normalized shots. Each keyframe in the recipe's cameraPath becomes
|
||||
* one shot, inheriting the recipe's style, post, and layer configuration.
|
||||
* @param {Object} recipe - A SCENE_RECIPES entry
|
||||
* @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }}
|
||||
*/
|
||||
export function recipeToScene(recipe) {
|
||||
const post = recipe.post || {};
|
||||
const ui = recipe.ui || {};
|
||||
const styleParams =
|
||||
post.styleParams && typeof post.styleParams === 'object'
|
||||
? deepClone(post.styleParams)
|
||||
: {};
|
||||
|
||||
// Normalize layer targets from the recipe into canonical form
|
||||
const layers = {};
|
||||
for (const [layerId, target] of Object.entries(recipe.layers || {})) {
|
||||
layers[layerId] = normalizeLayerEntry(target);
|
||||
}
|
||||
|
||||
// Derive HUD visibility/variant from recipe's ui.hudMode string
|
||||
const hudMode = ui.hudMode || 'minimal';
|
||||
const hudVisible = hudMode !== 'off';
|
||||
const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical';
|
||||
|
||||
// Convert each cameraPath keyframe into a shot with shared visual state
|
||||
const path = recipe.cameraPath || [];
|
||||
const shots = path.map((keyframe, idx) => {
|
||||
const keyframeLayers = deepClone(layers);
|
||||
for (const [layerId, target] of Object.entries(keyframe.layers || {})) {
|
||||
keyframeLayers[layerId] = normalizeLayerEntry(target);
|
||||
}
|
||||
const mapStack = keyframe.mapStack || post.mapStack;
|
||||
return {
|
||||
id: uid('shot'),
|
||||
title: keyframe.title || `Shot ${idx + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
keyframe.duration || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, keyframe.hold || 0),
|
||||
camera: {
|
||||
lat: keyframe.lat,
|
||||
lon: keyframe.lon,
|
||||
alt: keyframe.alt,
|
||||
heading: keyframe.heading || 0,
|
||||
pitch: keyframe.pitch || -40,
|
||||
roll: keyframe.roll || 0,
|
||||
},
|
||||
visual: {
|
||||
style: recipe.style || 'normal',
|
||||
bloom: {
|
||||
enabled:
|
||||
typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
|
||||
intensity:
|
||||
typeof post.bloom === 'number'
|
||||
? decodeBloomIntensity(post.bloom, 1)
|
||||
: BLOOM_INTENSITY_DEFAULT,
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
},
|
||||
sharpen: {
|
||||
enabled:
|
||||
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
||||
intensity: 65,
|
||||
},
|
||||
hud: {
|
||||
visible: hudVisible,
|
||||
variant: hudVariant,
|
||||
},
|
||||
detection: {
|
||||
mode: post.detectionMode || 'OFF',
|
||||
density: 35,
|
||||
},
|
||||
...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}),
|
||||
styleParams,
|
||||
},
|
||||
layers: keyframeLayers,
|
||||
...(recipe.id ? { sourcePackId: recipe.id } : {}),
|
||||
...(recipe.version ? { sourcePackVersion: recipe.version } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: recipe.id || uid('scene'),
|
||||
title: recipe.title || 'Untitled Scene',
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(recipe.releaseLayerIds)
|
||||
? recipe.releaseLayerIds
|
||||
: []
|
||||
).filter((layerId) => typeof layerId === 'string' && layerId.trim()),
|
||||
),
|
||||
],
|
||||
appliedShotPacks: [],
|
||||
shots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh default project by converting all built-in SCENE_RECIPES.
|
||||
* @returns {Object} A new project object with version, timestamps, and scenes
|
||||
*/
|
||||
export function createDefaultProject() {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
installedBuiltInSceneIds: SCENE_RECIPES.filter(
|
||||
(recipe) => typeof recipe.installAlongsideSceneId === 'string',
|
||||
).map((recipe) => recipe.id),
|
||||
scenes: SCENE_RECIPES.map(recipeToScene),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw shot object from storage or capture into a fully validated
|
||||
* shape with safe defaults for every field. Handles version migration for
|
||||
* bloom intensity and coerces all numeric fields.
|
||||
* @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema)
|
||||
* @param {number} [index=0] - Positional index used for fallback title
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.projectVersion] - Schema version of the enclosing project
|
||||
* @returns {Object} Fully normalized shot
|
||||
*/
|
||||
export function normalizeShot(
|
||||
rawShot,
|
||||
index = 0,
|
||||
{ projectVersion = PROJECT_VERSION } = {},
|
||||
) {
|
||||
const camera = rawShot?.camera || {};
|
||||
const visual = rawShot?.visual || {};
|
||||
const bloom = visual.bloom || {};
|
||||
const sharpen = visual.sharpen || {};
|
||||
const hud = visual.hud || {};
|
||||
const detection = visual.detection || {};
|
||||
|
||||
return {
|
||||
id: rawShot?.id || uid('shot'),
|
||||
title: rawShot?.title || `Shot ${index + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||
camera: camera.anchorId
|
||||
? {
|
||||
anchorId: camera.anchorId,
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number.isFinite(Number(camera.pitch))
|
||||
? Number(camera.pitch)
|
||||
: -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
}
|
||||
: {
|
||||
lat: Number(camera.lat) || 0,
|
||||
lon: Number(camera.lon) || 0,
|
||||
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number.isFinite(Number(camera.pitch))
|
||||
? Number(camera.pitch)
|
||||
: -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
...(camera.altitudeReference || rawShot?.move
|
||||
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
|
||||
: {}),
|
||||
},
|
||||
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
|
||||
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
projectVersion,
|
||||
fallbackIntensity: 50,
|
||||
}),
|
||||
sharpen: {
|
||||
enabled: !!sharpen.enabled,
|
||||
intensity: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(sharpen.intensity))
|
||||
? Number(sharpen.intensity)
|
||||
: 65,
|
||||
),
|
||||
),
|
||||
},
|
||||
hud: {
|
||||
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
|
||||
variant: typeof hud.variant === 'string' ? hud.variant : 'tactical',
|
||||
},
|
||||
detection: {
|
||||
...(typeof detection.allocation === 'string'
|
||||
? { allocation: detection.allocation }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(detection.fadePct))
|
||||
? { fadePct: Number(detection.fadePct) }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(detection.outsideOpacityPct))
|
||||
? { outsideOpacityPct: Number(detection.outsideOpacityPct) }
|
||||
: {}),
|
||||
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
|
||||
density: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(detection.density))
|
||||
? Number(detection.density)
|
||||
: 35,
|
||||
),
|
||||
),
|
||||
},
|
||||
...(visual.scope && typeof visual.scope === 'object'
|
||||
? { scope: deepClone(visual.scope) }
|
||||
: {}),
|
||||
...(SCENE_MAP_STACK_IDS.has(visual.mapStack)
|
||||
? { mapStack: visual.mapStack }
|
||||
: {}),
|
||||
styleParams:
|
||||
visual.styleParams && typeof visual.styleParams === 'object'
|
||||
? deepClone(visual.styleParams)
|
||||
: {},
|
||||
},
|
||||
layers: Object.fromEntries(
|
||||
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
|
||||
layerId,
|
||||
normalizeLayerEntry(value),
|
||||
]),
|
||||
),
|
||||
...(typeof rawShot?.sourcePackId === 'string'
|
||||
? { sourcePackId: rawShot.sourcePackId }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(rawShot?.sourcePackVersion))
|
||||
? { sourcePackVersion: Number(rawShot.sourcePackVersion) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and migrate an entire project object loaded from storage or import.
|
||||
* Defaults and migrates validated documents; empty projects remain empty.
|
||||
* @param {Object|null} rawProject - Raw project data (potentially from an older schema)
|
||||
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
|
||||
*/
|
||||
export function normalizeProject(rawProject) {
|
||||
if (!rawProject || typeof rawProject !== 'object')
|
||||
return createDefaultProject();
|
||||
const projectVersion = Number.isFinite(Number(rawProject.version))
|
||||
? Number(rawProject.version)
|
||||
: 1;
|
||||
|
||||
const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : [];
|
||||
const scenes = scenesRaw
|
||||
.map((scene, sceneIdx) => {
|
||||
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
|
||||
const shots = shotsRaw.map((shot, shotIdx) =>
|
||||
normalizeShot(shot, shotIdx, { projectVersion }),
|
||||
);
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
|
||||
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(scene?.releaseLayerIds)
|
||||
? scene.releaseLayerIds
|
||||
: []
|
||||
).filter(
|
||||
(layerId) => typeof layerId === 'string' && layerId.trim(),
|
||||
),
|
||||
),
|
||||
],
|
||||
appliedShotPacks: (Array.isArray(scene?.appliedShotPacks)
|
||||
? scene.appliedShotPacks
|
||||
: []
|
||||
)
|
||||
.filter((entry) => typeof entry?.id === 'string')
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: Number(entry.version) || 1,
|
||||
...(entry.shotBindings && typeof entry.shotBindings === 'object'
|
||||
? {
|
||||
shotBindings: Object.fromEntries(
|
||||
Object.entries(entry.shotBindings).filter(
|
||||
([title, shotId]) =>
|
||||
typeof title === 'string' && typeof shotId === 'string',
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
shots,
|
||||
};
|
||||
})
|
||||
// Keep scenes that have shots or at least a title
|
||||
.filter((scene) => scene.shots.length > 0 || scene.title);
|
||||
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
createdAt: rawProject.createdAt || new Date().toISOString(),
|
||||
updatedAt: rawProject.updatedAt || new Date().toISOString(),
|
||||
installedBuiltInSceneIds: [
|
||||
...new Set(
|
||||
(Array.isArray(rawProject.installedBuiltInSceneIds)
|
||||
? rawProject.installedBuiltInSceneIds
|
||||
: []
|
||||
).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()),
|
||||
),
|
||||
],
|
||||
scenes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||
const deepClone = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
/** Prepare scene-owned layer state, timing context and seek/travel controls. */
|
||||
export function layerStatesForShot(
|
||||
scene,
|
||||
shot,
|
||||
packs,
|
||||
{ cameraSettled = false, cameraTravel = null, sceneSeek = null } = {},
|
||||
) {
|
||||
const states = deepClone(shot?.layers || {});
|
||||
const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
|
||||
const sceneShotDurations =
|
||||
scene?.shots?.map((sceneShot) =>
|
||||
shotRuntimeDurationSec(scene, sceneShot, packs),
|
||||
) || [];
|
||||
const sceneDurationSec = sceneShotDurations.reduce(
|
||||
(sum, duration) => sum + duration,
|
||||
0,
|
||||
);
|
||||
const authoredSceneElapsedSec = sceneShotDurations
|
||||
.slice(0, Math.max(0, shotIndex))
|
||||
.reduce((sum, duration) => sum + duration, 0);
|
||||
const sceneElapsedSec = sceneSeek
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0),
|
||||
)
|
||||
: authoredSceneElapsedSec;
|
||||
const sourceRecipe = packs.recipeForShot(shot);
|
||||
for (const state of Object.values(states)) {
|
||||
if (state?.params?.presentation !== 'scene-beat') continue;
|
||||
state.params.sceneSurface ||= shot.sourcePackId
|
||||
? 'evidence-beat'
|
||||
: 'panel-only';
|
||||
const runtimeControls =
|
||||
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
|
||||
if (runtimeControls) {
|
||||
state.params.sceneControls = {
|
||||
...state.params.sceneControls,
|
||||
...runtimeControls,
|
||||
...(runtimeControls.deferEvidenceUntilCameraSettled
|
||||
? { cameraSettled }
|
||||
: {}),
|
||||
...(sceneSeek
|
||||
? {
|
||||
timelineSeek: {
|
||||
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
|
||||
flightDurationSec: Number(sceneSeek.flightDurationSec) || 0,
|
||||
holdDurationSec: Number(sceneSeek.holdDurationSec) || 0,
|
||||
holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0,
|
||||
cameraProgress: clamp01(Number(sceneSeek.cameraProgress) || 0),
|
||||
holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (runtimeControls.evidencePathDuringCamera && cameraTravel) {
|
||||
state.params.sceneControls.cameraTravel = { ...cameraTravel };
|
||||
}
|
||||
}
|
||||
state.params.sceneContext = {
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
shotId: shot.id,
|
||||
shotTitle: shot.title,
|
||||
shotIndex,
|
||||
shotCount: scene.shots.length,
|
||||
durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
|
||||
holdSec: effectiveShotHoldSec(scene, shot, packs, states),
|
||||
sceneElapsedSec,
|
||||
sceneDurationSec,
|
||||
sceneProgress:
|
||||
sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0,
|
||||
...(sceneSeek
|
||||
? {
|
||||
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
|
||||
shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0),
|
||||
seeking: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
for (const layerId of scene?.releaseLayerIds || []) {
|
||||
if (!Object.hasOwn(states, layerId)) states[layerId] = { enabled: false };
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/** Resolve recipe map preferences and registered presentation fallbacks. */
|
||||
export function visualStateForShot(shot, packs, isMapStackAvailable) {
|
||||
const sourceRecipe = packs.recipeForShot(shot);
|
||||
const sceneBeatState = Object.values(shot?.layers || {}).find(
|
||||
(state) => state?.params?.presentation === 'scene-beat',
|
||||
);
|
||||
const controls = {
|
||||
...sceneBeatState?.params?.sceneControls,
|
||||
...sourceRecipe?.runtimeControlsByBeat?.[sceneBeatState?.params?.beatId],
|
||||
};
|
||||
if (controls.imageryComparison === true) {
|
||||
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
|
||||
}
|
||||
if (
|
||||
sourceRecipe?.standaloneSurfaceBeatIds?.includes(
|
||||
sceneBeatState?.params?.beatId,
|
||||
)
|
||||
) {
|
||||
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
|
||||
}
|
||||
const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes(
|
||||
sceneBeatState?.params?.beatId,
|
||||
)
|
||||
? { ...(shot.visual || {}), mapStack: 'photoreal' }
|
||||
: shot?.visual || {};
|
||||
return packs.resolveVisual(shot, visual, isMapStackAvailable);
|
||||
}
|
||||
|
||||
/** Combine authored holds, registered pack overrides and layer reveal requirements. */
|
||||
export function effectiveShotHoldSec(
|
||||
scene,
|
||||
shot,
|
||||
packs,
|
||||
resolvedStates = null,
|
||||
) {
|
||||
const states = resolvedStates || layerStatesForShot(scene, shot, packs);
|
||||
const recipe = packs.recipeForShot(shot);
|
||||
const overrides = Object.values(states)
|
||||
.filter(
|
||||
(state) => state?.enabled && state.params?.presentation === 'scene-beat',
|
||||
)
|
||||
.map((state) => recipe?.runtimeHoldSecByBeat?.[state.params.beatId])
|
||||
.filter((value) => Number.isFinite(value) && value >= 0);
|
||||
const authoredHoldSec = overrides.length
|
||||
? Math.max(...overrides)
|
||||
: Number(shot?.holdSec) || 0;
|
||||
return Object.values(states).reduce(
|
||||
(holdSec, state) => {
|
||||
const controls = state?.params?.sceneControls;
|
||||
return Math.max(
|
||||
holdSec,
|
||||
Number(controls?.minimumHoldSec) || 0,
|
||||
controls?.evidenceSequence === true
|
||||
? Number(controls.evidenceSequenceDurationSec) || 0
|
||||
: 0,
|
||||
);
|
||||
},
|
||||
Math.max(packs.minimumHoldSec(states), authoredHoldSec),
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimate flight plus effective hold without mutating saved layer parameters. */
|
||||
export function shotRuntimeDurationSec(scene, shot, packs) {
|
||||
const states = deepClone(shot?.layers || {});
|
||||
const sourceRecipe = packs.recipeForShot(shot);
|
||||
for (const state of Object.values(states)) {
|
||||
if (state?.params?.presentation !== 'scene-beat') continue;
|
||||
const runtimeControls =
|
||||
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
|
||||
if (runtimeControls) {
|
||||
state.params.sceneControls = {
|
||||
...state.params.sceneControls,
|
||||
...runtimeControls,
|
||||
};
|
||||
}
|
||||
}
|
||||
return (
|
||||
(shot?.durationSec || DEFAULT_SHOT_DURATION_SEC) +
|
||||
effectiveShotHoldSec(scene, shot, packs, states)
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { createAssetDirectorySource } from '../director/packs/source.js';
|
||||
import { createApplicationTools } from '../app/tools.js';
|
||||
import { startStandaloneChrome } from './startupChrome.js';
|
||||
export function createStandaloneTools(options) {
|
||||
return createApplicationTools({
|
||||
startChrome: startStandaloneChrome,
|
||||
sceneDataPacks: {
|
||||
sources: {
|
||||
assets: createAssetDirectorySource({
|
||||
baseUrl: new URL('/scene-assets/', window.location.href).href,
|
||||
}),
|
||||
},
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
beginDeferredNavigation,
|
||||
@@ -34,6 +35,9 @@ export class NavigationController {
|
||||
cancelOrientation,
|
||||
});
|
||||
this._navigationGeneration = 0;
|
||||
this._cameraHandoffs = createStateChannel(() => ({
|
||||
generation: this._navigationGeneration,
|
||||
}));
|
||||
this._activeLocationSearchGeneration = null;
|
||||
this._disposed = false;
|
||||
}
|
||||
@@ -45,6 +49,7 @@ export class NavigationController {
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
this.tracking;
|
||||
this._navigationGeneration += 1;
|
||||
this._cameraHandoffs?.publish();
|
||||
// A newer destination owns the camera, so the last free-text search is no
|
||||
// longer where we are. DEFERRED navigation opts out here and clears at the
|
||||
// reassert seam instead: a geocode that never resolves moves no camera, and
|
||||
@@ -247,8 +252,14 @@ export class NavigationController {
|
||||
},
|
||||
});
|
||||
}
|
||||
/** Subscribe to ownership changes without claiming the camera or exposing mutable state. */
|
||||
subscribeCameraHandoff(listener) {
|
||||
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
|
||||
}
|
||||
stop() {
|
||||
this._disposed = true;
|
||||
this._cameraHandoffs?.publish();
|
||||
this._cameraHandoffs?.destroy();
|
||||
}
|
||||
destroy() {
|
||||
this.stop();
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
* New logic belongs with an owner, not in this forwarding surface.
|
||||
*/
|
||||
export class ShellFacade {
|
||||
/** Observe camera ownership transfers through the navigation owner. */
|
||||
subscribeCameraHandoff(listener) {
|
||||
return this._navigation.subscribeCameraHandoff(listener);
|
||||
}
|
||||
|
||||
get _models3dModeBtns() {
|
||||
return this._aircraftDisplay._models3dModeBtns;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user