diff --git a/.env.example b/.env.example index 5bc02df9c..1949c48f4 100644 --- a/.env.example +++ b/.env.example @@ -129,18 +129,20 @@ AISSTREAM_API_KEY= # 0 disables the silence watch entirely. # AISSTREAM_SILENCE_TIMEOUT_MS=120000 -# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile -# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: +# Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000 +# Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card +# required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: # the browser fetches same-origin /api/tomtom/* and never sees the key. # Keyless fallback: the traffic layer runs its built-in simulation (white dots, # hardcoded per-road-class speeds) — no key required for the layer to work. # TOMTOM_API_KEY= -# Optional soft cap on upstream tile fetches per UTC day (default 40000) — a +# Optional soft cap on upstream tile fetches per UTC day (default 6000) — a # configurable application safety ceiling, not a guarantee of staying within # TomTom's free allowance (currently 200K tile requests/month: -# https://docs.tomtom.com/pricing/). Over the cap the proxy serves cached/stale -# tiles instead of hitting upstream. -# TOMTOM_DAILY_TILE_BUDGET=40000 +# https://docs.tomtom.com/pricing/). 6000/day keeps a 31-day month at 186K, +# inside that allowance. Over the cap the proxy serves cached/stale tiles +# instead of hitting upstream. +# TOMTOM_DAILY_TILE_BUDGET=6000 # Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset # unless you're customizing the camera source pack. diff --git a/.gitignore b/.gitignore index 491a82c15..1a64fff39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ node_modules dist +# Credential files. `.env` alone is not enough: Vite's loadEnv (see +# server/standalone/vite.config.js) and scripts/setup-doctor.mjs both read the +# full dotenv ladder — .env, .env.local, .env. and .env..local — +# so a key placed in any of them is live for the app. Only .env was ignored, +# leaving the other three committable. .env.example is the tracked template. .env +.env.* +!.env.example *.log screenshots/ qa-shots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1ada795..da600f5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +- Report AIS speed and course that carry the standard "not available" code as + unknown instead of 102.3 knots and 360 degrees. Genuine readings, including a + stopped vessel's zero and the highest encodable values, are unchanged. + +- Render native `` controls declare a dark colour scheme and explicit option +colours, so the browser-painted option list matches the panel it drops out of. +The scheme is declared on the controls rather than on the document root, so +hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD +layout, Scenes, CCTV camera, Radio filter and Draw colour menus. + +Vessel snapshot completeness is separate from freshness. A current snapshot with +rejected or duplicate records shows PARTIAL with accepted/received counts; stale +or unknown freshness and transport failures retain their warnings. Partial +snapshots still retain missing contacts within the existing age and row limits. +A complete successful snapshot clears the partial indicator. + +Director imports now open a non-mutating preview before Apply. EDIT DETAILS +authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports +a selected scene or a bounded bundle of explicitly chosen pack files. Bundled +bytes remain in memory until replacement/teardown and require reimport after a +page reload. Stop releases rendering while retaining replay bytes. Cancellation +and stale drafts cannot replace newer project state. See [authoring and sharing](DIRECTOR-SHARING.md). + +Director version-6 documents add bounded, scene-local feature actions. Settled +LOAD/seek exposes keyboard-accessible text/source cards, anchor focus, explicit +shot transitions and admitted layer state changes. Pointer claims take priority; +Stop/replacement/teardown cancel pending actions and release handlers and UI. +Same-shot seeks rebuild selected packs and restore declared layer baselines. +See [scene actions](DIRECTOR-INTERACTIONS.md). Existing scenes/assets and credit remain. + +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, +response wording, push-to-talk timing, model preferences and Radio confirmation +order are preserved. Stopped connections, stale offers and delayed captures cannot affect a +replacement session; audio meters release failed initialization and reject revoked +frames. Delayed action results and post-capture continuations cannot resume a +stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). + +The application shell composes focused state owners for navigation, destination +lookup/orbit, Cockpit, visual settings, panel layout, aircraft display and layer +bindings. Keyboard/display subscriptions have a separate lifetime; existing +layer, scene and voice methods remain available through a delegation facade. +Disposal settles pending globe resets and removes camera-entry listeners before +asynchronous restoration; replacing a manager detaches its old Directions +services. See [UI ownership](UI-OWNERSHIP.md). + +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. +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 6 is the export format, with +zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); +see [Director](DIRECTOR.md) for camera, pack, action and sharing support. + 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. Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. +In the Pinokio browser shell, Nepal provider embeds and hidden preloads are +disabled before creating frames or loading provider SDKs, because the shell can +redirect iframe navigation to an external browser. Existing source-linked cards +remain available, and Open Original is an explicit action. Optional approved +local clips still use the existing fallback; the public pack bundles no witness +clips. Chrome and Safari retain embeds even when visiting a Pinokio-launched +server. This compatibility fallback does not repair Pinokio's navigation handler. + Nepal flood lines classify the active terrain or photoreal surface, and the surge marker follows that surface. Geographic route coordinates are unchanged; dynamic lines do not rely on unsupported depth-failure materials. Later media-only shots keep their completed source-path history throughout arrival and card reveal. Scene Stop, replacement, seek, and teardown revoke the Upper Valley locator's delayed camera approach and orbit while preserving its visible callout. A revoked timer or camera callback cannot start another flight or interfere with a newer shot. @@ -20,7 +107,7 @@ The Nepal overview route and lake-shot trail share the river-centerline starting The event panel omits the WITNESS shortcut. Shot media, source attribution, Open Original links and corridor navigation remain available. -The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. The authored estimate remains seven seconds plus the exit, replacing older saved holds at runtime without rewriting them. Stop, replacement and teardown revoke pending playback callbacks. +The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. When the host deliberately suppresses provider playback and there is no approved local clip, the source card instead uses the authored 7.65-second dwell before the next shot. The authored estimate replaces older saved holds at runtime without rewriting them. Autoplay requires transient Director ownership from Play Scene or Play Shot; LOAD, restoration and seek remain passive. Stop, replacement and teardown revoke that ownership and pending playback callbacks. Persistent media-led Nepal shots preserve their completed upstream flood path during camera travel. The next reach still reveals after arrival; direct loads reconstruct the prefix and backward replay trims downstream history. @@ -2435,9 +2522,9 @@ its criteria cannot be silently ignored. | Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | +| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | @@ -2525,7 +2612,9 @@ What a card needs is a resolved ground anchor: a step whose ground cell has not answered yet opens nothing rather than a card hanging at sea level. The cells are re-read for up to 90 seconds, which outlasts a cold terrain round trip; reroute, CLEAR and disable cancel that. -Transit is off by default, and share links use token `j`. The layer polls +Transit is off by default, and share links use token `j`. The movement panel order +places Transit directly after Street Traffic and before Bike Share. Cameras and +utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes @@ -2550,10 +2639,19 @@ clipped to the displayed time. The browser retains up to 15 minutes and 128 fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may shorten that history. Only the selected vehicle has a trail: completed segments are batched primitives, and a small moving head ends at the marker. Trail -geometry is bounded to 2,048 prepared vertices; history changes rebuild the -body, while ordinary playback updates the head and segment visibility. -Both normal and depth-fail instance colours follow the age ramp once per display -second or style change. Subdivision crossings update only changed visibility +geometry is bounded to 640 prepared vertices, prioritizing the active corridor +and shortening the oldest geometry first. Active and future marker corridors +retain their own 640-vertex budget; long report intervals use coarser subdivisions +than 25 m instead of dropping an admitted vehicle. Supported scenes use ground polylines +classified against both terrain and 3D tiles, including after map-stack changes, +with per-segment age colours. History changes rebuild the body. The head is one +short draped corridor, clipped to displayed time by a material uniform; only +subdivision crossings rebuild it. Ordinary frames upload no head geometry. +Without ground-polyline support, ordinary primitives retain a mode-coloured +0.55-alpha depth-fail path, including the head, without dimming it to near-black. +Instance attribute updates write back the same typed array returned by Cesium; +its getters return copies, so reading a second time discards a visibility or colour edit. +Normal instance colours follow the age ramp once per display second or style change. Subdivision crossings update only changed visibility attributes. Deselecting releases selection geometry; marker paths retain only active and future corridors. @@ -2571,22 +2669,27 @@ ingestion, eviction uses receipt-ordered queues, and capacity loss is disclosed when history is recreated. **Surface and route limitations.** Heights are aligned to work with Google 3D -tiles. Unknown historical surfaces leave gaps in a trail. Sparse reports can +tiles. Draped history is prepared on selection even while marker floors are unresolved; +Cesium resolves its surface. Without ground-polyline support, unknown historical +surfaces leave gaps. A selected body follows layer enablement and vehicle existence, +so an off-screen head does not hide an on-screen tail. Sparse reports can cut across corners; these paths are not surveyed route geometry. Subway markers are projected to street level because the feed does not provide depth, and the selected card explains that choice. Actual street-level views still require visual inspection. **Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection; -NVG, thermal and noir frames are 30/36 px. Source rasters are 48/96 px before padding. -External halos are 1/1.5 px in normal and 3/3 px in sensors, calculated from the +CRT, NVG, thermal and noir frames are 26/39 px. Source rasters are 48/96 px before padding. +External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors, calculated from the final unpadded display size. Six modes, two sizes and three profiles cap the raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram `#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown -`#D8DDE5`. Selection keeps the mode colour. Sensor bodies use solid rounded envelopes with at least 60% opaque white coverage -of the padded display footprint, without interior panel lines. Sensor input is opaque white with a -fully opaque black halo; black-hot reverses the contrast through the -thermal effect. Creation, mode changes, selection, deselection, map presets and +`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact +outer path, filled opaque white without interior panel lines, with an opaque +`#05080C` halo. Only the external padding grows; the unpadded silhouette remains +at CRT size. There is no envelope body. Black-hot reverses the contrast and +Ironbow maps the white input to its hottest palette entry through the thermal +effect; sprite code never applies a red tint. Creation, mode changes, selection, deselection, map presets and Cockpit vision use the same sprite styling function. Sensor signatures remain in-scene. The thresholds in `qaMetrics.js` require @@ -2596,14 +2699,17 @@ of its phosphor peak white (0.65992 from RGB 0.16/1/0.22), halo minimum ≤0.25 with the centre minus sampled background reported without gating. Each preset needs at least six verified, unobscured sprites; insufficient coverage is UNEXERCISED. The sampler excludes overlapping footprints, including otherwise ineligible -sprites, and verifies pick ownership across all nine sampled core pixels. +sprites, and chooses nine source-raster points in the widest solid white band, avoiding +window/panel gaps. Equal-width tram bands prefer the upper solid body over the +geometric centre; the nine-point spacing and all thresholds remain unchanged. +The sampler then verifies pick ownership at all nine points. Noir samples also exclude its deliberately vignetted outer field, using shader settings and screen position rather than observed brightness. Selected-trail diagnostics report every condition even when selection fails. Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour stroke over 3.25 px dark backing. Other contact themes retain their existing -behavior. The selected trail uses a 2 px core over 4 px backing, mode-coloured +behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured in normal and white input in sensors. Alpha falls linearly from 0.80 at the head to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes. @@ -2626,7 +2732,10 @@ heights, and reuses caller-owned sample and segment storage. collections above CCTV and Bikeshare. Traffic, Bikeshare and Transit share camera sensitivity claims; the original sensitivity returns after the last release. Visibility checks combine the camera frustum and ellipsoid occlusion at no more -than 4 Hz; missing map bounds do not admit the globe's far side. Frame work +than 4 Hz; missing map bounds do not admit the globe's far side. Camera arrivals +rebuild an unprepared or cold marker corridor during that visibility sweep, +adopt a loaded surface, and update both heightPending and surfaceReady diagnostics. +A resolved display sample clears its pending-height gate without waiting for a poll. Frame work visits moving visible vehicles, up to 64 pending height changes, and the selected head. Rotation visits visible membership at 5 Hz, with selected rotation per frame. The transit render hold exists only for visible positional or orientation @@ -2640,6 +2749,15 @@ destruction also releases collections and registrations. QA_BASE_URL=http://localhost:4305 QA_TAG=keyless node scripts/qa-transit.mjs ``` +`QA_BASE_URL=http://localhost:4305 node scripts/qa-transit-recovery.mjs` is the +focused keyless camera-arrival regression. With destination tiles loaded, it +injects nine disclosed moving-bus reports and delays their floor acquisition, +selects the hidden vehicle, then releases the floor and calls camera.setView. +Within two seconds it requires a visible marker, a prepared visible body and +at least six of eight paired off/on trail pixel samples. It uses production +selection, visibility and geometry owners and writes screenshots plus JSON; +it does not establish Google 3D acceptance. + The full run samples completed frames at 1920×1080 CSS pixels, records DPR, and measures moving fleets of 800 and 3,000 vehicles with 128 fixes each and DETECT on. Street/metro targets are respectively: transit frame CPU p95 ≤1/4 ms, frame @@ -2670,6 +2788,30 @@ injected playback independently of live fleet activity. Thermal checks verify style activation before reading generated controls; DETECT checks set density and exercise the real OFF/restore button. Cesium render errors fail selection acceptance. +The `trail-visible` section requires a visible Google 3D tileset. It selects a +scripted straight bus with CapMetro metadata under the isolated `qa-trail-capmetro` +feed ID, with nine retained fixes, runs for 20 seconds at each +300/600/900 m view (55/45/45 degrees), then freezes playback and waits for sustained tile and primitive readiness. +It captures off/on/off/on framebuffer patches at eight distinct projected surface +points outside the padded sprite and selection bracket. `Google 3D selected trail visible at 6 of 8 projected samples` +prints the selected key, fix count, elapsed display time, path length, hit count, +per-point coordinates/hits and renderer strategy. At least six points need a +repeatably changed green core or dark backing at the same pixels. Repeated off +and on channels and their contrast deltas must agree within 8 RGB levels; +background refinement, overlapping samples and unchanged dark roads fail. The dark +backing must reduce summed RGB by at least 40%, and either match needs RGB +distance of at least 40, so a faint depth-fail wash cannot satisfy visibility. Artifacts are +`-m-trail-visible.jpg` and matching JSON. Run the same harness +against the base and patched keyed builds; partial runs remain diagnostic. +Node checks run Cesium's final geometry pipeline, including batch IDs and +position encoding: 1,576 bytes per head subdivision and 2,014,128 bytes for the +1,278-instance body. The body plus a separate 81,792-byte instance reserve totals +2,095,920 bytes, below the 2 MiB body limit; the head remains below its separate +2 KiB/frame geometry limit. Ordinary frames upload no head geometry. These are +pipeline buffer measurements, not measured GPU time or instance-texture uploads. The same 20-second browser interval scopes +WebGL uploads to the trail body/head, forces one body rebuild, reports head CPU +p95/max, and gates body uploads at 2 MiB and head uploads at 2 KiB/frame. + Required empty or unexercised checks fail acceptance. The passing final line is `HARNESS: PASS (0 unexercised)`. A `QA_SECTIONS` subset is diagnostic only and cannot claim full acceptance. @@ -3415,6 +3557,7 @@ are omitted rather than framing the wrong part of the globe. - Build gate: `npm run build` - Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) - OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) +- Setup doctor resolves `OPENSKY_AUTH_MODE` from the environment and dotenv files. Explicit `anon` and OAuth mode without a client pair report keyless anonymous access (rate-limited); a complete OAuth pair retains the existing presence-only capability wording. Basic and auto modes report the selected mode without guessing which credentials runtime will accept. The proxy's auth behavior is unchanged. - Google key expected in Keychain service `google-maps-api` (or `GOOGLE_MAPS_API_KEY`, or `.env`) - OpenSky credentials expected in Keychain service `opensky-network` (or env, or `.env`); `OPENSKY_AUTH_MODE` and `OPENSKY_CREDENTIALS_FILE` read from `.env` too - Optional-key precedence in `dev-fresh.sh` is uniform — explicit shell env, then `.env`, then Keychain: `OPENAI_API_KEY` (Keychain `openai-api`/`api-key` — voice + HUD summary), `AISSTREAM_API_KEY` (`aisstream-api`/`api-key` — live vessels), `CESIUM_ION_TOKEN` (`cesium-ion`/`token` — Bing stacks), `TOMTOM_API_KEY` (`tomtom-api`/`api-key` — live traffic flow), `FIRMS_MAP_KEY` (`firms-map`/`map-key` — live fires), `LL2_API_TOKEN` (`.env` only) @@ -3430,6 +3573,7 @@ are omitted rather than framing the wrong part of the globe. - A cold OpenSky failure uses the current camera subpoint only to request a cached adsb.lol point fallback capped at 250 nm. A fresh OpenSky response or last-good cache wins; a nominally successful worldwide snapshot more than two minutes old prefers viewport-scoped adsb.lol when available, otherwise the stale source is reported honestly. The fallback is visibly source-labeled and is never presented as a worldwide snapshot. - GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded. - Proxy error payloads are sanitized (no internal error details returned to clients). +- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is. - `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`. - `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache. - `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context. @@ -3673,7 +3817,8 @@ easier to meet (detection is now on more often), but does not create it. - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy - (`.gev-cache/tomtom/`, 120 s TTL, `TOMTOM_DAILY_TILE_BUDGET` default 40k/day), + (`.gev-cache/tomtom/`, 120 s TTL, `TOMTOM_DAILY_TILE_BUDGET` default 6k/day, + sized so a 31-day month stays inside TomTom's 200K/month free allowance), decoded client-side (`flowTiles.js`), matched onto Overpass roads (`flowMatch.js`), and rendered as green/amber/red dot color + speed/density scaling (`trafficFlowStyle.js`); closures spawn no dots; unmatched roads stay diff --git a/docs/DIRECTOR-CAMERA.md b/docs/DIRECTOR-CAMERA.md new file mode 100644 index 000000000..2481ba296 --- /dev/null +++ b/docs/DIRECTOR-CAMERA.md @@ -0,0 +1,110 @@ +# 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; +[EDIT DETAILS](DIRECTOR-SHARING.md) captures anchors and move starts; a graphical +path editor remains a later extension. + +```json +{ + "version": 4, + "scenes": [{ + "id": "austin", + "title": "Across Austin", + "anchors": [ + { "id": "west", "title": "West viewpoint", "lat": 30.2672, + "lon": -97.76, "alt": 6000, "altitudeReference": "ellipsoid" }, + { "id": "east", "title": "East viewpoint", "lat": 30.2672, + "lon": -97.73, "alt": 7000, "altitudeReference": "ellipsoid" } + ], + "shots": [{ + "id": "crossing", + "title": "Cross the city", + "durationSec": 4, + "holdSec": 2, + "camera": { "anchorId": "east", "heading": 10, "pitch": -45, "roll": 0 }, + "move": { + "from": { "anchorId": "west", "heading": 350, "pitch": -45, "roll": 0 }, + "easing": "cubic-in-out" + }, + "visual": { "style": "normal" }, + "layers": {} + }] + }] +} +``` + +## One owner for each authored value + +An anchor is a scene-local geographic position, with a stable `id`, optional +`title`, latitude, longitude, height and explicit altitude reference. Each scene +accepts at most 1,024 anchors. IDs must be unique within that scene. References to +missing anchors fail validation before replacing a project. + +The shot's `camera` is its destination. It can be an inline pose or an +`anchorId` plus orientation. The optional `move.from` uses the same pose/reference +shape. References cannot also carry inline coordinates. They do not form chains. +Heading, pitch and roll belong to each camera pose, so two shots can look in +different directions from the same geographic anchor. Omitted orientation uses +heading 0, pitch −35 and roll 0 degrees. + +An explicit move requires `move.from`, `move.easing`, `durationSec` and `holdSec`. +Its duration is 0.2–86,400 seconds; the hold is 0–86,400 seconds. The destination +is not copied into another field. `linear` and `cubic-in-out` are the supported +easings. A shot without `move` retains the existing ordinary flight, even in a +version-4 file. Its destination may also reference an anchor. + +Capture continues to create ordinary camera-pose shots. Update Shot stores the +current viewport as an inline destination, preserving an explicit move's start +and easing; it does not change a shared anchor and thereby move other shots. +Editing imported directions and anchors currently means editing the scene JSON. + +## Coordinates and altitude + +Latitude/longitude use WGS84 degrees. Orientation uses the application's Cesium +heading/pitch/roll convention in degrees: heading rotates from local north, +negative pitch looks down, and zero pitch is horizontal. Camera positions use +meters above the WGS84 ellipsoid, matching `Cartesian3.fromDegrees` and captured +camera poses. These heights are not mean-sea-level elevations or heights above +the visible surface. + +Every anchor and every inline endpoint of an explicit move must say +`"altitudeReference": "ellipsoid"`. Ordinary legacy poses without that field +already use the same reference. Terrain-relative and mean-sea-level references +are rejected; no terrain request, default ground estimate or implicit conversion +is made. Authors must provide ellipsoidal heights with enough clearance for their +scene. The format does not promise collision avoidance or terrain following. + +During an explicit move, latitude and altitude interpolate between endpoints. +Longitude takes the shortest signed arc across the dateline; heading and roll +also take their shortest signed arcs. An exact 180-degree tie takes the negative +arc. This is an authored coordinate interpolation, not a geodesic or a camera +flight with an automatically raised arc. Endpoints are exact, including zero +pitch. Curved paths and look-at targets are later extensions. + +## Time, seeking and cancellation + +Load and Replay of an explicit move start at its authored `from` pose, regardless +of the current viewport. Play uses that same start. Both live playback and seek +use `sampleCameraMove`; a seek into the hold uses the exact destination. Backward +seeks do not depend on a prior camera position. Existing scene-pack media/reveal +rules can extend holds as before. + +Each active explicit move owns one animation-frame callback. Stop, replacement, +import, teardown, a navigation handoff or manual pointer/wheel input revokes that +callback and settles the move. Navigation also cancels a run during an authored +hold, so the next shot cannot retake the camera. Camera claims still respect +Cockpit's refusal. Already queued callbacks cannot move a replacement scene. +The timing diagnostic includes the active authored camera animation. + +The renderer-independent `gods-eye-view/director` export provides +`resolveCameraPose(scene, camera)`, `resolveCameraMove(scene, shot)` and +`sampleCameraMove(move, progress)`. These expect validated/normalized data. +Rendering and frame ownership live in `src/scenes/cameraMotion.js`; the existing +scene controller supplies camera application and lifecycle cancellation. + +Version-1/2/3 projects migrate to version 4 without converting their ordinary +shots into explicit moves. IDs, positions, durations, visual settings, packs and +source attribution survive. The bloom scale remains independent of the document +version; version-3 bloom is never interpreted as the older inverted scale. diff --git a/docs/DIRECTOR-DATA-PACKS.md b/docs/DIRECTOR-DATA-PACKS.md new file mode 100644 index 000000000..bf64efd0b --- /dev/null +++ b/docs/DIRECTOR-DATA-PACKS.md @@ -0,0 +1,115 @@ +# 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 and archive extraction are not implemented. Explicit local files +can be shared through [JSON asset bundles](DIRECTOR-SHARING.md). + +## 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. +See [actions](DIRECTOR-INTERACTIONS.md) and [authoring/sharing](DIRECTOR-SHARING.md) +for the controls built on this manifest/loading boundary. diff --git a/docs/DIRECTOR-INTERACTIONS.md b/docs/DIRECTOR-INTERACTIONS.md new file mode 100644 index 000000000..10168078c --- /dev/null +++ b/docs/DIRECTOR-INTERACTIONS.md @@ -0,0 +1,66 @@ +# Director scene actions + +Version-6 scene documents can attach actions to stable GeoJSON feature IDs in a +shot's selected data packs. Existing versions 1–5 migrate without adding actions. +Import validates data and references; it does not load assets or execute actions. + +Add `interactions` to a shot, for example: + +```json +{ + "id": "read-outline", + "label": "About this outline", + "target": { "packId": "outline", "featureId": "boundary" }, + "action": { + "type": "card", + "text": "An authored explanation, displayed as plain text.", + "url": "https://example.org/source" + } +} +``` + +The target must be a feature in a GeoJSON pack selected by that shot. Points, +lines and polygons retain stable pack/feature identities. Assets load before +feature existence is checked; a missing feature refuses the entire action set. +Images and native media are not interaction targets in this version. + +| Action | Fields | Behavior | +| --- | --- | --- | +| `card` | `text`, optional `url` | Show plain text and an optional HTTPS source link; never HTML | +| `focus` | `anchorId` | Set a top-down camera pose at a scene anchor through camera ownership arbitration | +| `shot` | `shotId` | Load another shot in the same scene at its authored start | +| `layer` | `layerId`, boolean `enabled` | Request an explicit state through the ordinary layer admission path | + +There are at most 64 interactions per shot. Card text is limited to 4,096 +characters; source links cannot contain credentials, query strings or fragments. +No script, module, arbitrary fetch endpoint or request header is accepted. + +Actions appear after LOAD or seek finishes. Click a pack feature to select its +actions and focus the first button; choose a button explicitly to execute it. +Tab traverses buttons and Enter/Space activates them. The selected buttons have +visible outlines, and status feedback is announced to assistive technology. +There are no global keyboard shortcuts to collide with navigation or typing. +Drawing ownership blocks both pointer selection and action execution. Camera +focus uses existing navigation admission, including Cockpit restrictions. + +Actions are inactive during automatic playback and camera travel. They do not +introduce automatic branching or loops. A chain of user-triggered shot changes +is limited to 64 transitions; an explicit LOAD starts a new chain. Import/Stop, +replacement, playback and teardown remove selection, cards and picking handlers, +and abort pending layer requests. No per-frame interaction loop runs. + +A layer action must name a layer with an explicit baseline in the current shot. +A target shot must declare a baseline for every layer the departing shot can +change. LOAD and seek reapply those baselines and clear selection. Stop cancels +pending transitions; already completed layer changes remain visible, consistent +with ordinary scene controls. Camera focus is an immediate anchor pose, not a +new timeline segment; seek restores the authored camera pose. Scene JSON is not +modified by executing an action. + +The portable interaction session owns action admission/cancellation. The scene +interaction owner supplies accessible DOM and picking; pack renderers own the +feature identities and release them with their geometry. Layer/source admission +and camera arbitration remain with their existing owners. + +Author actions in scene JSON or the validated EDIT DETAILS draft. +[Import preview and file sharing](DIRECTOR-SHARING.md) preserve those declarations. Existing content, assets, notices and contributor credit are preserved. diff --git a/docs/DIRECTOR-SHARING.md b/docs/DIRECTOR-SHARING.md new file mode 100644 index 000000000..7748b2eb2 --- /dev/null +++ b/docs/DIRECTOR-SHARING.md @@ -0,0 +1,96 @@ +# Director authoring and file sharing + +Open Scenes and select a scene and shot. Existing capture, update, playback and +whole-project export controls remain available. Authored scene content, source +links, assets and contributor credit are preserved. + +## Review imports before applying + +IMPORT accepts scene JSON (including older supported versions) or a +`.gevbundle.json` asset bundle. A preview lists scenes, shots, data-pack attribution, +configured or unavailable sources, missing layer IDs and bundled bytes. Preview +makes no asset requests and does not change the current project. Configured +sources do not guarantee that a file exists: ordinary assets are checked on LOAD. +Bundle preview verifies the manifest and byte integrity; format decoding and +geometry limits are still enforced when rendering. + +Apply replaces the current project after validation and playback cleanup. Export +your current project first if you want to keep both. Cancel or Escape discards +the staged import. A changed project invalidates an open draft rather than being +overwritten. Invalid files leave the existing project and browser save intact. +The programmatic `importProjectFile(file)` API still applies trusted callers' +imports directly; the installed file chooser supplies the review step. + +## Edit scene details + +EDIT DETAILS provides validated JSON fields for: + +- Scene anchors and data-pack manifests. +- The selected shot's camera, optional move, duration, hold, pack IDs and actions. + +Capture camera as anchor adds a named geographic position to the draft. Set move +start to current camera creates a pose-to-pose move ending at the shot's existing +camera; Use ordinary flight removes that explicit move. Heights are meters above +the WGS84 ellipsoid. Apply details validates the complete project, preserves IDs, +layer declarations and content notices, and keeps the selected shot. Cancel +leaves the project unchanged. See [camera directions](DIRECTOR-CAMERA.md), +[data packs](DIRECTOR-DATA-PACKS.md) and [actions](DIRECTOR-INTERACTIONS.md) for +field definitions. This is a basic draft editor, not a node graph or path editor. + +## Share a selected scene + +SHARE SCENE exports only the selected scene. Download scene JSON preserves its +camera, timing, layers, anchors, manifests, interactions and attribution, but +contains no asset bytes. The recipient needs the referenced source registrations +and files. Existing EXPORT PRESETS continues to export the entire project. + +For a bundle, select the declared pack files or a folder, then Download asset +bundle. Folder paths match a manifest path either directly or after removing the +chosen root directory. Individual filenames must be unambiguous across all pack +sources; ambiguous or missing matches fail without downloading a partial bundle. +Previously imported bundle files can be reused. Export never automatically fetches +remote files or copies registered recipe media. Existing integrity declarations +must match the files you supply. + +Bundles include only declared data-pack files. Registered scene content, linked +media, map tiles and live data are not included. Source notices and licenses remain +in the document; packaging a file does not grant permission to redistribute it. + +## Bundle format and limits + +A bundle is uncompressed JSON with these fields: + +```json +{ + "format": "gev-scene-bundle", + "version": 1, + "project": { "version": 6, "scenes": [] }, + "assets": [] +} +``` + +Each asset contains `path`, `mimeType`, `base64` and lowercase hexadecimal `sha256`. +Export copies the document and rewrites pack references to the reserved +`scene-bundle` source, adding exact `byteLength` and `sha256`. Paths are confined +relative names; URLs, traversal, duplicate or unreferenced assets, unknown fields, +missing packs and mismatched hashes are rejected. Scene documents remain bounded +at 5 MiB. A bundle allows 50 MiB of JSON, 64 files, 8 MiB per file and 32 MiB of +asset bytes in total. Supported MIME types are JSON/GeoJSON, PNG, MP4/WebM video +and MPEG/Ogg/WAV/WebM audio. Normal pack format and geometry checks still apply. +There are no executable modules, request headers or credentials in the format. + +Imported bytes stay in memory for the current project and session. Stop releases +rendered resources while retaining these bytes for replay. Project replacement +or application teardown releases them. Only scene JSON is saved in browser +storage: **reimport the bundle after reloading the app**. Missing bundle bytes +fail explicitly, with no network fallback. This is file sharing, not a storage +service or an offline basemap. + +Portable helpers are exported through `gods-eye-view/director`: `parseSceneShare`, +`readSceneShare`, `createSceneBundle`, `createBundleAssets`, `describeSceneShare`, +`editSceneDetails` and `selectSceneDocument`. Loading and UI remain separate owners. +`getSharingState()` reports copied dialog/asset counts for lifecycle diagnostics. + +Run `node scripts/qa-director-sharing.mjs` against the local server for installed +controls, cancellation, stale drafts, export/import round trips, resource cleanup +and narrow-screen acceptance. Fixtures are synthetic and carry their own notices. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md new file mode 100644 index 000000000..a2ca13da7 --- /dev/null +++ b/docs/DIRECTOR.md @@ -0,0 +1,108 @@ +# Director: reusable scene playback + +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 +records implemented camera directions, data packs, declarative interactions and +file sharing, along with the remaining extensions. + +## Implemented boundary + +`src/director/playback.js`, exported as `gods-eye-view/director`, provides: + +- `buildPlaybackQueue(scenes, startSceneId, { single })`: ordered scenes/shots, + retaining the existing round-robin behavior. +- `playSceneQueue(queue, { token, adapter, previousScene, releaseOnFinish })`: + sequential execution, cancellation checks and scene release. + +The runner imports no renderer, UI, storage, recipe or dataset. An adapter supplies +these phases, in order: `selectShot`, `applyVisual`, `applyLayers`, `travel`, +`settle`, `hold`, `completeShot`. Each receives `{ scene, shot, index, total, +token }`. It also supplies `releaseScene(scene, token?)` and optionally `complete()`. +Callbacks may return promises. Each playback invocation requires its own adapter +state and token; the caller owns camera arbitration and concurrent-run refusal. + +The runner checks cancellation after each phase. Adapters must also pass the +abort signal or liveness predicate into pending work: a check between phases +cannot stop a camera flight or network request already in progress. Cleanup +between scenes and on preview exit runs without an aborted token. A refused +initial handoff starts no shot. Final cleanup runs on completion, Stop or failure; +`releaseOnFinish: false` retains the final scene for ordinary playback. Exceptions +propagate so the caller can restore its controls. Empty/already-cancelled queues +acquire no resources. This low-level API expects normalized scene objects; +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 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 +by @manjunath22466 in #590. Its clips use source links/embeds; comparison images +and river coordinates are bundled. See the +[event pack provenance](../public/events/bhote-koshi-2026/README.md). + +## Next changes, in order + +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. Basic anchor/move authoring is available through EDIT DETAILS; curved paths and + look-at targets remain 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. File bundles are available; transforms and terrain-relative placement + remain future extensions. Scene files carry no credentials or executable modules. +5. **Declarative interactions — implemented.** [Version 6](DIRECTOR-INTERACTIONS.md) + connects selected feature IDs to accessible text/source cards, anchor focus, + shot seek and admitted layer changes. References are validated and pending + actions cancel on Stop, replacement or teardown. Scene files contain no + JavaScript or arbitrary HTML; actions require explicit user input. +6. **Sharing and basic authoring — implemented.** [Authoring and sharing](DIRECTOR-SHARING.md) + adds import preview, missing-source/layer diagnostics, validated scene/shot + drafts and selected-scene JSON or explicit-file bundles. Pack attribution is + preserved. Bundle bytes last for the session and must be reimported after a + reload. A future node graph can author the same document without becoming a + playback or format requirement. + +Each step must preserve existing scene editing, keyboard playback, camera +arbitration, layer cleanup, media timing and provider fallback. Acceptance includes +portable runner tests, existing Director regressions and browser playback with +Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures +for failures and cancellation, alongside live visual checks for actual content. + +Declarative actions are implemented in version 6; see [scene actions](DIRECTOR-INTERACTIONS.md) for supported targets, execution and reset semantics. diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md new file mode 100644 index 000000000..f6dc66636 --- /dev/null +++ b/docs/SCENE-DOCUMENT.md @@ -0,0 +1,105 @@ +# Scene document format + +Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md) +and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on +import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with +a field path before the current project, selection or saved bytes change. + +```json +{ + "version": 5, + "scenes": [{ + "id": "my-scene", + "title": "My scene", + "shots": [{ + "id": "my-shot", + "title": "Arrival", + "durationSec": 4, + "holdSec": 1, + "camera": { "lat": 51.5, "lon": -0.12, "alt": 800, + "heading": 0, "pitch": -35, "roll": 0 }, + "visual": { "style": "normal" }, + "layers": { "traffic": { "enabled": true } } + }] + }] +} +``` + +## Fields and migration + +The root requires `scenes` (an array, which may be empty). Optional root fields +are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs). +Each scene requires a `shots` array; optional fields are `id`, `title`, +`releaseLayerIds`, `appliedShotPacks` and version-4 `anchors`. A pack marker has `id`, optional numeric +`version` and `shotBindings` (original shot title to saved shot ID). These markers +retain the editor's existing pack-update and rename behavior. + +Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`, +`sourcePackId`, `sourcePackVersion` and version-4 `move`. Scene IDs are unique within the project; +shot IDs are unique within their scene. Existing IDs and pack bindings survive +migration. Missing IDs are generated once and persisted by the next save/export. +Missing titles and optional camera/visual fields receive the editor's defaults. +Empty scenes and intentionally empty projects remain empty. + +Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses +meters above the WGS84 ellipsoid. Import preserves finite heights below +100 meters and zero pitch, which previously fell through to defaults. Anchors and explicit moves require the `ellipsoid` altitude reference; +terrain-relative inputs are rejected. See the camera contract for exact semantics. +Flight durations retain the editor's minimum of 0.2 seconds and its 4-second +fallback for zero; holds may be zero. + +Visual fields are `style`, `mapStack`, `styleParams`, `bloom`, `sharpen`, `hud`, and +`detection`, and `scope`. Bloom has `enabled`, `intensity`, `version`; sharpen has `enabled`, +`intensity`; HUD has `visible`, `variant`; detection has `mode`, `density`, +`allocation`, `fadePct` and `outsideOpacityPct`. Scope has `enabled`, `featherPct`. +The latter settings were written by Update Shot but previously lost during +normalization; they now survive import and capture. +Version-1/2 bloom without an explicit scale version uses the original inverted +scale and migrates once to scale 2. The scene document and bloom scale versions +are separate. Legacy numeric strings are accepted for numeric fields. + +Layer entries accept a boolean shorthand or `{ "enabled": true, "params": {} }`. +Parameters and style parameters retain nested JSON data. They are interpreted by +already registered components; importing a file does not fetch its URLs, register +modules, evaluate code or apply its layers. Unsupported structural fields are +rejected instead of silently dropping potential edits. Map-stack selection +retains the existing registered-stack behavior. + +## Validation limits + +`gods-eye-view/director` exports `parseSceneDocument(text)`, +`validateSceneDocument(project)`, `stringifySceneDocument(project)`, +`SceneDocumentError`, `SCENE_DOCUMENT_VERSION` and `SCENE_DOCUMENT_LIMITS`. +Validation is independent of the renderer, storage and recipes. Parsing checks +UTF-8 size before JSON decoding; the file importer also checks file size before +reading it. Migration/defaults live separately in `src/scenes/project.js`. + +Limits are 5 MiB, 256 scenes, 10,000 total shots, 10,000 entries per collection, +24 levels of nesting, 200,000 visited values, 65,536 characters per JSON string, +256 characters per field/ID, and 4,096 characters per title. Numeric values must +be finite. Camera limits are latitude ±90, longitude ±180, altitude −12,000 to +1 billion meters, heading/roll ±360 and pitch ±90; durations/holds are at most +86,400 seconds. Post-processing values are bounded and normalized to the existing +slider ranges. Object prototype keys are refused at every depth. + +An invalid import leaves the current project and playback untouched. A valid +import cancels and settles pending playback before replacing the project. If +multiple files are read concurrently, only the newest request can replace it. +Disposal prevents a pending import from publishing. + +The browser storage key remains `godsEyeView.sceneProject.v2` for compatibility; +that key's suffix is not the document version. An unreadable saved project stays +in storage. The app supplies temporary defaults and a visible warning, but blocks +saving over the original bytes until a valid file is explicitly imported. Export +can preserve temporary edits. Storage writes use the same validation before replacing saved bytes; invalid +edits and storage write failures surface an unsaved toast. + +Only authored project fields belong in this file. Runtime clocks, camera flights, +loading progress and run diagnostics remain separate. Camera anchors and authored +moves arrived in version 4; data packs follow in version 5. Declarative +interactions remain a planned extension. No scene assets, source links, licenses or contributor attribution +change with this document boundary. + +Version 5 additionally accepts scene `dataPacks` and shot `dataPackIds`, as +defined in [data packs](DIRECTOR-DATA-PACKS.md). Version-4 camera documents +continue to import without adding any packs. diff --git a/docs/UI-OWNERSHIP.md b/docs/UI-OWNERSHIP.md new file mode 100644 index 000000000..8d24215c7 --- /dev/null +++ b/docs/UI-OWNERSHIP.md @@ -0,0 +1,35 @@ +# Application UI ownership + +`src/ui/applicationShell.js` constructs controls, connects their explicit inputs +and coordinates shutdown. `shellFacade.js` preserves the methods used by layers, +scene playback and voice. It delegates; new behavior belongs with its owner. + +| Owner | Responsibility | +| --- | --- | +| `navigationController.js` | Navigation generation, tracking release and camera authority | +| `locationNavigation.js` | Destination lookup/selection, orbit, world jumps and globe-reset completion | +| `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal | +| `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime | +| `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration | +| `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls | +| `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners | +| `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime | +| `shareRestoration.js` | Initial share/layer restoration and cancellation | +| `shellFeedback.js` | Loading feedback, notices and toast lifetime | + +Owners receive named services, elements, operations and readers. They do not +receive the application shell itself. Readers resolve a replaceable collaborator +at use time; operations cross an ownership boundary without copying its state. +The facade exposes existing properties as delegates rather than duplicate state. + +Shutdown has two phases. First revoke input, deferred navigation, camera-entry +listeners, lookups, timers and rendering work. Then await Context restoration, +release sensor overrides while the manager is still attached, detach layer +services and destroy scene resources. Late callbacks cannot acquire the camera. +Manager replacement also releases subscriptions and the previous Directions +module even if the replacement does not provide a Directions layer. + +Source-contract tests follow both sides of delegated wiring. Lifecycle tests +exercise pending reset cancellation, stale camera callbacks, replacement and +idempotent teardown. Browser acceptance covers the composed application, including +share restoration, scene commands, navigation, Cockpit, display and voice. diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md new file mode 100644 index 000000000..d5553df00 --- /dev/null +++ b/docs/VOICE-OWNERSHIP.md @@ -0,0 +1,48 @@ +# Realtime voice ownership + +`realtimeController.js` composes the session and owns its status, UI bindings and +overall lifetime. It coordinates startup and shutdown. `realtimeFacade.js` +preserves existing methods and properties by delegation, without copying state. +The session adapter, action runner and backend interfaces remain unchanged. + +| Owner | State and responsibility | +| --- | --- | +| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | +| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | +| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | +| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | +| `realtimeCost.js` | Next-session preferences and the current session's model-bound meter | +| `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | +| `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | + +Pure input policy, preferences and protocol-response policy have separate modules. +Owners receive named readers, operations or focused collaborators. Cross-owner +cancellation uses operations; response handling does not own Radio's reservations +or viewport deletion bookkeeping. + +Shutdown first invalidates acquisition and action work, then closes transport, +stops audio analysis, releases media and clears conversation/input state. Full +removal also detaches UI, keyboard, annotation and Radio observers. Radio takeover +retains confirmed playback through that same shutdown path. A late offer, peer callback, +viewport capture or cancelled meter frame cannot enter a replacement session. +Action and post-capture continuations retain their conversation identity before +publishing output, queueing a response or changing status. + +Behavioral invariants remain in force: a 500 ms Space hold claims push-to-talk; +short control taps and text entry remain native; a click-started session stays +open-mic. Every accepted or refused tool call receives its terminal output once. +New typed input supersedes old intent. Radio waits for the spoken confirmation and +verified muted playback. A live cost meter stays bound to its negotiated model, +even when the next-session preference changes. + +Run the unit suite and both live recorded-audio modes against a configured local +server: + +```sh +node scripts/qa-voice-wav.mjs http://localhost:4173 +node scripts/qa-voice-wav.mjs http://localhost:4173 --push-to-talk +``` + +The second mode uses the actual Space shortcut, verifies microphone acquisition +and release, and checks the same globe/Radio outcome. Tracking and scene-control +acceptance exercise voice's shared application operations separately. diff --git a/package.json b/package.json index a919399a1..809841ba2 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "./ui/scenes": "./src/ui/scenes.js", "./ui/shell": "./src/ui/applicationShell.js", "./ui/styles": "./src/ui/styles.js", + "./director": "./src/director/playback.js", "./scenes": "./src/scenes/director.js", "./sources/live": "./src/sources/live/index.js", "./layers/flights": "./src/layers/flights/index.js", diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index d84bc18ac..a5e61f039 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -35,9 +35,12 @@ const portableExport = (key) => /^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test( key, ) || - ['./voice/action-schemas', './voice/session', './data/lifecycle'].includes( - key, - ); + [ + './director', + './voice/action-schemas', + './voice/session', + './data/lifecycle', + ].includes(key); /** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */ export function checkImportDirections(root) { diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b86632108..2ed4f1081 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -181,6 +181,7 @@ "src/ui/layers.js", "src/ui/layerPanel.js", "src/ui/layerKeyRequirement.test.mjs", + "src/ui/layerPanel.test.mjs", "src/ui/clearLayersControl.js", "scripts/qa-layer-panel.mjs", "scripts/qa-directions.mjs", @@ -494,6 +495,7 @@ "src/layers/cctv/source.test.mjs", "src/data/alprCameras.js", "src/data/alprCameras.test.mjs", + "src/data/dataCredits.test.mjs", "src/layers/alpr/index.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", @@ -701,6 +703,7 @@ "src/layers/transit/viewport.js", "src/layers/transit/lifecycle.test.mjs", "scripts/qa-transit.mjs", + "scripts/qa-transit-heading.mjs", "src/tooling/transitProvider.test.mjs", "src/app/layers/transit.js", "src/data/transitIcons.js", @@ -723,7 +726,24 @@ "scripts/qa-transit-controls.mjs", "src/tooling/transitQa.test.mjs", "scripts/qa-transit-browser.mjs", + "scripts/qa-transit-recovery.mjs", "scripts/format-runtime.json", "src/tooling/transitService.test.mjs", - "src/sources/overpassFeatures.test.mjs" + "src/sources/overpassFeatures.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", + "src/director/interactions/interactions.test.mjs", + "scripts/qa-director-interactions.mjs", + "src/director/sharing/sharing.test.mjs", + "scripts/qa-director-sharing.mjs", + "src/data/aisStreamSentinels.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 643edce10..433f5f36a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -504,6 +504,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/locationNavigation.js", + "src/ui/cockpitCoordinator.js", "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", "src/ui/cctv.js", @@ -574,7 +576,11 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/worldFocus.js", - "src/data/feedState.js" + "src/data/feedState.js", + "src/ui/aircraftDisplay.js", + "src/ui/layerBindings.js", + "src/ui/displayBindings.js", + "src/ui/shellFacade.js" ], "external": ["cesium"] }, @@ -606,6 +612,29 @@ ], "external": [] }, + "director": { + "exports": ["./director"], + "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", + "src/director/interactions/document.js", + "src/director/interactions/session.js", + "src/director/sharing/bundle.js", + "src/director/sharing/preview.js", + "src/director/sharing/lifetime.js", + "src/director/authoring.js" + ], + "external": [] + }, "scenes": { "exports": ["./scenes"], "modules": [ @@ -615,6 +644,8 @@ "src/data/bhoteKoshiFloodPath.js", "src/maps/catalog.js", "src/scenes/director.js", + "src/scenes/playbackAdapter.js", + "src/director/playback.js", "src/scenes/recipes.js", "src/scenes/scenePolicy.js", "src/bloom.js", @@ -622,7 +653,36 @@ "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", + "src/director/interactions/document.js", + "src/director/interactions/session.js", + "src/scenes/interactions.js", + "src/data/inputOwnership.js", + "src/director/sharing/bundle.js", + "src/director/sharing/preview.js", + "src/director/sharing/lifetime.js", + "src/director/authoring.js", + "src/scenes/sharing.js", + "src/ui/sceneSharing.js" ], "external": ["cesium"] }, @@ -1105,7 +1165,18 @@ "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", - "src/voice/realtimeEvents.js" + "src/voice/realtimeEvents.js", + "src/voice/realtimePreferences.js", + "src/voice/realtimeProtocol.js", + "src/voice/realtimeDiagnostics.js", + "src/voice/realtimeViewport.js", + "src/voice/realtimeInputPolicy.js", + "src/voice/realtimeInput.js", + "src/voice/realtimeCost.js", + "src/voice/realtimeFacade.js", + "src/voice/realtimeRadio.js", + "src/voice/realtimeTurns.js", + "src/voice/realtimeConnection.js" ], "external": [] }, @@ -1518,6 +1589,8 @@ "src/renderGovernor.js", "src/rightRailPolicy.js", "src/scenes/director.js", + "src/scenes/playbackAdapter.js", + "src/director/playback.js", "src/scenes/recipes.js", "src/scenes/scenePolicy.js", "src/scopeMask.js", @@ -1547,6 +1620,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/locationNavigation.js", + "src/ui/cockpitCoordinator.js", "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", "src/ui/cctv.js", @@ -1691,7 +1766,50 @@ "src/sources/overpassFeaturesRecords.js", "src/sources/overpassFeatures.js", "src/sources/featureSource.js", - "src/sources/overpassRoads.js" + "src/sources/overpassRoads.js", + "src/ui/aircraftDisplay.js", + "src/ui/layerBindings.js", + "src/ui/displayBindings.js", + "src/ui/shellFacade.js", + "src/voice/realtimePreferences.js", + "src/voice/realtimeProtocol.js", + "src/voice/realtimeDiagnostics.js", + "src/voice/realtimeViewport.js", + "src/voice/realtimeInputPolicy.js", + "src/voice/realtimeInput.js", + "src/voice/realtimeCost.js", + "src/voice/realtimeFacade.js", + "src/voice/realtimeRadio.js", + "src/voice/realtimeTurns.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", + "src/director/interactions/document.js", + "src/director/interactions/session.js", + "src/scenes/interactions.js", + "src/director/sharing/bundle.js", + "src/director/sharing/preview.js", + "src/director/sharing/lifetime.js", + "src/director/authoring.js", + "src/scenes/sharing.js", + "src/ui/sceneSharing.js" ], "external": [ "@mapbox/vector-tile", @@ -2156,7 +2274,11 @@ }, "ui-navigation": { "exports": ["./ui/navigation"], - "modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"], + "modules": [ + "src/navigationPolicy.js", + "src/ui/navigationController.js", + "src/app/stateChannel.js" + ], "external": ["cesium"] }, "ui-share-restoration": { diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 5c3829462..9213dcaa5 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -2171,7 +2171,8 @@ try { ); const resetResult = await page.evaluate(() => { - const manager = window.__godsEyeView.styleManager; + // Both the UI and compatibility facade delegate to this navigation owner. + const manager = window.__godsEyeView.styleManager._locationNavigation; const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module; window.__qaCockpitReset = { calls: 0, @@ -2203,7 +2204,7 @@ try { const awareness = gev.dataManager.layers.get('military-awareness')?.module; const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null; const height = gev.viewer.camera.positionCartographic?.height; - gev.styleManager.resetToGlobeView = qa.original; + gev.styleManager._locationNavigation.resetToGlobeView = qa.original; delete window.__qaCockpitReset; return { calls: qa.calls, diff --git a/scripts/qa-director-camera.mjs b/scripts/qa-director-camera.mjs new file mode 100644 index 000000000..62f0af5ff --- /dev/null +++ b/scripts/qa-director-camera.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** Verify imported camera directions through real rendering and camera authority handoffs. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const output = path.resolve('qa-shots/director-camera'); +fs.mkdirSync(output, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failed = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failed++; +} +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const { sceneDirector: d, styleManager: style } = window.__godsEyeView; + const file = { + version: 4, + scenes: [ + { + id: 'directions', + title: 'Camera directions', + anchors: [ + { + id: 'start', + title: 'Austin west', + lat: 30.2672, + lon: -97.76, + alt: 6000, + altitudeReference: 'ellipsoid', + }, + { + id: 'end', + title: 'Austin east', + lat: 30.2672, + lon: -97.73, + alt: 7000, + altitudeReference: 'ellipsoid', + }, + ], + shots: [ + { + id: 'move', + title: 'Across Austin', + durationSec: 1, + holdSec: 0.2, + camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 }, + move: { + from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 }, + easing: 'linear', + }, + layers: {}, + visual: { style: 'normal' }, + }, + ], + }, + ], + }; + await d.importProjectFile( + new File([JSON.stringify(file)], 'directions.json'), + ); + const scene = d._project.scenes[0]; + const shot = scene.shots[0]; + const imported = + scene.anchors.length === 2 && shot.camera.anchorId === 'end'; + await d.seekScene(scene.id, 0.5 / 1.2); + const middle = style.getCameraState(); + const seekMidpoint = + Math.abs(middle.lon - -97.745) < 1e-6 && + Math.abs(middle.alt - 6500) < 0.01; + await d.seekScene(scene.id, 0); + const back = style.getCameraState(); + const backward = Math.abs(back.lon - -97.76) < 1e-6; + const replay = await d.replayShot(scene.id, shot.id); + const end = style.getCameraState(); + const replayEnd = + replay?.started && + Math.abs(end.lon - -97.73) < 1e-6 && + Math.abs(end.alt - 7000) < 0.01; + d.stopScene(); + await d.startScene(scene.id, { single: true, preview: false }); + const completed = + !d.running && d.getPlaybackTimingState().activeTimers === 0; + const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6; + // Updating the target creates an explicit inline pose while retaining the authored start. + d.updateSelectedShot(); + const saved = JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + const updated = + saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' && + saved.scenes[0].shots[0].move.from.anchorId === 'start'; + shot.durationSec = 30; + const run = d.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while (!d._cameraMotion.active && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 20)); + const started = d._cameraMotion.active; + style.runImmediateNavigation('location', () => true); + await run; + const revoked = + started && + !d.running && + !d._cameraMotion.active && + d.getPlaybackTimingState().activeTimers === 0; + const stoppedPose = style.getCameraState(); + await new Promise((r) => setTimeout(r, 150)); + const stable = + Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7; + return { + imported, + seekMidpoint, + backward, + replayEnd, + completed, + settled, + updated, + revoked, + stable, + }; + }); + for (const [name, passed] of Object.entries(result)) check(name, passed); + await new Promise((r) => setTimeout(r, 6000)); + await page.screenshot({ path: path.join(output, 'austin.png') }); + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector; + const pose = d.styleManager.getCameraState(); + d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 }); + d.viewer.scene.requestRender(); + }); + await new Promise((r) => setTimeout(r, 3000)); + await page.screenshot({ path: path.join(output, 'angle.png') }); + const inputStarted = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes[0]; + window.__qaCameraRun = d.startScene(scene.id, { + single: true, + preview: false, + }); + const deadline = Date.now() + 10000; + while (!d._cameraMotion.active && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 20)); + return d._cameraMotion.active; + }); + const canvas = + (await page.$('canvas.cesium-widget-canvas')) || + (await page.$('.cesium-widget canvas')); + const rect = await canvas.boundingBox(); + await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); + await page.mouse.down(); + await page.mouse.up(); + const input = + inputStarted && + (await page.evaluate(async () => { + await window.__qaCameraRun; + const d = window.__godsEyeView.sceneDirector; + return !d.running && d.getPlaybackTimingState().activeTimers === 0; + })); + check('manual canvas input revokes the move and releases clocks', input); + const hold = await page.evaluate(async () => { + const { sceneDirector: d, styleManager: style } = window.__godsEyeView; + const scene = d._project.scenes[0]; + const shot = scene.shots[0]; + shot.durationSec = 0.2; + shot.holdSec = 30; + const run = d.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while ( + (!d._activeSceneTravel || !d.running || d._cameraMotion.active) && + Date.now() < deadline + ) + await new Promise((r) => setTimeout(r, 20)); + const holding = d.running && !d._cameraMotion.active; + const began = performance.now(); + style.runImmediateNavigation('location', () => true); + await run; + return ( + holding && + performance.now() - began < 1000 && + d.getPlaybackTimingState().activeTimers === 0 + ); + }); + check('navigation during an authored hold settles immediately', hold); + + if (errors.length) + console.error( + errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +if (failed) process.exitCode = 1; diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs new file mode 100644 index 000000000..6c04f4b80 --- /dev/null +++ b/scripts/qa-director-interactions.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; +const output = 'qa-shots/director-interactions'; +fs.mkdirSync(output, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +let failures = 0; +const errors = []; +const check = (name, ok) => { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +}; +page.on('pageerror', (e) => errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request', (request) => { + if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json') + void request + .respond({ + status: 200, + contentType: 'application/geo+json', + body: JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'point', + geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] }, + }, + ], + }), + }) + .catch(() => {}); + else void request.continue().catch(() => {}); +}); +const action = (id) => `[data-director-action="${id}"]`; +const load = () => + page.evaluate(() => + window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', { + flyDuration: 0.2, + }), + ); +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 camera = { + lat: 30.2672, + lon: -97.742, + alt: 4000, + heading: 0, + pitch: -90, + roll: 0, + altitudeReference: 'ellipsoid', + }; + const target = { packId: 'data', featureId: 'point' }; + const interaction = (id, label, action) => ({ id, label, target, action }); + window.__interactionProject = { + version: 6, + scenes: [ + { + id: 'interactive', + title: 'Interactive fixture', + anchors: [ + { + id: 'view', + lat: 30.27, + lon: -97.74, + alt: 5000, + altitudeReference: 'ellipsoid', + }, + ], + dataPacks: [ + { + id: 'data', + version: 1, + format: 'geojson', + source: { adapter: 'assets', path: 'qa/actions.json' }, + attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' }, + placement: { altitudeReference: 'ellipsoid' }, + }, + ], + shots: [ + { + id: 'one', + title: 'Choose an action', + camera, + durationSec: 0.2, + holdSec: 0, + move: { + from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' }, + easing: 'linear', + }, + visual: { style: 'normal', mapStack: 'photoreal' }, + layers: { traffic: false }, + dataPackIds: ['data'], + interactions: [ + interaction('card', 'Read source', { + type: 'card', + text: 'Plain text only', + url: 'https://example.org/source', + }), + interaction('focus', 'Focus anchor', { + type: 'focus', + anchorId: 'view', + }), + interaction('layer', 'Show traffic', { + type: 'layer', + layerId: 'traffic', + enabled: true, + }), + interaction('next', 'Next shot', { + type: 'shot', + shotId: 'two', + }), + ], + }, + { + id: 'two', + title: 'Next', + camera: { ...camera, heading: 30 }, + durationSec: 0.2, + holdSec: 0, + layers: { traffic: false }, + }, + ], + }, + ], + }; + await d.importProjectFile( + new File([JSON.stringify(window.__interactionProject)], 'actions.json'), + ); + }); + check( + 'import installs no action handlers or UI', + await page.evaluate( + () => + !window.__godsEyeView.sceneDirector.getInteractionState().active && + !document.querySelector('[data-director-interactions]'), + ), + ); + await load(); + check( + 'settled LOAD activates four actions', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getInteractionState().count === 4, + ), + ); + await page.waitForFunction( + () => window.__godsEyeView.tileset?.tilesLoaded === true, + { timeout: 60000 }, + ); + await new Promise((r) => setTimeout(r, 2000)); + const point = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const entity = d.viewer.entities.values.find( + (e) => e.name === 'data / point', + ); + const pos = entity.position.getValue(d.viewer.clock.currentTime); + const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos); + const rect = d.viewer.scene.canvas.getBoundingClientRect(); + return { x: screen.x + rect.left, y: screen.y + rect.top }; + }); + await page.mouse.click(point.x, point.y); + check( + 'real globe pick selects the feature and focuses its keyboard action', + await page.evaluate( + () => + document.activeElement?.dataset.directorAction === 'card' && + document + .querySelector('[data-director-interactions] [role=status]') + .textContent.includes('point'), + ), + ); + await page.keyboard.press('Enter'); + check( + 'Enter shows literal text and a safe source link', + await page.evaluate(() => { + const card = document.querySelector('[data-director-action-card]'); + return ( + card?.textContent.includes('Plain text only') && + !card.querySelector('b') && + card.querySelector('a')?.rel === 'noopener noreferrer' + ); + }), + ); + await page.screenshot({ path: `${output}/selected.png` }); + fs.writeFileSync( + `${output}/report.json`, + JSON.stringify( + await page.evaluate(() => ({ + tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + })), + null, + 2, + ), + ); + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + check( + 'Tab/Space focuses the authored anchor through camera ownership', + await page.evaluate( + () => + Math.abs( + window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic + .height - 5000, + ) < 1, + ), + ); + await page.screenshot({ path: `${output}/anchor.png` }); + const sourceBase = process.env.QA_SOURCE_BASE || '/src'; + // Resolve ownership from the same module graph as the active application. + await page.evaluate(async (base) => { + window.__inputOwner = await import(`${base}/data/inputOwnership.js`); + window.__lease = window.__inputOwner.claimPointer('qa-draw'); + }, sourceBase); + await page.click(action('card')); + check( + 'drawing ownership refuses actions', + await page.evaluate(() => + document + .querySelector('[data-director-interactions] [role=status]') + .textContent.includes('unavailable'), + ), + ); + await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease)); + // Hold the actual manager admission call to exercise Stop before an enable settles. + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector, + m = d.dataManager; + window.__originalEnable = m.setEnabled; + m.setEnabled = function (id, enabled, options) { + if (id !== 'traffic' || !enabled) + return window.__originalEnable.call(this, id, enabled, options); + window.__actionSignal = options.signal; + return new Promise((resolve) => { + window.__lateAction = () => resolve(true); + options.signal.addEventListener('abort', () => resolve(false), { + once: true, + }); + }); + }; + }); + await page.click(action('layer')); + await page.waitForFunction(() => window.__actionSignal); + await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene()); + check( + 'Stop aborts pending layer admission and removes handlers/cards/selection', + await page.evaluate( + () => + window.__actionSignal.aborted && + !window.__godsEyeView.sceneDirector.getInteractionState().active && + !document.querySelector('[data-director-interactions]'), + ), + ); + await page.evaluate(() => { + window.__lateAction(); + window.__godsEyeView.sceneDirector.dataManager.setEnabled = + window.__originalEnable; + }); + await load(); + await page.evaluate(() => + window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1), + ); + check( + 'same-shot seek restores pack geometry and resets action state', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getInteractionState().count === 4 && + window.__godsEyeView.sceneDirector.getInteractionState().selected === + null && + window.__godsEyeView.sceneDirector.getDataPackState().count === 1, + ), + ); + await page.evaluate(() => { + window.__oldActionButton = document.querySelector( + '[data-director-action=card]', + ); + }); + await page.click(action('next')); + await page.waitForFunction( + () => window.__godsEyeView.sceneDirector._selectedShotId === 'two', + ); + check( + 'explicit shot transition replaces resources and removes actions', + await page.evaluate( + () => + !window.__godsEyeView.sceneDirector.getInteractionState().active && + window.__godsEyeView.sceneDirector.getDataPackState().count === 0, + ), + ); + await load(); + await page.evaluate(() => window.__oldActionButton.click()); + check( + 'detached buttons cannot invoke replacement actions with the same ID', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getInteractionState().selected === + null && + !document.querySelector('[data-director-action-card]').textContent, + ), + ); + await page.evaluate(() => { + window.__godsEyeView.sceneDirector._interactionTransitions = 64; + }); + await page.click(action('next')); + check( + 'bounded transition chain cannot take another branch', + await page.evaluate( + () => window.__godsEyeView.sceneDirector._selectedShotId === 'one', + ), + ); + await load(); + await page.evaluate(async () => { + const p = structuredClone(window.__interactionProject); + p.scenes[0].shots[0].interactions[0].target.featureId = 'missing'; + const d = window.__godsEyeView.sceneDirector; + await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json')); + await d.loadShot('interactive', 'one', { flyDuration: 0.2 }); + }); + check( + 'unknown loaded feature refuses the whole action set', + await page.evaluate( + () => + !window.__godsEyeView.sceneDirector.getInteractionState().active && + !document.querySelector('[data-director-interactions]'), + ), + ); + await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + await d.importProjectFile( + new File([JSON.stringify(window.__interactionProject)], 'restore.json'), + ); + await d.loadShot('interactive', 'one', { flyDuration: 0.2 }); + await d.destroy(); + }); + check( + 'teardown releases interactive resources', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getInteractionState().count === 0 && + !document.querySelector('[data-director-interactions]'), + ), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-director-packs.mjs b/scripts/qa-director-packs.mjs new file mode 100644 index 000000000..9ef68d420 --- /dev/null +++ b/scripts/qa-director-packs.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node +/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const output = path.resolve('qa-shots/director-packs'); +fs.mkdirSync(output, { recursive: true }); +const png = await sharp({ + create: { + width: 64, + height: 64, + channels: 4, + background: { r: 255, g: 80, b: 30, alpha: 0.65 }, + }, +}) + .png() + .toBuffer(); +const wav = Buffer.alloc(44 + 16000); +wav.write('RIFF'); +wav.writeUInt32LE(wav.length - 8, 4); +wav.write('WAVEfmt ', 8); +wav.writeUInt32LE(16, 16); +wav.writeUInt16LE(1, 20); +wav.writeUInt16LE(1, 22); +wav.writeUInt32LE(8000, 24); +wav.writeUInt32LE(16000, 28); +wav.writeUInt16LE(2, 32); +wav.writeUInt16LE(16, 34); +wav.write('data', 36); +wav.writeUInt32LE(16000, 40); +const geojson = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'point', + geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] }, + }, + { + type: 'Feature', + id: 'line', + geometry: { + type: 'LineString', + coordinates: [ + [-97.745, 30.268, 400], + [-97.735, 30.268, 400], + ], + }, + }, + { + type: 'Feature', + id: 'polygon', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-97.746, 30.265, 300], + [-97.74, 30.265, 300], + [-97.74, 30.267, 300], + [-97.746, 30.265, 300], + ], + ], + }, + }, + ], +}; +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = [], + requests = []; +let pending, + failures = 0; +const check = (name, passed) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +}; +page.on('pageerror', (e) => errors.push(e.message)); + +await page.setRequestInterception(true); +page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.startsWith('/scene-assets/')) + return void request.continue(); + requests.push(url.pathname); + if (url.pathname.endsWith('/slow.json')) { + pending = request; + return; + } + const reply = url.pathname.endsWith('/outline.json') + ? { contentType: 'application/geo+json', body: JSON.stringify(geojson) } + : url.pathname.endsWith('/image.png') + ? { contentType: 'image/png', body: png } + : url.pathname.endsWith('/audio.wav') + ? { contentType: 'audio/wav', body: wav } + : { status: 404, body: 'missing' }; + void request.respond(reply).catch(() => {}); +}); +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const manifest = (id, format, file, placement) => ({ + id, + version: 1, + format, + source: { adapter: 'assets', path: `qa/${file}` }, + attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' }, + placement, + }); + const camera = { + lat: 30.2672, + lon: -97.742, + alt: 4000, + heading: 0, + pitch: -90, + roll: 0, + }; + const project = { + version: 5, + scenes: [ + { + id: 'packs', + title: 'Data packs QA', + anchors: [ + { + id: 'media', + lat: 30.269, + lon: -97.737, + alt: 450, + altitudeReference: 'ellipsoid', + }, + ], + dataPacks: [ + manifest('outline', 'geojson', 'outline.json', { + altitudeReference: 'ellipsoid', + }), + manifest('image', 'image', 'image.png', { + bounds: [-97.738, 30.264, -97.732, 30.267], + height: 300, + altitudeReference: 'ellipsoid', + }), + manifest('media', 'media', 'audio.wav', { anchorId: 'media' }), + manifest('slow', 'geojson', 'slow.json', { + altitudeReference: 'ellipsoid', + }), + manifest('missing', 'geojson', 'missing.json', { + altitudeReference: 'ellipsoid', + }), + ], + shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({ + id, + title: id, + camera, + durationSec: 0.3, + holdSec: 0.2, + visual: { style: 'normal', mapStack: 'photoreal' }, + layers: {}, + dataPackIds: + id === 'all' + ? ['outline', 'image', 'media'] + : id === 'empty' + ? [] + : [id], + })), + }, + ], + }; + await d.importProjectFile( + new File([JSON.stringify(project)], 'packs.json'), + ); + window.__packBaseline = d.viewer.entities.values.length; + window.__packPrimitiveBaseline = d.viewer.scene.primitives.length; + window.__packRevoked = []; + const revoke = URL.revokeObjectURL.bind(URL); + URL.revokeObjectURL = (url) => { + window.__packRevoked.push(url); + revoke(url); + }; + }); + check('import does not acquire unselected assets', requests.length === 0); + const loaded = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 }); + window.__packMaterials = Array.from( + { + length: + d.viewer.scene.primitives.length - window.__packPrimitiveBaseline, + }, + (_, i) => + d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i) + .appearance?.material, + ).filter(Boolean); + return { + started: result?.started, + state: d.getDataPackState(), + count: d.viewer.entities.values.length - window.__packBaseline, + primitives: + d.viewer.scene.primitives.length - window.__packPrimitiveBaseline, + cards: document.querySelectorAll('[data-director-pack]').length, + paused: document.querySelector('[data-director-pack] audio')?.paused, + }; + }); + check( + 'GeoJSON, PNG and media load only through registered sources', + loaded.started && loaded.state.count === 3 && requests.length === 3, + ); + check( + 'geometry and image batches plus anchored markers render separately', + loaded.count === 2 && loaded.primitives === 3, + ); + check( + 'attribution is visible and media does not autoplay', + loaded.cards === 3 && loaded.paused === true, + ); + await page.waitForFunction( + () => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal', + { timeout: 15000 }, + ); + await new Promise((r) => setTimeout(r, 5000)); + await page.waitForFunction( + () => window.__godsEyeView.tileset?.tilesLoaded === true, + { timeout: 60000 }, + ); + const evidence = await page.evaluate(() => ({ + mapStack: window.__godsEyeView.mapStackController.getActiveId(), + tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + })); + fs.writeFileSync( + path.join(output, 'report.json'), + JSON.stringify(evidence, null, 2), + ); + check( + 'static geometry is ready before visual acceptance', + await page.evaluate(() => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + return Array.from( + { length: primitives.length - window.__packPrimitiveBaseline }, + (_, i) => primitives.get(window.__packPrimitiveBaseline + i), + ).every((p) => p.ready !== false); + }), + ); + await page.screenshot({ path: path.join(output, 'packs.png') }); + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector; + d._setCameraView({ + ...d.styleManager.getCameraState(), + heading: 30, + pitch: -85, + }); + }); + await page.waitForFunction( + () => window.__godsEyeView.tileset?.tilesLoaded === true, + { timeout: 60000 }, + ); + await new Promise((r) => setTimeout(r, 2000)); + await page.screenshot({ path: path.join(output, 'angle.png') }); + const life = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const media = document.querySelector('[data-director-pack] audio'); + await media.play(); + const played = !media.paused; + await d.loadShot('packs', 'empty', { flyDuration: 0.3 }); + const cleared = + d.getDataPackState().count === 0 && + !document.querySelector('[data-director-packs]') && + d.viewer.entities.values.length === window.__packBaseline && + d.viewer.scene.primitives.length === window.__packPrimitiveBaseline && + media.paused && + !media.getAttribute('src') && + window.__packRevoked.length === 2 && + window.__packMaterials.length === 1 && + window.__packMaterials.every((material) => material.isDestroyed()); + await d.seekScene('packs', 0.1); + const seek = d.getDataPackState().count === 3; + d.stopScene(); + const stopped = + d.getDataPackState().count === 0 && + d.viewer.entities.values.length === window.__packBaseline; + const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 }); + return { + played, + cleared, + seek, + stopped, + missing: missing?.started === false && d.getDataPackState().count === 0, + }; + }); + for (const [name, passed] of Object.entries(life)) check(name, passed); + await page.evaluate(() => { + window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot( + 'packs', + 'slow', + ); + }); + const deadline = Date.now() + 15000; + while (!pending && Date.now() < deadline) + await new Promise((r) => setTimeout(r, 50)); + check('pending transport reached', !!pending); + const cancelled = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const start = performance.now(); + d.stopScene(); + await window.__pendingPack; + return performance.now() - start < 1000 && d.getDataPackState().count === 0; + }); + check('Stop promptly settles a pending transport', cancelled); + if (pending) + await pending + .respond({ + contentType: 'application/json', + body: JSON.stringify(geojson), + }) + .catch(() => {}); + check( + 'late response cannot reintroduce entities', + await page.evaluate( + () => + window.__godsEyeView.viewer.entities.values.length === + window.__packBaseline && + window.__godsEyeView.viewer.scene.primitives.length === + window.__packPrimitiveBaseline, + ), + ); + await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + await d.loadShot('packs', 'all', { flyDuration: 0.3 }); + await d.importProjectFile( + new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'), + ); + }); + check( + 'replacement import clears every pack resource', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getDataPackState().count === 0 && + !document.querySelector('[data-director-packs]'), + ), + ); + check('no uncaught browser errors', errors.length === 0); + if (errors.length) + console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]'))); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/scripts/qa-director-sharing.mjs b/scripts/qa-director-sharing.mjs new file mode 100644 index 000000000..bfc76ed93 --- /dev/null +++ b/scripts/qa-director-sharing.mjs @@ -0,0 +1,482 @@ +#!/usr/bin/env node +/** Sharing/authoring acceptance with local synthetic assets and real installed controls. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const output = path.resolve('qa-shots/director-sharing'); +fs.mkdirSync(output, { recursive: true }); +const geometry = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'point', + geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 500] }, + }, + ], +}; +fs.writeFileSync(path.join(output, 'data.geojson'), JSON.stringify(geometry)); +const camera = { + lat: 30.2672, + lon: -97.742, + alt: 4000, + heading: 0, + pitch: -90, + roll: 0, +}; +const project = { + version: 6, + scenes: [ + { + id: 'share', + title: 'Sharing fixture', + dataPacks: [ + { + id: 'data', + version: 1, + format: 'geojson', + source: { adapter: 'assets', path: 'example/data.geojson' }, + attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' }, + placement: { altitudeReference: 'ellipsoid' }, + }, + ], + shots: [ + { + id: 'shot', + title: 'First', + camera, + durationSec: 0.3, + holdSec: 0, + visual: { style: 'normal', mapStack: 'photoreal' }, + layers: {}, + dataPackIds: ['data'], + interactions: [ + { + id: 'info', + label: 'Read fixture', + target: { packId: 'data', featureId: 'point' }, + action: { type: 'card', text: 'Synthetic scene note' }, + }, + ], + }, + ], + }, + { id: 'second', title: 'Other scene', shots: [] }, + ], +}; +const file = path.join(output, 'project.json'); +fs.writeFileSync(file, JSON.stringify(project)); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +let failures = 0; +const errors = [], + requests = []; +const check = (name, ok) => { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +}; +page.on('pageerror', (e) => errors.push(e.message)); +page.on('request', (r) => { + if (new URL(r.url()).pathname.startsWith('/scene-assets/')) + requests.push(r.url()); +}); +async function click(label) { + const handle = await page.evaluateHandle( + (label) => + [...document.querySelectorAll('button')].find( + (b) => b.textContent === label, + ), + label, + ); + await handle.asElement().click(); + await handle.dispose(); +} +async function value(label, text) { + await page.$eval( + `[aria-label="${label}"]`, + (node, text) => { + node.value = text; + node.dispatchEvent(new Event('input', { bubbles: true })); + }, + text, + ); +} +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.click('[data-collapse-target="scene-panel"]'); + const before = await page.evaluate(() => + JSON.stringify(window.__godsEyeView.sceneDirector._project), + ); + const input = await page.$('#scene-import-file'); + await input.uploadFile(file); + await page.waitForSelector('[data-director-apply-import]'); + check( + 'preview parses metadata without acquiring assets or replacing the current project', + requests.length === 0 && + (await page.evaluate( + (before) => + JSON.stringify(window.__godsEyeView.sceneDirector._project) === + before, + before, + )), + ); + check( + 'preview renders author attribution as literal text', + await page.evaluate( + () => + document + .querySelector('[data-director-dialog]') + .textContent.includes('Synthetic fixture') && + !document.querySelector('[data-director-dialog] b'), + ), + ); + await page.keyboard.press('Escape'); + check( + 'Escape cancels preview without altering saved work', + await page.evaluate( + (before) => + !document.querySelector('[data-director-dialog]') && + JSON.stringify(window.__godsEyeView.sceneDirector._project) === before, + before, + ), + ); + await input.uploadFile(file); + await page.waitForSelector('[data-director-apply-import]'); + await page.click('[data-director-apply-import]'); + await page.waitForFunction( + () => !document.querySelector('[data-director-dialog]'), + ); + check( + 'Apply import replaces metadata but does not start playback or fetch a pack', + requests.length === 0 && + (await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 2 && + !window.__godsEyeView.sceneDirector.running, + )), + ); + await click('EDIT DETAILS'); + await click('Capture camera as anchor'); + await page.waitForFunction(() => + document + .querySelector('[data-director-dialog] [role=status]') + .textContent.includes('Anchor added'), + ); + await click('Set move start to current camera'); + await page.waitForFunction(() => + document + .querySelector('[data-director-dialog] [role=status]') + .textContent.includes('Move added'), + ); + const draft = await page.$eval( + '[aria-label="Shot camera, timing, packs and actions"]', + (n) => n.value, + ); + const bad = JSON.parse(draft); + bad.camera.lat = 91; + await value('Shot camera, timing, packs and actions', JSON.stringify(bad)); + const saved = await page.evaluate(() => + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + await page.click('[data-director-apply-details]'); + await page.waitForFunction(() => + document + .querySelector('[data-director-dialog] [role=status]') + .textContent.includes('camera.lat'), + ); + check( + 'invalid draft preserves saved bytes and existing authored content', + await page.evaluate( + (saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved, + saved, + ), + ); + await value('Shot camera, timing, packs and actions', draft); + await page.click('[data-director-apply-details]'); + await page.waitForFunction( + () => !document.querySelector('[data-director-dialog]'), + ); + check( + 'author controls preserve selected shot, pack attribution and action definitions', + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector, + s = d._getSelectedScene(); + return ( + d._selectedShotId === 'shot' && + s.anchors.length === 1 && + s.shots[0].move.easing === 'cubic-in-out' && + s.shots[0].interactions.length === 1 && + s.dataPacks[0].attribution.license === 'CC0-1.0' + ); + }), + ); + await page.evaluate(() => { + window.__shareExports = []; + const create = URL.createObjectURL.bind(URL); + URL.createObjectURL = (blob) => { + if (blob.type === 'application/json') + window.__shareExports.push(blob.text()); + return create(blob); + }; + }); + await click('SHARE SCENE'); + await click('Download scene JSON'); + const exported = await page.evaluate(async () => + JSON.parse(await window.__shareExports.at(-1)), + ); + check( + 'scene JSON exports only the selected scene and keeps its credit', + exported.scenes.length === 1 && + exported.scenes[0].dataPacks[0].attribution.license === 'CC0-1.0', + ); + await click('Download asset bundle'); + await page.waitForFunction(() => + document + .querySelector('[data-director-dialog] [role=status]') + .textContent.includes('Could not complete'), + ); + check( + 'missing explicit files produce no partial download or network fallback', + requests.length === 0 && + (await page.evaluate(() => window.__shareExports.length === 1)), + ); + const files = await page.$('[aria-label="Choose data-pack files"]'); + await files.uploadFile(path.join(output, 'data.geojson')); + await click('Download asset bundle'); + await page.waitForFunction( + () => + document.querySelector('[data-director-dialog] [role=status]') + .textContent === 'Asset bundle downloaded', + ); + const bundle = await page.evaluate( + async () => await window.__shareExports.at(-1), + ); + const bundleFile = path.join(output, 'scene.gevbundle.json'); + fs.writeFileSync(bundleFile, bundle); + check( + 'bundle contains the selected bytes and preserves attribution', + JSON.parse(bundle).assets.length === 1 && + JSON.parse(bundle).project.scenes[0].dataPacks[0].attribution.license === + 'CC0-1.0', + ); + await page.keyboard.press('Escape'); + await input.uploadFile(bundleFile); + await page.waitForSelector('[data-director-apply-import]'); + check( + 'bundle preview verifies bytes and explains session-only storage', + await page.evaluate( + () => + document + .querySelector('[data-director-dialog]') + .textContent.includes('bundled bytes verified') && + window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0, + ), + ); + await page.screenshot({ path: path.join(output, 'preview.png') }); + await page.click('[data-director-apply-import]'); + await page.waitForFunction( + () => !document.querySelector('[data-director-dialog]'), + ); + await page.evaluate(() => + window.__godsEyeView.sceneDirector.loadShot('share', 'shot', { + flyDuration: 0.3, + }), + ); + check( + 'imported bundle renders and enables feature actions with zero asset requests', + requests.length === 0 && + (await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getDataPackState().count === 1 && + window.__godsEyeView.sceneDirector.getInteractionState().count === 1, + )), + ); + await page.waitForFunction( + () => window.__godsEyeView.tileset?.tilesLoaded === true, + { timeout: 60000 }, + ); + await page.screenshot({ path: path.join(output, 'rendered.png') }); + fs.writeFileSync( + path.join(output, 'report.json'), + JSON.stringify( + await page.evaluate(() => ({ + tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + })), + ), + ); + await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene()); + check( + 'Stop releases rendered resources while retaining reusable project bytes', + await page.evaluate(() => { + const d = window.__godsEyeView.sceneDirector; + return ( + d.getDataPackState().count === 0 && + d.getSharingState().assets.count === 1 + ); + }), + ); + await page.evaluate(() => + window.__godsEyeView.sceneDirector.seekScene('share', 0), + ); + check( + 'seek reloads the bundle without a network fallback', + requests.length === 0 && + (await page.evaluate( + () => window.__godsEyeView.sceneDirector.getDataPackState().count === 1, + )), + ); + await click('EDIT DETAILS'); + await page.evaluate(() => { + window.__godsEyeView.sceneDirector._project.scenes[0].title = 'Newer edit'; + }); + await page.click('[data-director-apply-details]'); + check( + 'stale author draft cannot overwrite a newer edit', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes[0].title === + 'Newer edit', + ), + ); + await page.keyboard.press('Escape'); + await click('SHARE SCENE'); + await page.setViewport({ width: 390, height: 844 }); + check( + 'modal remains within a narrow viewport', + await page.$eval('[data-director-dialog]', (n) => { + const r = n.getBoundingClientRect(); + return ( + r.left >= 0 && + r.right <= innerWidth && + r.top >= 0 && + r.bottom <= innerHeight + ); + }), + ); + await page.screenshot({ path: path.join(output, 'narrow.png') }); + await page.keyboard.press('Escape'); + const pending = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + let finish; + const work = d._sharing.preview({ + name: 'pending.json', + text: () => new Promise((r) => (finish = r)), + }); + d._sharing.close(); + await work; + finish(JSON.stringify({ version: 6, scenes: [] })); + return !d.getSharingState().open; + }); + check( + 'cancel settles a pending file read and suppresses its late completion', + pending, + ); + const cancelledApply = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const before = JSON.stringify(d._project); + const saved = JSON.stringify({ ...localStorage }); + let finish; + const pendingWork = new Promise((resolve) => (finish = resolve)); + d._pendingWork.add(pendingWork); + const owner = new AbortController(); + const work = d.importProjectFile( + { name: 'cancelled.json' }, + { + prepared: { project: { version: 6, scenes: [] }, assets: new Map() }, + expectedProject: JSON.stringify(d._project, null, 2), + signal: owner.signal, + }, + ); + owner.abort(); + finish(); + const applied = await work; + d._pendingWork.delete(pendingWork); + return ( + applied === false && + JSON.stringify(d._project) === before && + JSON.stringify({ ...localStorage }) === saved && + d.getSharingState().assets.count === 1 + ); + }); + check( + 'cancelling Apply during pending scene cleanup preserves saved project and bundle bytes', + cancelledApply, + ); + await click('EDIT DETAILS'); + await page.evaluate(() => { + const fields = [ + ...document.querySelectorAll('[data-director-dialog] textarea'), + ]; + const scene = JSON.parse(fields[0].value), + shot = JSON.parse(fields[1].value); + scene.dataPacks = []; + shot.dataPackIds = []; + shot.interactions = []; + fields[0].value = JSON.stringify(scene); + fields[1].value = JSON.stringify(shot); + }); + await page.click('[data-director-apply-details]'); + await page.waitForFunction( + () => !document.querySelector('[data-director-dialog]'), + ); + check( + 'removing a pack through authoring releases its retained bytes', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0, + ), + ); + await page.evaluate( + (text) => + window.__godsEyeView.sceneDirector.importProjectFile( + new File([text], 'again.gevbundle.json'), + ), + bundle, + ); + await page.evaluate(() => + window.__godsEyeView.sceneDirector.importProjectFile( + new File([JSON.stringify({ version: 6, scenes: [] })], 'empty.json'), + ), + ); + check( + 'replacement releases stored bundle bytes', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0, + ), + ); + await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + void d._sharing.preview({ + name: 'pending.json', + text: () => new Promise(() => {}), + }); + await d.destroy(); + }); + check( + 'teardown removes pending dialogs and authoring controls', + await page.evaluate( + () => + !document.querySelector('[data-director-dialog]') && + !document.querySelector('[data-director-authoring]') && + !window.__godsEyeView.sceneDirector.getSharingState().open, + ), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs new file mode 100644 index 000000000..1361a8dcb --- /dev/null +++ b/scripts/qa-director-timing.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +const shots = path.resolve('qa-shots/director-timing'); +fs.mkdirSync(shots, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +} +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const director = window.__godsEyeView.sceneDirector; + const camera = director.styleManager.getCameraState(); + const visual = director.styleManager.getVisualState(); + const scene = { + id: 'timing-qa', + title: 'Timing QA', + shots: [0, 1].map((index) => ({ + id: `timing-${index}`, + title: `Timing ${index}`, + durationSec: 0.4, + holdSec: 0.3, + camera: { ...camera, lon: camera.lon + index * 0.002 }, + visual, + layers: {}, + })), + }; + director._project.scenes.push(scene); + const load = await director.loadShot(scene.id, scene.shots[0].id, { + flyDuration: 0.3, + }); + const forward = await director.seekScene(scene.id, 0.9); + const forwardSnapshot = director.getPlaybackTimingState().snapshot; + const backward = await director.seekScene(scene.id, 0.1); + const backwardSnapshot = director.getPlaybackTimingState().snapshot; + const replay = await director.replayShot(scene.id, scene.shots[1].id); + director.stopScene(); + await director.startScene(scene.id, { single: true, preview: false }); + const completed = + !director.running && director.getPlaybackTimingState().activeTimers === 0; + scene.shots[0].holdSec = 60; + const run = director.startScene(scene.id, { single: true, preview: false }); + const deadline = Date.now() + 10000; + while ( + director.getPlaybackTimingState().activeTimers < 3 && + Date.now() < deadline + ) + await new Promise((resolve) => setTimeout(resolve, 25)); + const holding = director.getPlaybackTimingState().activeTimers >= 3; + const stoppedAt = performance.now(); + director.stopScene('QA Stop during hold'); + await run; + const stoppedPromptly = performance.now() - stoppedAt < 1000; + const noTimers = director.getPlaybackTimingState().activeTimers === 0; + director._project.scenes = director._project.scenes.filter( + ({ id }) => id !== scene.id, + ); + return { + loaded: load?.started, + forward: forward && forwardSnapshot.shotId === 'timing-1', + backward: backward && backwardSnapshot.shotId === 'timing-0', + replay: replay?.started, + completed, + holding, + stoppedPromptly, + noTimers, + }; + }); + for (const [name, passed] of Object.entries(result)) check(name, passed); + const content = await page.evaluate(async () => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes.find(({ title }) => + /Nepal Flood Incident/i.test(title), + ); + if (!scene) return { exists: false }; + const shot = + scene.shots.find(({ title }) => /Regional Approach/i.test(title)) || + scene.shots[0]; + const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 }); + return { + exists: true, + shots: scene.shots.length, + loaded: loaded?.started === true, + }; + }); + check( + 'existing Nepal scene and authored shots load', + content.exists && content.shots >= 25 && content.loaded, + ); + await new Promise((resolve) => setTimeout(resolve, 2500)); + await page.screenshot({ path: path.join(shots, 'nepal-regional.png') }); + await page.evaluate(() => { + const g = window.__godsEyeView; + g.sceneDirector.stopScene(); + const pose = g.styleManager.getCameraState(); + g.sceneDirector._setCameraView({ + ...pose, + heading: pose.heading + 45, + pitch: -55, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page.screenshot({ path: path.join(shots, 'nepal-angle.png') }); + check( + 'no pending clocks after Stop', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector.getPlaybackTimingState() + .activeTimers === 0, + ), + ); + check('no browser exceptions', errors.length === 0); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/scripts/qa-nepal-media-playback.mjs b/scripts/qa-nepal-media-playback.mjs new file mode 100644 index 000000000..87dc5d64f --- /dev/null +++ b/scripts/qa-nepal-media-playback.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture, +// not native-host acceptance; the YouTube SDK clock below is deterministic. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const output = path.resolve( + process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media', +); +fs.mkdirSync(output, { recursive: true }); +const browser = await puppeteer.launch({ + headless: process.env.QA_HEADFUL !== '1', + args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [], +}); +let assertions = 0; +const check = (name, value) => { + assert.ok(value, name); + assertions++; + console.log(`[PASS] ${name}`); +}; +try { + for (const pinokio of [true, false]) { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + if (pinokio) + await page.setUserAgent((await browser.userAgent()) + ' Pinokio/8.0.40'); + const popups = []; + page.on('popup', (popup) => popups.push(popup.url())); + // Do not depend on provider availability to prove our startup/trim/exit handoff. + await page.setRequestInterception(true); + page.on('request', (request) => { + if (/youtube(?:-nocookie)?\.com\/embed\//.test(request.url())) { + void request.respond({ + status: 200, + contentType: 'text/html', + body: '

Player clock fixture

', + }); + } else void request.continue(); + }); + await page.evaluateOnNewDocument(() => { + window.__mediaFixture = { starts: [], stops: [] }; + window.YT = { + Player: class { + constructor(_frame, { events }) { + this.events = events; + this.began = null; + this.stoppedAt = null; + this.timer = setTimeout( + () => events.onReady({ target: this }), + 1200, + ); + } + mute() {} + playVideo() { + if (this.began != null) return; + this.began = Date.now(); + window.__mediaFixture.starts.push(this.began); + this.events.onStateChange({ target: this, data: 1 }); + } + getCurrentTime() { + return this.began == null + ? 0 + : ((this.stoppedAt || Date.now()) - this.began) / 1000; + } + getPlayerState() { + return this.began == null ? -1 : this.stoppedAt ? 2 : 1; + } + pauseVideo() { + if (this.began != null && !this.stoppedAt) { + this.stoppedAt = Date.now(); + window.__mediaFixture.stops.push(this.getCurrentTime()); + } + } + destroy() { + clearTimeout(this.timer); + this.pauseVideo(); + } + }, + }; + }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.querySelector('#loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async (pinokio) => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes.find((s) => /Nepal/.test(s.title)); + const shot = scene.shots.find((s) => /Mailung Bazzar/.test(s.title)); + const following = scene.shots[scene.shots.indexOf(shot) + 1]; + const previous = scene.shots[scene.shots.indexOf(shot) - 1]; + const frames = () => + [...document.querySelectorAll('iframe')].map((f) => f.src); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 }); + const openingFrames = frames(); + await d.loadShot(scene.id, shot.id, { flyDuration: 0.1 }); + const passiveFrames = frames(); + const started = Date.now(); + const run = d.startScene(scene.id, { + single: true, + afterShotId: previous.id, + preview: false, + }); + let advanced = null; + while (Date.now() - started < 25000) { + if (d._selectedShotId === following.id) { + advanced = Date.now() - started; + break; + } + if (!d.running) break; + await sleep(100); + } + const runningAtAdvance = d.running; + const clipStops = [...window.__mediaFixture.stops]; + const status = document.querySelector('#scene-status')?.textContent; + d.stopScene(); + await run; + await sleep(250); + const stoppedFrames = frames(); + let replayAutoplay = false; + let replacementSilent = false; + if (!pinokio) { + await d.replayShot(scene.id, shot.id); + replayAutoplay = frames().some((src) => /autoplay=1/.test(src)); + await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 }); + const starts = window.__mediaFixture.starts.length; + await sleep(1600); + replacementSilent = + frames().length === 0 && + starts === window.__mediaFixture.starts.length; + } + const gl = d.viewer.scene.context._gl; + const info = gl.getExtension('WEBGL_debug_renderer_info'); + return { + openingFrames, + passiveFrames, + advanced, + runningAtAdvance, + status, + clipStops, + stoppedFrames, + replayAutoplay, + replacementSilent, + renderer: info + ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER), + }; + }, pinokio); + console.log(JSON.stringify({ pinokioPolicyFixture: pinokio, ...result })); + check( + 'opening shot creates no unrelated provider frames', + result.openingFrames.length === 0, + ); + check( + 'passive LOAD never requests autoplay', + result.passiveFrames.every((src) => !/autoplay=1/.test(src)), + ); + check( + 'actual next-shot identity reached while scene remains running', + result.advanced > 0 && result.runningAtAdvance, + ); + check('no scene timeout', !/timed out|Error:/i.test(result.status || '')); + check('Stop removes provider resources', result.stoppedFrames.length === 0); + check('no automatic external window', popups.length === 0); + if (pinokio) { + check( + 'suppressed providers allocate no iframe', + result.passiveFrames.length === 0, + ); + check( + 'fallback keeps authored dwell', + result.advanced >= 7500 && result.advanced < 18000, + ); + } else { + check( + 'supported clip follows seven-second source clock', + result.clipStops.some((sec) => sec >= 7 && sec < 7.6), + ); + check( + 'explicit Play Shot still requests autoplay', + result.replayAutoplay, + ); + check( + 'replacement disowns pending player startup', + result.replacementSilent, + ); + } + await page.screenshot({ + path: path.join(output, pinokio ? 'pinokio-policy.png' : 'chrome.png'), + }); + await page.close(); + } + console.log(`PASS: ${assertions} assertions`); +} finally { + await browser.close(); +} diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index f5c18dfed..9dae69cdc 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -85,6 +85,9 @@ try { ); promptValue = 'QA shot'; await page.click('.scene-shot-label', { count: 2 }); + await page.waitForSelector('.scene-shot-rename', { visible: true }); + await page.type('.scene-shot-rename', promptValue); + await page.keyboard.press('Enter'); await page.waitForFunction( () => document.querySelector('.scene-shot-label')?.textContent === @@ -151,7 +154,20 @@ try { const projectFile = path.join(shots, 'project.json'); fs.writeFileSync(projectFile, JSON.stringify(fixture)); const input = await page.$('#scene-import-file'); + const beforePreview = await page.evaluate(() => + JSON.stringify(window.__godsEyeView.sceneDirector._project), + ); await input.uploadFile(projectFile); + await page.waitForSelector('[data-director-apply-import]'); + check( + 'Import preview leaves the current project untouched', + await page.evaluate( + (saved) => + JSON.stringify(window.__godsEyeView.sceneDirector._project) === saved, + beforePreview, + ), + ); + await page.click('[data-director-apply-import]'); await page.waitForFunction( () => document.getElementById('scene-status').textContent === @@ -259,8 +275,8 @@ try { await input.uploadFile(badFile); await page.waitForFunction(() => document - .getElementById('scene-status') - .textContent.includes('invalid JSON'), + .querySelector('[data-director-dialog] [role=status]') + ?.textContent.includes('invalid JSON'), ); check( 'Invalid import reports failure and preserves the current project', @@ -271,6 +287,45 @@ 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 + .querySelector('[data-director-dialog] [role=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 + .querySelector('[data-director-dialog] [role=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.keyboard.press('Escape'); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); await page.screenshot({ path: path.join(shots, 'narrow.png') }); diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index 1e71e484e..eb7b59985 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -367,7 +367,7 @@ export async function sampleTransitPixels(page, palette) { const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255; const readLuma = (cx, cy) => { const px = Math.floor(cx * scale); - const py = Math.floor(canvas.height - 1 - cy * scale); + const py = canvas.height - 1 - Math.floor(cy * scale); const buf = new Uint8Array(4); gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); return luma(buf[0], buf[1], buf[2]); @@ -399,46 +399,117 @@ export async function sampleTransitPixels(page, palette) { continue; } } + // Pick nine points from a solid patch of the source silhouette, + // before examining framebuffer brightness. Avoid panels/window gaps. + let corePoints = null, + widestBand = -1; + const cosCore = Math.cos(s.rotation), + sinCore = Math.sin(s.rotation); + // A tram's parallel sides have equally wide bands. Prefer its upper + // body band over the geometric centre when those widths tie. + const offsets = + s.mode === 'tram' + ? [-0.2, 0.2, -0.1, 0.1, 0] + : [0, -0.1, 0.1, -0.2, 0.2]; + for (const offset of offsets) { + const candidates = []; + let solid = true, + bandWidth = Infinity; + // Rank source geometry, never the observed framebuffer brightness. + // A thin tram needs the widest opaque white band, with room for + // the pixel filter on each side of the sampled patch. + for (let dy = -1; dy <= 1; dy++) { + const ry = Math.floor( + ((dy + offset * s.rect.height) / s.rect.height + 0.5) * + s.raster.height, + ); + const middle = Math.floor(s.raster.width / 2); + const white = (x) => + x >= 0 && + x < s.raster.width && + [0, 1, 2, 3].every( + (c) => + s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240, + ); + let left = middle, + right = middle; + while (white(left - 1)) left--; + while (white(right + 1)) right++; + bandWidth = Math.min( + bandWidth, + white(middle) ? right - left + 1 : 0, + ); + } + for (let dx = -1; dx <= 1; dx++) + for (let dy = -1; dy <= 1; dy++) { + const localY = dy + offset * s.rect.height; + const rx = Math.floor( + (dx / s.rect.width + 0.5) * s.raster.width, + ); + const ry = Math.floor( + (localY / s.rect.height + 0.5) * s.raster.height, + ); + const i = (ry * s.raster.width + rx) * 4; + if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240)) + solid = false; + candidates.push( + screenPoint( + s.screenX + dx * cosCore + localY * sinCore, + s.screenY - dx * sinCore + localY * cosCore, + ), + ); + } + if (solid && bandWidth > widestBand) { + corePoints = candidates; + widestBand = bandWidth; + } + } + if (!corePoints) { + skipped.push({ + key: s.key, + reason: 'no solid silhouette interior for nine samples', + }); + continue; + } // Picking is independent of rendered brightness: an overlapping // label or sprite must not masquerade as a dim sensor signature. // Check the whole sampled core, not just its centre pick. let coreOwned = true; if (scene.pick) { const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING); - for (let dx = -1; dx <= 1; dx++) - for (let dy = -1; dy <= 1; dy++) { - let u = (s.x + dx) / width, - v = 1 - (s.y + dy) / height; - if (style === 'surveillance') { - const x = u * 2 - 1, - y = v * 2 - 1, - r2 = x * x + y * y; - const d = - 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075; - u = (x * d + 1) / 2; - v = (y * d + 1) / 2; - } - if (sensor) { - const grid = 1 + (pixelation - 1) * intensity; - u += - ((Math.floor((u * canvas.width) / grid) * grid) / - canvas.width - - u) * - intensity; - v += - ((Math.floor((v * canvas.height) / grid) * grid) / - canvas.height - - v) * - intensity; - } - const picked = scene.pick( - { x: u * width, y: (1 - v) * height }, - 1, - 1, - ); - if (picked?.id !== s.key && picked?.primitive?.id !== s.key) - coreOwned = false; + for (const core of corePoints) { + let u = core.x / width, + v = 1 - core.y / height; + if (style === 'surveillance') { + const x = u * 2 - 1, + y = v * 2 - 1, + r2 = x * x + y * y; + const d = + 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075; + u = (x * d + 1) / 2; + v = (y * d + 1) / 2; } + if (sensor) { + const grid = 1 + (pixelation - 1) * intensity; + u += + ((Math.floor((u * canvas.width) / grid) * grid) / + canvas.width - + u) * + intensity; + v += + ((Math.floor((v * canvas.height) / grid) * grid) / + canvas.height - + v) * + intensity; + } + const picked = scene.pick( + { x: u * width, y: (1 - v) * height }, + 1, + 1, + ); + if (picked?.id !== s.key && picked?.primitive?.id !== s.key) + coreOwned = false; + } if (gl.bindFramebuffer) gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); } @@ -451,9 +522,8 @@ export async function sampleTransitPixels(page, palette) { continue; } let centre = 0; - for (let dx = -1; dx <= 1; dx += 1) - for (let dy = -1; dy <= 1; dy += 1) - centre += readLuma(s.x + dx, s.y + dy) / 9; + for (const core of corePoints) + centre += readLuma(core.x, core.y) / corePoints.length; let ringMin = 1; let ringMax = 0; // Sample the actual rotated raster halo, not a fixed-radius circle diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs new file mode 100644 index 000000000..a16bab9bc --- /dev/null +++ b/scripts/qa-transit-heading.mjs @@ -0,0 +1,156 @@ +import puppeteer from 'puppeteer'; + +/** Prove parked and moving headings in the real scene, without live feeds. */ +export async function runTransitHeadingRegression(base, check) { + const browser = await puppeteer.launch({ + headless: true, + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: ['--no-sandbox', '--disable-background-timer-throttling'], + }); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto( + `${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => window.__godsEyeView?.dataManager?.layers?.has('transit'), + { timeout: 90000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 6000)); + await page.evaluate(async () => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + const layer = app.dataManager.layers.get('transit').module; + layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100); + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const { destroyTrack } = await import('/src/data/contactPlayback.js'); + const { + initializePlayback, + recordFix, + updatePlayback, + attachFloorToPlace, + } = await import('/src/layers/transit/movement.js'); + // The first three are parked: held east, reported east only, unknown. + for (let i = 0; i < 3; i++) { + const entry = state._vehicles.get(`mbta:qa-${i}`); + state._moving.delete(entry); + destroyTrack(entry.track); + initializePlayback(entry, state._historyBudget); + recordFix(entry, { + t: Date.now() - 30000, + lat: entry.record.lat, + lon: entry.record.lon, + }); + attachFloorToPlace(entry, entry.record, 20); + updatePlayback(entry, Date.now(), performance.now()); + entry.courseDeg = i === 0 ? 90 : null; + entry.record.bearing = i === 1 ? 90 : null; + entry.displayPaths.clear(); + parts.rendering.placeSample(entry); + parts.rendering.schedulePlayback(entry); + } + parts.rendering.syncRenderHold(); + window.__headingErrors = []; + app.viewer.scene.renderError.addEventListener((scene, error) => + window.__headingErrors.push(error.message), + ); + }); + const pose = async (heading) => { + await page.evaluate((heading) => { + const camera = window.__godsEyeView.viewer.camera; + const C = camera.positionCartographic.constructor; + camera.setView({ + destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)), + orientation: { heading, pitch: -Math.PI / 2, roll: 0 }, + }); + }, heading); + await new Promise((resolve) => setTimeout(resolve, 700)); + }; + const sample = () => + page.evaluate(async () => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const state = app.dataManager.layers + .get('transit') + .module._transitStateForTest(); + const mul = (m, v) => + [0, 1, 2, 3].map( + (r) => + m[r] * v[0] + + m[r + 4] * v[1] + + m[r + 8] * v[2] + + m[r + 12] * v[3], + ); + const project = (p) => { + const clip = mul( + scene.camera.frustum.projectionMatrix, + mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), + ); + return { + x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2, + y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2, + }; + }; + return [0, 1, 2, 3].map((i) => { + const entry = state._vehicles.get(`mbta:qa-${i}`); + const p = project(entry.marker.position); + return { + rotation: entry.marker.rotation, + course: entry.courseDeg, + moving: state._moving.has(entry), + visible: entry.marker.show, + x: p.x, + y: p.y, + }; + }); + }); + await pose(0); + const before = await sample(); + await pose(Math.PI / 2); + const after = await sample(); + const deltaDeg = (a, b) => + (Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI; + for (const [i, label] of ['held course', 'reported bearing'].entries()) { + const delta = deltaDeg(before[i].rotation, after[i].rotation); + check( + `parked ${label} reprojects through a 90 degree camera orbit`, + before[i].visible && + after[i].visible && + !before[i].moving && + !after[i].moving && + Math.abs(delta - 90) <= 5, + JSON.stringify({ before: before[i], after: after[i], delta }), + ); + } + check( + 'unknown parked course remains screen-up', + before[2].rotation === 0 && after[2].rotation === 0, + ); + await new Promise((resolve) => setTimeout(resolve, 1200)); + const moved = (await sample())[3]; + const dx = moved.x - after[3].x, + dy = moved.y - after[3].y; + const travelRotation = Math.atan2(-dx, -dy); + const error = Math.abs(deltaDeg(travelRotation, moved.rotation)); + check( + 'moving vehicle still points along its rendered travel after orbit', + moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5, + JSON.stringify({ + pixels: Math.hypot(dx, dy), + error, + after: after[3], + moved, + }), + ); + check( + 'heading regression has no scene render errors', + (await page.evaluate(() => window.__headingErrors)).length === 0, + ); + } finally { + await browser.close(); + } +} diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs new file mode 100644 index 000000000..e5fadda49 --- /dev/null +++ b/scripts/qa-transit-recovery.mjs @@ -0,0 +1,330 @@ +/** Camera-jump regression against an existing server; never starts a server. */ +import puppeteer from 'puppeteer'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; + +const base = process.env.QA_BASE_URL || 'http://localhost:4305'; +const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; +await mkdir(shots, { recursive: true }); +const browser = await puppeteer.launch({ + headless: false, + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto( + `${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`, + ); + await page.waitForFunction(() => + window.__godsEyeView?.dataManager?.layers.has('transit'), + ); + await page.evaluate(async () => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + }); + await page.waitForFunction( + () => { + const scene = window.__godsEyeView.viewer.scene; + return scene.globe.tilesLoaded && scene.globe.show; + }, + { timeout: 60000 }, + ); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return !cover || cover.classList.contains('hidden'); + }, + { timeout: 60000 }, + ); + await page.evaluate(() => { + const app = window.__godsEyeView, + camera = app.viewer.camera; + const C = camera.positionCartographic.constructor, + lat = 30.267 + 90 / 111320; + const floor = + app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0; + camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + lat - 600 / Math.tan(Math.PI / 3) / 111320, + floor + 600, + ), + ), + orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await page.waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 60000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const result = await page.evaluate(async () => { + const app = window.__godsEyeView, + scene = app.viewer.scene, + camera = app.viewer.camera; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const { getRegisteredTransitFeed } = + await import('/src/data/transitFeeds.js'); + const { setRate } = await import('/src/data/contactPlayback.js'); + const C = camera.positionCartographic.constructor; + const feed = { + ...getRegisteredTransitFeed('capmetro-austin'), + id: 'qa-camera-recovery', + }; + const aim = (lat, height = 600) => + camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + lat - 600 / Math.tan(Math.PI / 3) / 111320, + height, + ), + ), + orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, + }); + parts.selection.clearSelection(); + parts.ingestion.abortAllInFlight(); + state._activeFeeds.clear(); + for (const key of state._vehicles.keys()) + parts.ingestion.removeVehicle(key); + // Model an outstanding floor lookup while reports arrive off screen. + // Only acquisition is controlled; selection, visibility, paths and rendering + // use their real owners. Release to the loaded terrain at the camera jump. + const requestHeight = parts.height.requestHeight; + parts.height.requestHeight = (lat, lon) => ({ + cell: `${lat},${lon}`, + height: null, + prior: null, + }); + aim(30.28); + const now = Date.now(); + for (let i = 0; i < 9; i++) { + const timestamp = now - 135000 + i * 15000; + parts.ingestion.applySnapshot( + feed, + { + fetchedAt: timestamp, + vehicles: [ + { + id: 'moving-bus', + routeId: '1', + tripId: 'qa', + lat: 30.267 + (i * 90) / 111320, + lon: -97.7431, + timestamp: timestamp / 1000, + timestampSource: 'vehicle', + bearing: 0, + }, + ], + }, + { stale: false }, + ); + } + const entry = state._vehicles.get(`${feed.id}:moving-bus`); + entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction. + parts.rendering.sampleIdle(entry); + const before = { + shown: entry.marker.show, + heightPending: entry.heightPending, + phase: entry.sample.phase, + fixes: entry.track.count, + }; + clearTimeout(state._floorTimer); + state._floorTimer = null; + const started = performance.now(); + parts.selection.selectVehicle(entry.key); + const preparedAtSelection = !!parts.trails.diagnostics()?.body; + parts.height.requestHeight = requestHeight; + const floor = + scene.globe.getHeight( + C.fromDegrees(entry.sample.lon, entry.sample.lat), + ) || 0; + aim(entry.sample.lat, floor + 600); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + let trail; + while (performance.now() - started < 1400) { + trail = parts.trails.diagnostics(); + if (entry.marker.show && trail?.body?.show && trail.body.ready) break; + scene.requestRender(); + await wait(25); + } + const status = { + before, + preparedAtSelection, + markerShow: entry.marker.show, + body: !!trail?.body, + bodyShow: trail?.body?.show === true, + visibility: layer + ._transitVisibilityForTest() + .find((e) => e.key === entry.key), + recoveryMs: performance.now() - started, + }; + if (!entry.marker.show || !trail?.body?.show || !trail.body.ready) + return { + ...status, + reason: 'marker or body did not recover before pixel deadline', + }; + setRate(entry.track, 0, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + parts.rendering.schedulePlayback(entry); + const V = entry.marker.position.constructor; + const points = entry.trailSegments + .filter((s) => s.toT <= entry.sample.displayT) + .flatMap((s) => s.positions); + const lengths = [0]; + for (let i = 1; i < points.length; i++) + lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); + const canvas = scene.canvas, + gl = scene.context._gl, + scale = canvas.width / canvas.clientWidth; + const mul = (m, v) => + [0, 1, 2, 3].map( + (i) => + m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], + ); + const project = (p) => { + const q = mul( + camera.frustum.projectionMatrix, + mul(camera.viewMatrix, [p.x, p.y, p.z, 1]), + ); + return { + x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, + inFront: q[3] > 0, + }; + }; + const samples = Array.from({ length: 8 }, (_, i) => { + const d = (lengths.at(-1) * (i + 1)) / 9; + let j = 1; + while (j < lengths.length - 1 && lengths[j] < d) j++; + const p = V.lerp( + points[j - 1], + points[j], + (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), + new V(), + ); + const cart = C.fromCartesian(p); + cart.height = scene.globe.getHeight(cart) || 0; + return { + ...project(C.toCartesian(cart)), + radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, + }; + }); + const sprite = { + ...project(entry.marker.position), + radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, + }; + const read = () => + samples.map(({ x, y }) => { + const r = Math.ceil(3 * scale), + px = Math.round(x * scale), + py = Math.round(canvas.height - 1 - y * scale); + if ( + px < r || + py < r || + px + r >= canvas.width || + py + r >= canvas.height + ) + return null; + const data = new Uint8Array((2 * r + 1) ** 2 * 4); + gl.readPixels( + px - r, + py - r, + 2 * r + 1, + 2 * r + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return Array.from(data); + }); + const frame = () => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + remove(); + reject(new Error('No recovery frame')); + }, 500); + const remove = scene.postRender.addEventListener(() => { + remove(); + clearTimeout(timer); + resolve(read()); + }); + scene.requestRender(); + }); + let visible = true; + const remove = scene.preRender.addEventListener(() => { + trail.body.show = visible; + if (trail.headPrimitive) + trail.headPrimitive.show = visible && trail.head.show; + }); + const captures = []; + try { + for (const show of [false, true, false, true]) { + visible = show; + await frame(); + captures.push(await frame()); + } + } finally { + remove(); + parts.trails.update(); + } + const [off, on, offAgain, onAgain] = captures; + return { + ...status, + samples, + sprite, + off, + on, + offAgain, + onAgain, + tilesReady: + scene.globe.tilesLoaded && + parts.trails.diagnostics().body === trail.body, + elapsedMs: performance.now() - started, + attributes: Array.from({ length: 4 }, (_, i) => { + const a = trail.body.getGeometryInstanceAttributes(`${i}:1`); + return { show: Array.from(a.show), color: Array.from(a.color) }; + }), + }; + }); + const pixels = reduceTrailPixels(result.on, result.off, result); + result.present = pixels.present; + result.pixelPass = pixels.pass; + result.pixelDetails = pixels; + result.errors = errors; + await page.screenshot({ path: `${shots}/recovery.png` }); + await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); + for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; + const pass = + result.before?.shown === false && + result.before?.phase === 'playing' && + result.preparedAtSelection && + result.markerShow && + result.body && + result.bodyShow && + result.elapsedMs <= 2000 && + pixels.pass && + !errors.length; + console.log( + `${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`, + ); + console.log(JSON.stringify(result)); + process.exitCode = pass ? 0 : 1; +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 94ec134bb..1ceb45b22 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,12 @@ +import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; + +// Not registered or active: camera discovery and live polls never own this feed. +/** CapMetro metadata under a private QA identity for selected-trail pixels. */ +export const TRAIL_VISIBILITY_FEED = Object.freeze({ + ...getRegisteredTransitFeed('capmetro-austin'), + id: 'qa-trail-capmetro', +}); + import { reportTransitVisibility } from './qa-transit-browser.mjs'; import { writeFile } from 'node:fs/promises'; import { @@ -5,6 +14,7 @@ import { reduceMotion, reduceBackground, reduceSensorContrast, + reduceTrailPixels, } from '../src/layers/transit/qaMetrics.js'; /** CDP includes objects collected during the sample, not just surviving objects. */ @@ -581,3 +591,459 @@ export async function runBostonLive({ JSON.stringify(evidence, null, 2), ); } + +/** Google 3D proof: paired framebuffer reads reject coincidental road colours. */ +export async function runTrailVisibility({ + page, + check, + wait, + shots, + tag, + selectStyle, + altitude = 600, + pitch = 45, +}) { + const name = 'Google 3D selected trail visible at 6 of 8 projected samples'; + await selectStyle(page, 'normal'); + await page.evaluate( + async ({ altitude, pitch }) => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + if (app.mapStackController.isStackAvailable('photoreal')) + await app.mapStackController.setStack('photoreal'); + const camera = app.viewer.camera, + C = camera.positionCartographic.constructor; + camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + 30.26725 - altitude / Math.tan((pitch * Math.PI) / 180) / 111320, + altitude, + ), + ), + orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 }, + }); + }, + { altitude, pitch }, + ); + await wait(8000); + const setup = await page.evaluate( + async ({ altitude, pitch, feed }) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const google = + !!app.tileset && + app.tileset.show !== false && + app.mapStackController.getActiveId() === 'photoreal'; + if (!google) + return { google: false, reason: 'visible Google 3D tileset required' }; + const C = scene.camera.positionCartographic.constructor; + const centreLat = 30.267 + 90 / 111320; + const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat)); + if (Number.isFinite(floor)) + scene.camera.setView({ + destination: C.toCartesian( + C.fromDegrees( + -97.7431, + centreLat - altitude / Math.tan((pitch * Math.PI) / 180) / 111320, + floor + altitude, + ), + ), + orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 }, + }); + parts.selection.clearSelection(); + parts.ingestion.abortAllInFlight(); + state._activeFeeds.clear(); + for (const key of state._vehicles.keys()) + parts.ingestion.removeVehicle(key); + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = null; + const { seek, setRate } = await import('/src/data/contactPlayback.js'); + const now = Date.now(); + // Nine straight street fixes, 30 m apart. Use the production surface path. + for (let i = 0; i < 9; i++) { + const timestamp = (now - 120000 + i * 15000) / 1000; + parts.ingestion.applySnapshot( + feed, + { + fetchedAt: timestamp * 1000, + vehicles: [ + { + id: 'trail-visible', + routeId: '1', + tripId: 'trail-visible', + lat: 30.267 + (i * 30) / 111320, + lon: -97.7431, + timestamp, + timestampSource: 'vehicle', + bearing: 0, + }, + ], + }, + { stale: false }, + ); + } + const entry = state._vehicles.get(`${feed.id}:trail-visible`); + entry.qaFixture = true; + const clocks = { wallNowMs: now, monoNowMs: performance.now() }; + seek(entry.track, now - 45000, clocks); + setRate(entry.track, 1, clocks); + parts.rendering.sampleIdle(entry); + parts.height.anchorFloors(); + parts.rendering.refreshVisibility(); + parts.rendering.maintainPresentation(); + parts.selection.selectVehicle(entry.key); + return { + google, + key: entry.key, + fixes: entry.track.count, + startDisplayT: now - 45000, + startLat: entry.sample.lat, + }; + }, + { altitude, pitch, feed: TRAIL_VISIBILITY_FEED }, + ); + if (!setup.google) { + check(name, false, JSON.stringify(setup), { unexercised: true }); + return; + } + const cost = await page.evaluate(async (key) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const parts = layer._transitPartsForTest(), + entry = layer._transitStateForTest()._vehicles.get(key); + const gl = scene.context._gl, + originals = new Map(), + wrapped = new Map(); + let owner = null, + headBytes = 0, + bodyBytes = 0, + headUploadMax = 0, + bodyUploadMax = 0, + frames = 0; + const headCpu = []; + const wrap = (primitive, kind) => { + if (!primitive || wrapped.has(primitive)) return; + const update = primitive.update; + wrapped.set(primitive, update); + primitive.update = function (frameState) { + const before = performance.now(); + owner = kind; + try { + return update.call(this, frameState); + } finally { + owner = null; + if (kind === 'head') headCpu.push(performance.now() - before); + } + }; + }; + for (const name of ['bufferData', 'bufferSubData']) { + const original = gl[name]; + originals.set(name, original); + gl[name] = function (...args) { + if (owner) { + const value = args[name === 'bufferData' ? 1 : 2], + offset = args[3] || 0, + length = args[4]; + const bytes = + typeof value === 'number' + ? value + : value + ? length !== undefined + ? length * (value.BYTES_PER_ELEMENT || 1) + : value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1) + : 0; + if (owner === 'head') headBytes += bytes; + else bodyBytes += bytes; + } + return original.apply(this, args); + }; + } + const pre = scene.preRender.addEventListener(() => { + headBytes = bodyBytes = 0; + const trail = parts.trails.diagnostics(); + wrap(trail?.body, 'body'); + wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head'); + }); + const post = scene.postRender.addEventListener(() => { + frames++; + headUploadMax = Math.max(headUploadMax, headBytes); + bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); + }); + try { + // Include a full history upload, even if the first selection already rendered. + entry.pathRevision = -1; + parts.trails.prepareEntry(entry); + scene.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 20000)); + headCpu.sort((a, b) => a - b); + return { + frames, + headUploadMax, + bodyUploadMax, + headCpuP95Ms: headCpu[Math.floor(headCpu.length * 0.95)] ?? null, + headCpuMaxMs: headCpu.at(-1) ?? null, + }; + } finally { + pre(); + post(); + for (const [name, original] of originals) gl[name] = original; + for (const [primitive, update] of wrapped) + if (!primitive.isDestroyed()) primitive.update = update; + } + }, setup.key); + const result = await page.evaluate(async (setup) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const entry = state._vehicles.get(setup.key); + const trail = parts.trails.diagnostics(); + if (!entry || !trail) + return { ...setup, present: 0, reason: 'selection disappeared' }; + const { setRate } = await import('/src/data/contactPlayback.js'); + setRate(entry.track, 0, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + const ready = () => + app.tileset?.show !== false && + app.tileset?.tilesLoaded === true && + (scene.globe?.show === false || scene.globe?.tilesLoaded === true) && + trail.body?.ready === true && + (!trail.headPrimitive || trail.headPrimitive.ready === true); + // Require sustained readiness after freezing playback, before projecting + // onto the refined surface. Repeated captures still check pixel stability. + const tilesReady = await new Promise((resolve) => { + const started = performance.now(); + let readySince = null; + const timer = setInterval(() => { + const now = performance.now(); + readySince = ready() ? (readySince ?? now) : null; + scene.requestRender(); + if ( + (readySince !== null && now - readySince >= 500) || + now - started >= 30000 + ) { + clearInterval(timer); + resolve(readySince !== null && now - readySince >= 500); + } + }, 80); + }); + if (!tilesReady) + return { + ...setup, + present: 0, + reason: 'tiles or trail geometry did not become ready', + }; + const canvas = scene.canvas, + gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + const scale = canvas.width / canvas.clientWidth; + const points = entry.trailSegments + .filter((s) => s.toT <= entry.sample.displayT) + .flatMap((s) => s.positions); + if (trail.head?.show) points.push(...trail.head.positions); + if (points.length < 2) + return { ...setup, present: 0, reason: 'no completed path' }; + const C = scene.camera.positionCartographic.constructor; + const V = entry.marker.position.constructor; + const lengths = [0]; + for (let i = 1; i < points.length; i++) + lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); + const mul = (m, v) => + [0, 1, 2, 3].map( + (i) => + m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], + ); + const samples = Array.from({ length: 8 }, (_, i) => { + const d = (lengths.at(-1) * (i + 1)) / 9; + let j = 1; + while (j < lengths.length - 1 && lengths[j] < d) j++; + const p = V.lerp( + points[j - 1], + points[j], + (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), + new V(), + ); + const cart = C.fromCartesian(p); + const height = scene.sampleHeight( + cart, + [entry.marker, trail.body, trail.headPrimitive].filter(Boolean), + ); + if (Number.isFinite(height)) cart.height = height; + const surface = C.toCartesian(cart); + const q = mul( + scene.camera.frustum.projectionMatrix, + mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]), + ); + return { + x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, + inFront: q[3] > 0, + radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, + }; + }); + const marker = entry.marker.position; + const markerClip = mul( + scene.camera.frustum.projectionMatrix, + mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]), + ); + const sprite = { + x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight, + // Include the padded, rotated sprite and its selection bracket. + radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, + }; + const read = () => + samples.map(({ x, y, inFront }) => { + const r = Math.ceil(3 * scale), + px = Math.round(x * scale), + py = Math.round(canvas.height - 1 - y * scale); + if ( + !inFront || + px < r || + py < r || + px + r >= canvas.width || + py + r >= canvas.height + ) + return null; + const data = new Uint8Array((2 * r + 1) ** 2 * 4); + gl.readPixels( + px - r, + py - r, + 2 * r + 1, + 2 * r + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return data; + }); + const frame = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + remove(); + reject(new Error('Trail framebuffer did not render within 5 s')); + }, 5000); + const remove = scene.postRender.addEventListener(() => { + remove(); + clearTimeout(timeout); + resolve(read()); + }); + scene.requestRender(); + }); + const bodyShow = trail.body?.show, + headShow = trail.head?.show, + headPrimitiveShow = trail.headPrimitive?.show, + backingShow = trail.backing?.show; + let showTrail = false; + const applyVisibility = () => { + if (trail.body) trail.body.show = showTrail && bodyShow; + if (trail.headPrimitive) + trail.headPrimitive.show = showTrail && headPrimitiveShow; + if (trail.head) trail.head.show = showTrail && headShow; + if (trail.backing) trail.backing.show = showTrail && backingShow; + }; + // Override the layer's frame update in both states; selection stays fixed. + const removeVisibility = scene.preRender.addEventListener(applyVisibility); + const captures = []; + let captureReady = true; + try { + for (const visible of [false, true, false, true]) { + showTrail = visible; + // Let post-processing settle before each measured framebuffer. + for (let i = 0; i < 3; i++) await frame(); + captures.push(await frame()); + const current = parts.trails.diagnostics(); + captureReady &&= + ready() && + current?.body === trail.body && + current?.headPrimitive === trail.headPrimitive; + } + } finally { + removeVisibility(); + if (trail.body) trail.body.show = bodyShow; + if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow; + if (trail.head) trail.head.show = headShow; + if (trail.backing) trail.backing.show = backingShow; + await frame(); + } + const [off, on, offAgain, onAgain] = captures.map((patches) => + patches.map((p) => (p ? Array.from(p) : null)), + ); + return { + ...setup, + fixes: entry.track.count, + selected: state._selectedKey === setup.key, + pathMetres: lengths.at(-1), + samples, + on, + off, + onAgain, + offAgain, + sprite, + tilesReady: captureReady, + elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, + movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, + body: trail.body?.constructor.name, + headStrategy: trail.headStrategy || 'PolylineCollection', + headRebuilds: trail.headRebuilds ?? null, + }; + }, setup); + const pixels = reduceTrailPixels(result.on, result.off, result); + result.cost = cost; + result.altitude = altitude; + result.pitch = pitch; + result.present = pixels.present; + result.total = pixels.total; + result.samples = result.samples?.map((s, i) => ({ + ...s, + hit: pixels.hits[i], + stable: pixels.stable[i], + eligible: pixels.eligible[i], + })); + await page.screenshot({ + path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, + type: 'jpeg', + quality: 95, + }); + await writeFile( + `${shots}/${tag}-${altitude}m-trail-visible.json`, + JSON.stringify(result, null, 2), + ); + for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; + check( + name, + result.google && + result.selected && + result.fixes >= 5 && + result.pathMetres > 15 && + result.elapsedDisplayMs >= 19000 && + result.movedMetres >= 5 && + pixels.pass, + JSON.stringify(result), + ); + check( + 'selected trail stays within 2 MiB rebuild and 2 KiB/frame head uploads', + cost.frames > 30 && + cost.bodyUploadMax > 0 && + cost.bodyUploadMax <= 2 * 1024 * 1024 && + cost.headUploadMax > 0 && + cost.headUploadMax <= 2048, + JSON.stringify({ altitude, ...cost }), + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitPartsForTest() + .selection.clearSelection(), + ); +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index de09a6222..aac1d8f60 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { runTransitHeadingRegression } from './qa-transit-heading.mjs'; import { boundPageEvaluations, reportTransitVisibility, @@ -16,6 +17,7 @@ import { runFleetBudgets, runBostonMatrix, runBostonLive, + runTrailVisibility, } from './qa-transit-scenes.mjs'; /** * Browser proof for the Transit layer against a running app. @@ -78,6 +80,18 @@ const check = ( failures += 1; } else if (!passed) failures += 1; }; +// A focused deterministic regression can run independently of live-feed QA. +if (process.argv.includes('--heading-only')) { + try { + await runTransitHeadingRegression(BASE, check); + } catch (error) { + check('heading regression completed', false, error.stack || error.message); + } + console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`); + process.exit(failures ? 1 : 0); +} +if (runs('heading')) await runTransitHeadingRegression(BASE, check); + const renderingSource = await readFile( new URL('../src/layers/transit/rendering.js', import.meta.url), 'utf8', @@ -1774,6 +1788,13 @@ try { selectStyle, sampleRendered, }; + if (runs('trail-visible')) + for (const [altitude, pitch] of [ + [300, 55], + [600, 45], + [900, 45], + ]) + await runTrailVisibility({ ...sceneQA, altitude, pitch }); if (runs('budgets')) await runFleetBudgets(sceneQA); if (runs('matrix')) await runBostonMatrix(sceneQA); if (runs('boston-live')) await runBostonLive(sceneQA); diff --git a/scripts/qa-voice-wav.mjs b/scripts/qa-voice-wav.mjs index b75524e7a..f9d7f8ee9 100644 --- a/scripts/qa-voice-wav.mjs +++ b/scripts/qa-voice-wav.mjs @@ -2,7 +2,7 @@ /** * Credentialed AI voice acceptance using a prerecorded Chromium microphone. * - * Run: node scripts/qa-voice-wav.mjs http://localhost:4189 + * Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk] */ import { createHash } from 'node:crypto'; import fs from 'node:fs'; @@ -11,8 +11,13 @@ import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const appUrl = process.argv[2] || 'http://localhost:4189'; -const wavPath = process.argv[3] +const pushToTalk = process.argv.includes('--push-to-talk'); +const positional = process.argv.slice(2).filter(arg => arg !== '--push-to-talk'); +const targetUrl = new URL(positional[0] || 'http://localhost:4189'); +// Keep first-run focus changes outside this voice/keyboard acceptance test. +targetUrl.searchParams.set('welcome', '0'); +const appUrl = targetUrl.href; +const wavPath = positional[1] || path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav'); const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb'; @@ -60,6 +65,7 @@ try { await page.waitForFunction(() => ( window.__godsEyeView?.voiceCommands && document.getElementById('gev-voice-button') + && document.getElementById('loading-screen')?.classList.contains('hidden') ), { timeout: 30_000 }); const readState = () => page.evaluate(() => { @@ -81,7 +87,26 @@ try { }); const initial = await readState(); - await page.evaluate(() => document.getElementById('gev-voice-button').click()); + let pushToTalkStarted = false; + let microphoneReleased = false; + if (pushToTalk) { + await page.bringToFront(); + await page.focus('.cesium-widget canvas'); + await page.keyboard.down('Space'); + await page.waitForFunction(() => document.querySelector( + '[data-push-to-talk="held"][data-microphone="active"]', + ), { timeout: 30_000 }); + pushToTalkStarted = true; + // The hash-pinned fixture is 9.2 seconds long and plays once from mic acquisition. + await new Promise(resolve => setTimeout(resolve, 10_000)); + await page.keyboard.up('Space'); + microphoneReleased = await page.evaluate(() => { + const status = window.__godsEyeView.voiceCommands.status; + return status === 'idle' || Boolean(document.querySelector('[data-microphone="muted"]')); + }); + } else { + await page.click('#gev-voice-button'); + } const timeline = [initial]; let lastSignature = JSON.stringify(initial); @@ -113,10 +138,14 @@ try { } const result = { - ok: finalState.voiceStatus === 'idle' + ok: (!pushToTalk || (pushToTalkStarted && microphoneReleased)) + && finalState.voiceStatus === 'idle' && finalState.radioAudioState === 'playing' && finalState.radioVoiceDucked === false && Number(finalState.cameraHeightM) >= 10_000_000, + mode: pushToTalk ? 'push-to-talk' : 'click', + pushToTalkStarted, + microphoneReleased, fixture: wavPath, fixtureSha256, appUrl, diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 51b78be7d..954369030 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -8,6 +8,8 @@ import { projectRoot } from './project-root.mjs'; import { selectMapStartupRoute } from '../src/mapStartup.js'; const ROOT = projectRoot(import.meta.url); +const OPENSKY_AUTH_MODE_DEFAULT = 'oauth'; +const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']); export const CREDENTIALS = Object.freeze([ { name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] }, @@ -101,6 +103,21 @@ export function readDoctorDotenvValue( return String(values[key] ?? ''); } +/** Resolve the OpenSky auth mode using the same environment and dotenv precedence as the launcher. */ +export function resolveOpenSkyAuthMode({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefinesMode = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_AUTH_MODE'); + const environmentMode = String(environment.OPENSKY_AUTH_MODE || '').trim(); + const dotenvMode = authoritativeEnvironment && environmentDefinesMode + ? '' + : readDoctorDotenvValue('OPENSKY_AUTH_MODE', rootDir); + const mode = (environmentMode || dotenvMode).trim().toLowerCase(); + return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -128,8 +145,25 @@ export function resolveCredential(spec, { return { configured: false, source: null }; } -export function buildCapabilitySummary(credentials) { +export function buildCapabilitySummary( + credentials, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, +) { const configured = (name) => credentials[name]?.configured === true; + const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); + const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) + ? requestedOpenSkyMode + : OPENSKY_AUTH_MODE_DEFAULT; + const flights = openSkyMode === 'anon' + ? 'OpenSky keyless anonymous access (rate-limited)' + : openSkyMode === 'basic' + ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + : openSkyMode === 'auto' + ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '', @@ -140,9 +174,7 @@ export function buildCapabilitySummary(credentials) { : route === 'google-ion' ? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available' : 'Esri World Imagery (keyless satellite basemap) with keyless terrain', - flights: configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET') - ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' - : 'OpenSky OAuth credentials not configured', + flights, voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added', vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added', fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added', @@ -173,7 +205,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment : { available: false, version: null }, dependenciesInstalled, credentials, - capabilities: buildCapabilitySummary(credentials), + capabilities: buildCapabilitySummary(credentials, { + openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + }), }; } diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 5f601532f..85cece004 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -308,11 +308,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } const timedOut = error?.name === 'AbortError' || error?.name === 'TimeoutError'; + // GET /api/cctv/health serializes `message`, and the CCTV panel + // renders it as a status label, so the raw error would leave the + // server by a different door than the sanitized body below and + // land on screen. + // + // The log line drops the text too, unlike the catch at the bottom + // of this file: a media-fetch failure names the camera's upstream + // host, and these lines get pasted into issues. The status codes + // below carry the diagnosis — 504 for a timeout, 502 otherwise. + console.warn('[CCTV Proxy] media fetch failed'); setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', label: source?.provider || 'Configured source', - message: error?.message || 'Media fetch failed', + message: 'Media fetch failed', }); res.writeHead(timedOut ? 504 : 502, { 'Content-Type': 'application/json', diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 54c065412..92d38f2e8 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -78,20 +78,23 @@ async function handleHudSummary(req, res) { res.statusCode = response.ok && summary ? 200 : response.status || 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); + if (!response.ok) + console.warn(`[hud-summary] upstream HTTP ${response.status}`); res.end( JSON.stringify({ summary: summary || null, - error: response.ok - ? null - : data.error?.message || 'OpenAI HUD summary request failed', + // Never relay `data.error.message`: that is OpenAI's own wording, and + // it carries request ids, organization hints and quota phrasing. + error: response.ok ? null : 'OpenAI HUD summary request failed', }), ); - } catch (error) { + } catch { + console.warn('[hud-summary] request failed'); res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end( JSON.stringify({ - error: error?.message || 'OpenAI HUD summary request failed', + error: 'OpenAI HUD summary request failed', }), ); } diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 53c9d60da..b4dfb4b5a 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -132,26 +132,35 @@ function createRealtimeTokenHandler({ }); const body = await response.text(); res.statusCode = response.status; - res.setHeader( - 'Content-Type', - response.headers.get('content-type') || 'application/json', - ); // Which tier/model this secret was actually minted for. The upstream - // body is passed through untouched (the client parses it verbatim), so - // these headers are the authoritative echo — including the case where a - // bogus ?tier= was silently downgraded to standard. + // success body is passed through untouched (the client parses it + // verbatim), so these headers are the authoritative echo — including the + // case where a bogus ?tier= was silently downgraded to standard. res.setHeader('X-GEV-Voice-Tier', tier); res.setHeader('X-GEV-Voice-Model', model); if (requestedTier && !isKnownVoiceTier(requestedTier)) { res.setHeader('X-GEV-Voice-Tier-Fallback', '1'); } + if (!response.ok) { + console.warn(`[realtime-token] upstream HTTP ${response.status}`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ error: 'Failed to create Realtime token' })); + return; + } + res.setHeader( + 'Content-Type', + response.headers.get('content-type') || 'application/json', + ); res.end(body); - } catch (error) { + } catch { + // For a network fault this was a resolver message naming the upstream + // host; the client only needs to know the mint failed. + console.warn('[realtime-token] mint failed'); res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end( JSON.stringify({ - error: error?.message || 'Failed to create Realtime token', + error: 'Failed to create Realtime token', }), ); } diff --git a/server/providers/traffic.js b/server/providers/traffic.js index fda118753..2a3c2e60a 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -23,9 +23,16 @@ import { * Budget governor (mirrors the OpenSky credit-governor philosophy — last-good * data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json, * keyed by UTC date, reset on day change) counts upstream fetch attempts - * against a soft cap (TOMTOM_DAILY_TILE_BUDGET, default 40,000 of the free - * tier's ~50k/day). Over the cap the proxy serves stale tiles when available, - * else 429 {error:'budget'}. + * against a soft cap (TOMTOM_DAILY_TILE_BUDGET). Over the cap the proxy + * serves stale tiles when available, else 429 {error:'budget'}. + * + * The default is derived from TomTom's published free allowance, which is + * granted MONTHLY, not daily: 200,000 Traffic Flow & Incidents tile requests + * per month (https://docs.tomtom.com/pricing/). The previous 40,000/day + * default — and the "~50k/day" figure this comment used to cite — exhausted + * a month's entire allowance in five days, leaving the traffic layer dead + * for the rest of the billing period. 6,000/day keeps a full 31-day month + * inside the allowance (186,000) with headroom to spare. * * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: * status reports hasKey:false and the tile endpoint 503s {error:'no_key'} @@ -37,7 +44,8 @@ export function tomtomProxy() { const TILE_TTL_MS = 120_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom'); const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json'); - const DEFAULT_DAILY_BUDGET = 40000; + // 200,000/month free tier / 31 days = 6,451. Rounded down for headroom. + const DEFAULT_DAILY_BUDGET = 6000; const MEM_MAX_ENTRIES = 256; const UPSTREAM_TIMEOUT_MS = 15000; diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 4a53464f6..297081b38 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -72,8 +72,8 @@ export function ingestAisStreamEnvelope(envelope) { imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), type: vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), - speed: numberValue(message.Sog ?? message.SOG), - course: numberValue(message.Cog ?? message.COG), + speed: normalizedSpeedOverGround(message.Sog ?? message.SOG), + course: normalizedCourseOverGround(message.Cog ?? message.COG), heading: normalizedHeading(message.TrueHeading ?? message.Heading), last_position_UTC: normalizeAisTimestamp( metadata.time_utc ?? metadata.TimeUtc, @@ -277,6 +277,28 @@ function normalizedHeading(value) { return heading !== null && heading >= 0 && heading <= 360 ? heading : null; } +/** + * Speed over ground in knots, or null when the report carries AIS's "not + * available" code. ITU-R M.1371 encodes SOG in 0.1-knot units where 1022 means + * "102.2 knots or higher" and 1023 means unavailable, so 102.3 is a sentinel + * and not a reading. It is finite, so every consumer would otherwise treat it + * as a genuine speed. + */ +function normalizedSpeedOverGround(value) { + const speed = numberValue(value); + return speed !== null && speed >= 0 && speed < 102.3 ? speed : null; +} + +/** + * Course over ground in degrees, or null when the report carries AIS's "not + * available" code (3600 in 0.1-degree units, i.e. 360). Valid courses stop at + * 359.9; heading keeps its own separate sentinel of 511. + */ +function normalizedCourseOverGround(value) { + const course = numberValue(value); + return course !== null && course >= 0 && course < 360 ? course : null; +} + function normalizeAisTimestamp(value) { const text = stringValue(value); if (!text) return new Date().toISOString(); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 47ffe5658..f22ce2cd4 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -117,9 +117,9 @@ export function createApplicationCatalog({ createApplicationTraffic({ source: sources.traffic }), createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), + createApplicationTransit({ surface, source: sources.transit }), createApplicationBikeshare({ source: sources.bikeshare }), createApplicationDirections(), - createApplicationTransit({ surface, source: sources.transit }), vessels, installations, createApplicationAwareness({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 9de176605..6d1c0224f 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -41,6 +41,11 @@ test('catalogs construct distinct layers and classification from their supplied }); assert.equal(first.layers.length, 21); assert.ok(first.get('transit')); + const order = first.layers.map(({ id }) => id); + assert.deepEqual( + order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1), + ['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'], + ); assert.ok(first.get('bhote-koshi-2026')); assert.ok(first.get('bhote-koshi-locator')); const lifecycle = new LayerLifecycle({}); diff --git a/src/app/tools.js b/src/app/tools.js index 87b51a5f3..27d0cc2d1 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -21,6 +21,7 @@ export function createApplicationTools({ voice = {}, startChrome, onSceneDirector, + sceneDataPacks, signal, defer, }) { @@ -28,6 +29,7 @@ export function createApplicationTools({ const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { + dataPacks: sceneDataPacks, isMapStackAvailable: (id) => mapStackController?.isStackAvailable(id) === true, }); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 4551d830c..8a09810ea 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,3 +1,4 @@ +import { LayerBindings } from './ui/layerBindings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { StyleManager } from './ui/applicationShell.js'; @@ -302,8 +303,8 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () = const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose'); ordered(dispose, [ 'this._disposed = true;', + 'this._layerBindings.stop();', 'this._navigation.destroy();', - 'this._removeWorldRequestFocusListener?.();', 'await this._contextControls.restoreForDisposal();', ], 'dispose invalidation'); assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/); @@ -312,12 +313,16 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () = test('teardown synchronously closes immediate camera entry points', () => { const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose'); ordered(dispose, [ + 'this._disposed = true;', + 'this._layerBindings.stop();', + 'await this._contextControls.restoreForDisposal();', + ], 'synchronous teardown barrier'); + ordered(LayerBindings.prototype.stop.toString(), [ 'this._disposed = true;', 'this._removeCctvRequestFocusListener?.();', 'this._removeWorldRequestFocusListener?.();', 'this._navigationOwnerChangedRemover?.();', - 'await this._contextControls.restoreForDisposal();', - ], 'synchronous teardown barrier'); + ], 'camera bindings close synchronously'); const navigation = body( ui, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 20cb1ecf5..7843946aa 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -408,7 +408,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`); } - assert.match(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/); + assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/); + assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/); const callback = { 1: shellMethod('enterCockpit').toString() }; assert.ok(callback, 'Cockpit onEntered callback is missing'); assert.match( @@ -438,7 +439,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the 'normal Context must not reopen over Cockpit', ); - assert.match(ui, /onExited: \(\) => this\._panelChrome\.exitCockpit\(\)/); + assert.match(ui, /onExited: \(\) => this\.exitPanels\(\)/); + assert.match(ui, /exitPanels: \(\) => this\._panelChrome\.exitCockpit\(\)/); const exitCallback = { 1: shellMethod('exitCockpit').toString() }; assert.ok(exitCallback, 'Cockpit onExited callback is missing'); assert.match( diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs new file mode 100644 index 000000000..6595b795e --- /dev/null +++ b/src/data/aisStreamSentinels.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + aisStreamRows, + ingestAisStreamEnvelope, +} from '../../server/providers/vessels/ais-store.js'; + +// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over +// ground in 0.1-degree units, reserving the top code of each field for "not +// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees. +// Both are finite numbers, so they reach consumers as ordinary readings unless +// the store rejects them here. +const positionReport = (mmsi, body) => ({ + MessageType: 'PositionReport', + Message: { PositionReport: { UserID: Number(mmsi), ...body } }, + MetaData: { + MMSI: mmsi, + latitude: 53.40879, + longitude: 6.19741, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, +}); + +const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi); + +test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => { + assert.equal( + ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })), + true, + ); + assert.equal(rowFor('244060365').speed, null); +}); + +test('course over ground reported as unavailable is unknown, not 360 degrees', () => { + assert.equal( + ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })), + true, + ); + assert.equal(rowFor('245382000').course, null); +}); + +test('genuine speed and course readings survive the unavailable check', () => { + ingestAisStreamEnvelope( + positionReport('341260000', { Sog: 12.9, Cog: 74.6 }), + ); + const row = rowFor('341260000'); + assert.equal(row.speed, 12.9); + assert.equal(row.course, 74.6); +}); + +test('a stopped vessel keeps its zero speed and zero course', () => { + ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 })); + const row = rowFor('244374235'); + assert.equal(row.speed, 0); + assert.equal(row.course, 0); +}); + +test('the fastest encodable reading below the unavailable code is kept', () => { + ingestAisStreamEnvelope( + positionReport('244060366', { Sog: 102.2, Cog: 359.9 }), + ); + const row = rowFor('244060366'); + assert.equal(row.speed, 102.2); + assert.equal(row.course, 359.9); +}); diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index b00f265a8..234d2e095 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -595,8 +595,15 @@ export function createBhoteKoshiEmbeddedMedia({ facebookLoader = loadFacebookSdk, youtubeLoader = loadYouTubeApi, } = {}) { - if (!documentRef?.createElement || !viewer) { + // Pinokio externalizes HTTPS iframe navigation, including hidden preloads. + // Use the existing source-card/local-clip fallback before allocating provider + // resources. Browsers visiting the same Pinokio-launched server keep embeds. + const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test( + globalRef.navigator?.userAgent || '', + ); + if (pinokioShell || !documentRef?.createElement || !viewer) { return { + supportsPlayback: false, warm: () => false, show: () => false, play: () => false, @@ -1078,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({ } return { + supportsPlayback: true, warm, show, getPlaybackState: () => record?.youtubeSession?.getState() || null, diff --git a/src/data/bhoteKoshiEmbeddedMedia.test.mjs b/src/data/bhoteKoshiEmbeddedMedia.test.mjs index 5223fff6c..6829adca5 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.test.mjs +++ b/src/data/bhoteKoshiEmbeddedMedia.test.mjs @@ -10,6 +10,57 @@ import { resolveEmbeddedMediaSource, } from './bhoteKoshiEmbeddedMedia.js'; +test('Pinokio fallback never creates provider DOM or loads SDKs', async () => { + const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); }; + const media = createBhoteKoshiEmbeddedMedia({ + viewer: {}, + documentRef: { createElement: unexpected }, + globalRef: { + navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' }, + setTimeout: unexpected, + requestAnimationFrame: unexpected, + }, + facebookLoader: unexpected, + youtubeLoader: unexpected, + xLoader: unexpected, + }); + for (const sourceUrl of [ + 'https://www.youtube.com/watch?v=abcdefghijk', + 'https://www.facebook.com/watch/?v=123456789', + 'https://x.com/example/status/123456789', + ]) { + const options = { observation: { media: { sourceUrl } }, autoplay: true }; + assert.equal(media.warm(options), false, 'no hidden preload'); + assert.equal(media.show(options), false, 'retain the existing fallback card'); + assert.equal(media.play(), false); + assert.equal(media.pause(), false); + media.hide(); + } + media.destroy(); + media.hide(); + await Promise.resolve(); +}); + +test('ordinary browsers retain real preloads on the same launcher URL', () => { + for (const userAgent of [ + 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36', + 'Mozilla/5.0 Version/18.0 Safari/605.1.15', + 'Mozilla/5.0 Electron/38.0.0', + '', + ]) { + const { media, root } = preloadFixture({ globalRef: { + navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' }, + } }); + assert.equal(media.warm({ observation: { media: { + sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk', + } } }), true); + assert.equal(root.children.length, 1); + media.pause(); + assert.equal(root.children.length, 0); + media.destroy(); + } +}); + function trimmedPlaybackFixture() { let now = 0; let nextId = 0; diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index d53999f21..ce8d4829b 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1000,6 +1000,8 @@ export function createBhoteKoshiEventLayer({ let _sceneMediaPlaybackBeatId = null; let _sceneMediaPlaybackCompletedBeatId = null; let _sceneMediaPlaybackTimer = null; + let _sceneMediaOwner = null; + let _removeSceneMediaAbort = null; let _witnessEmbedActive = false; let _sceneRevealAnimationFrame = null; let _sceneRevealHoldActive = false; @@ -1261,8 +1263,41 @@ export function createBhoteKoshiEventLayer({ return true; } + // Playback authority is supplied by the Director, never restored from params. + function setSceneMediaPlayback(owner = null) { + _removeSceneMediaAbort?.(); + _removeSceneMediaAbort = null; + _sceneMediaOwner = owner; + stopSceneMediaPlayback(); + releaseEvidenceVideo({ restorePoster: false }); + _embeddedMedia?.hide?.({ immediate: true }); + _embeddedEvidenceIndex = -1; + _sceneMediaPlaybackCompletedBeatId = null; + if (owner?.token?.signal) { + const signal = owner.token.signal; + const cancel = () => setSceneMediaPlayback(); + if (signal.aborted) cancel(); + else { + signal.addEventListener('abort', cancel, { once: true }); + _removeSceneMediaAbort = () => + signal.removeEventListener('abort', cancel); + } + } + } + + function sceneMediaPlaybackActive() { + return ( + _sceneMediaOwner != null && + !_sceneMediaOwner.token.cancelled && + !_sceneMediaOwner.token.signal?.aborted && + _sceneMediaOwner.sceneId === _sceneContext?.sceneId && + _sceneMediaOwner.shotId === _sceneContext?.shotId + ); + } + function sceneEvidenceMediaAutoplays() { return ( + sceneMediaPlaybackActive() && _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && _sceneControls.evidenceMediaAutoplay === true && @@ -1333,7 +1368,7 @@ export function createBhoteKoshiEventLayer({ stopSceneMediaPlayback({ pauseMedia: false }); _sceneMediaPlaybackBeatId = beatId; _embeddedMedia?.play?.(); - if (sceneUsesTrimmedMedia(beatId)) { + if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) { const deadline = Date.now() + 18000; const check = () => { if ( @@ -1415,6 +1450,14 @@ export function createBhoteKoshiEventLayer({ !sceneUsesTrimmedMedia(beatId) ) return null; + const media = _evidenceTimeline.find( + (item) => item.observation?.id === beatId, + )?.observation?.media; + // Deliberate provider suppression is a source card, not a player waiting + // to load. Returning no gate selects the Director's authored card dwell. + // A local clip still owns playback; a starting supported provider still waits. + if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath) + return null; return { pending: _sceneMediaPlaybackCompletedBeatId !== beatId || @@ -2103,6 +2146,7 @@ export function createBhoteKoshiEventLayer({ releaseEvidenceVideo(); _embeddedEvidenceIndex = -1; const warmingEvidence = + sceneMediaPlaybackActive() && _presentation === BHOTE_KOSHI_SCENE_PRESENTATION && _sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT; // Keep the same preload across camera frames; stop/disable still cancels it. @@ -3476,6 +3520,7 @@ export function createBhoteKoshiEventLayer({ } async function disable() { + setSceneMediaPlayback(); _enabled = false; ++_comparisonSurfaceGeneration; _mapStackController?.clearTerrainComparison?.(); @@ -3770,6 +3815,7 @@ export function createBhoteKoshiEventLayer({ setParams, getParams, getSceneShotMediaHold, + setSceneMediaPlayback, getStats() { return { count: _enabled ? 1 : 0, diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index bd69cd55e..48183ee01 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -30,6 +30,8 @@ import { } from './bhoteKoshiEvent.js'; import { SCENE_RECIPES } from '../scenes/recipes.js'; import { LayerLifecycle } from './lifecycle.js'; +import { createBhoteKoshiEmbeddedMedia } from './bhoteKoshiEmbeddedMedia.js'; +import { SceneDirector } from '../scenes/director.js'; const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url); const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url); @@ -277,8 +279,9 @@ test('trimmed scene media holds through provider startup, playback and fade, the try { const viewer = eventViewer(); await layer.init(viewer); + layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } }); layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar', - sceneContext: { holdSec: 7.65 }, + sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 }, sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' }); await layer.enable(viewer, { origin: 'scene' }); assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true); @@ -301,6 +304,94 @@ test('trimmed scene media holds through provider startup, playback and fade, the } }); +test('Pinokio source-card fallback selects authored Director dwell, not an impossible player hold', async () => { + const previous = { document: globalThis.document, window: globalThis.window }; + installEventDom(); + globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async url => ({ url }), + terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), + mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + embeddedMediaFactory: options => createBhoteKoshiEmbeddedMedia({ ...options, + globalRef: { navigator: { userAgent: 'Chrome Pinokio/8.0.40' } }, + documentRef: { createElement() { assert.fail('unsupported host must allocate no provider DOM'); } }, + }), + }); + const token = { cancelled: false, signal: new AbortController().signal }; + const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar', + sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 }, + sceneControls: { evidenceMediaAutoplay: true, mediaPlaybackHoldSec: 7, cameraSettled: true } }; + try { + const viewer = eventViewer(); + await layer.init(viewer); + layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token }); + layer.setParams(params, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null); + const waits = []; + await SceneDirector.prototype._holdShot.call({ + _layerStatesForShot: () => ({ [layer.id]: { enabled: true, params } }), + dataManager: { layers: new Map([[layer.id, { module: layer }]]) }, + _effectiveShotHoldSec: () => 7.65, + _sleep: async ms => waits.push(ms), + stopScene: () => assert.fail('a source card must not time out'), + }, { id: 'scene' }, { id: 'shot' }, token); + assert.deepEqual(waits, [7650], 'use the authored dwell instead of completing instantly or polling 20 seconds'); + event.evidenceSpine.find(item => item.id === 'mailung-bazzar').media.videoPath = 'fixture-approved.mp4'; + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true, + 'an approved local clip remains a real playback owner even with the provider suppressed'); + } finally { await layer.destroy(); Object.assign(globalThis, previous); } +}); + +test('saved autoplay controls cannot authorize media; live shot ownership is cancellable and never serialized', async () => { + const previous = { document: globalThis.document, window: globalThis.window }; + installEventDom(); + const timers = new Map(); let timerId = 0; + globalThis.window = { setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) }; + const calls = []; + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async url => ({ url }), + terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), + mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + embeddedMediaFactory: () => ({ supportsPlayback: true, warm: () => true, + show: options => { calls.push(options.autoplay); return true; }, + getPlaybackState: () => null, play() {}, pause() {}, hide() {}, destroy() {} }), + }); + const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar', + sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 }, + sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaPlaybackHoldSec: 7 } }; + try { + const viewer = eventViewer(); await layer.init(viewer); + layer.setParams(params, { origin: 'local-restore' }); await layer.enable(viewer); + assert.ok(calls.length); assert.ok(calls.every(value => value === false)); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null); + const saved = layer.getParams(); + const abort = new AbortController(); + layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false, signal: abort.signal } }); + layer.setParams(params, { origin: 'scene' }); + assert.equal(calls.at(-1), true); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true, + 'a temporarily null supported-provider state is still startup, not unsupported'); + assert.deepEqual(layer.getParams(), saved, 'live playback authority must not enter saved params'); + const late = [...timers.values()]; + abort.abort(); late.forEach(fn => fn()); + assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null); + layer.setParams(params, { origin: 'local-restore' }); + assert.equal(calls.at(-1), false, 'late callbacks and restored flags cannot restart revoked playback'); + layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'different-shot', token: { cancelled: false } }); + layer.setParams(params, { origin: 'scene' }); + assert.equal(calls.at(-1), false, 'a different shot cannot borrow this playback owner'); + } finally { await layer.destroy(); Object.assign(globalThis, previous); } +}); + test('Bhote Koshi timeline helpers clamp untrusted UI values', () => { assert.equal(clampUnit(-1), 0); assert.equal(clampUnit(0.42), 0.42); @@ -1695,7 +1786,9 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting sceneControls: { evidenceMediaAutoplay: true }, beatId: 'immediate-collapse-viewpoint', beatReveal: 0.36, + sceneContext: { sceneId: 'scene', shotId: 'shot' }, }, { origin: 'scene' }); + layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } }); await layer.enable(viewer, { origin: 'scene' }); assert.equal(videos.length, 1); const video = videos[0]; @@ -1874,6 +1967,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from sceneSurface: 'evidence-beat', beatId: 'gyirong-border-gate', beatReveal: 0.36, + sceneContext: { sceneId: 'scene', shotId: 'shot' }, sceneControls: { evidenceSequence: true, evidenceSequenceDurationSec: 0.9, @@ -1886,6 +1980,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from try { await layer.init(viewer); layer.setParams(params, { origin: 'scene' }); + layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } }); await layer.enable(viewer, { origin: 'scene' }); const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate'); const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 450668e48..c67ca8d52 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -5,6 +5,8 @@ import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKos import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import * as Cesium from 'cesium'; import { SceneDirector } from '../scenes/director.js'; +import { createPlaybackClock } from '../director/clock.js'; +import { createDefaultScenePacks } from '../scenes/packs/defaults.js'; import { anchoredCalloutPresentation, BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, @@ -503,11 +505,14 @@ 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, _loadGeneration: 0, _cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel, + _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, }; try { layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION }); diff --git a/src/data/contactTrailRenderer.js b/src/data/contactTrailRenderer.js index ae8712f9d..1f82f3b9c 100644 --- a/src/data/contactTrailRenderer.js +++ b/src/data/contactTrailRenderer.js @@ -21,23 +21,70 @@ export function trailAlpha(ageMs) { /** One static history batch and one frequently updated head. Never entities. */ export function createContactTrailRenderer(scene) { - const heads = scene.primitives.add(new Cesium.PolylineCollection()); - const backing = heads.add({ - positions: [], - width: 4, - show: false, - material: Cesium.Material.fromType('Color', { - color: new Cesium.Color(0.02, 0.03, 0.05, 0.8), - }), - }); - const head = heads.add({ - positions: [], - width: 2, - show: false, - material: Cesium.Material.fromType('Color', { - color: Cesium.Color.WHITE.clone(), - }), - }); + const ground = + !!scene.frameState?.context && + Cesium.GroundPolylinePrimitive.isSupported(scene); + const collection = ground ? scene.groundPrimitives : scene.primitives; + // One 25 m corridor is clipped in material coordinates. Only subdivision + // crossings build geometry; frame updates write a scalar, never vertex buffers. + const headMaterial = (alpha) => + new Cesium.Material({ + fabric: { + type: 'TransitTrailHead', + uniforms: { + color: new Cesium.Color(0.37, 0.94, 0.54, alpha), + fraction: 0, + backing: true, + }, + source: `czm_material czm_getMaterial(czm_materialInput materialInput) { + if (materialInput.st.s > fraction) discard; + czm_material m = czm_getDefaultMaterial(materialInput); + bool edge = abs(materialInput.st.t - 0.5) > 0.3; + m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb; + m.alpha = color.a; + return m; + }`, + }, + translucent: true, + }); + const material = headMaterial(0.8), + depthMaterial = headMaterial(0.55); + depthMaterial.uniforms.backing = false; + const head = { positions: [], show: false, width: 3, material }; + const backing = { positions: [], show: false, width: 5 }; + let headPrimitive = null, + headRebuilds = 0; + function geometry(positions, width, material = false) { + return ground + ? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 }) + : new Cesium.PolylineGeometry({ + positions, + width, + arcType: Cesium.ArcType.NONE, + vertexFormat: material + ? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT + : Cesium.PolylineColorAppearance.VERTEX_FORMAT, + }); + } + function primitive(instances, appearance, depthFailAppearance) { + return ground + ? new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances, + appearance, + classificationType: Cesium.ClassificationType.BOTH, + allowPicking: false, + asynchronous: true, + show: visible, + }) + : new Cesium.Primitive({ + geometryInstances: instances, + appearance, + depthFailAppearance, + allowPicking: false, + asynchronous: false, + show: visible, + }); + } let body = null, segments = [], revision = -1, @@ -68,7 +115,10 @@ export function createContactTrailRenderer(scene) { completed = -1; lastSecond = -1; active = null; - if (body) scene.primitives.remove(body); + if (headPrimitive) collection.remove(headPrimitive); + headPrimitive = null; + head.show = backing.show = false; + if (body) collection.remove(body); body = null; const instances = []; attributeIds.length = 0; @@ -80,18 +130,13 @@ export function createContactTrailRenderer(scene) { instances.push( new Cesium.GeometryInstance({ id: `${i}:${lane}`, - geometry: new Cesium.PolylineGeometry({ - positions: segment.positions, - width: lane ? 2 : 4, - arcType: Cesium.ArcType.NONE, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), + geometry: geometry(segment.positions, lane ? 3 : 5), attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( tint(0.8, !lane), ), depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( - tint(0.8, !lane), + tint(0.55), ), show: new Cesium.ShowGeometryInstanceAttribute(false), }, @@ -100,20 +145,12 @@ export function createContactTrailRenderer(scene) { } } if (instances.length) { - const depthFailAppearance = new Cesium.PolylineColorAppearance({ - translucent: true, - fragmentShaderSource: - 'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }', - }); - body = scene.primitives.add( - new Cesium.Primitive({ - geometryInstances: instances, - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - depthFailAppearance, - allowPicking: false, - asynchronous: false, - show: visible, - }), + body = collection.add( + primitive( + instances, + new Cesium.PolylineColorAppearance({ translucent: true }), + new Cesium.PolylineColorAppearance({ translucent: true }), + ), ); rebuilds++; } @@ -122,6 +159,7 @@ export function createContactTrailRenderer(scene) { if (!sample || !renderedPosition) { head.show = false; backing.show = false; + if (headPrimitive) headPrimitive.show = false; return; } const second = Math.floor(sample.displayT / 1000); @@ -152,8 +190,10 @@ export function createContactTrailRenderer(scene) { attributeIds[i][lane], ); if (!attributes) continue; - attributes.show[0] = i <= done ? 1 : 0; - attributes.show = attributes.show; + // Cesium getters return copies. Write back the array we changed. + const show = attributes.show; + show[0] = i <= done ? 1 : 0; + attributes.show = show; } } completed = done; @@ -166,16 +206,20 @@ export function createContactTrailRenderer(scene) { attributeIds[i][lane], ); if (!attributes) continue; + const rgba = attributes.color; Cesium.ColorGeometryInstanceAttribute.toValue( tint(alpha, !lane), - attributes.color, + rgba, ); - attributes.color = attributes.color; - Cesium.ColorGeometryInstanceAttribute.toValue( - tint(alpha, !lane), - attributes.depthFailColor, - ); - attributes.depthFailColor = attributes.depthFailColor; + attributes.color = rgba; + if (!ground) { + const depthRgba = attributes.depthFailColor; + Cesium.ColorGeometryInstanceAttribute.toValue( + tint(0.55), + depthRgba, + ); + attributes.depthFailColor = depthRgba; + } } } lastSecond = second; @@ -183,16 +227,43 @@ export function createContactTrailRenderer(scene) { const show = visible && !!segment && sample.fraction < 1; head.show = show; backing.show = show; + if (headPrimitive) headPrimitive.show = show; if (!show) return; if (active !== segment) { active = segment; + if (headPrimitive) collection.remove(headPrimitive); + headPrimitive = collection.add( + primitive( + new Cesium.GeometryInstance({ + geometry: geometry(segment.positions, 5, true), + }), + new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + new Cesium.PolylineMaterialAppearance({ + material: depthMaterial, + translucent: true, + }), + ), + ); + headRebuilds++; headPositions[0] = segment.positions[0]; headPositions[1] = endpoint; } Cesium.Cartesian3.clone(renderedPosition, endpoint); head.positions = headPositions; backing.positions = headPositions; - Cesium.Color.clone(tint(0.8), head.material.uniforms.color); + material.uniforms.fraction = Math.max( + 0, + Math.min( + 1, + (sample.displayT - segment.fromT) / (segment.toT - segment.fromT), + ), + ); + depthMaterial.uniforms.fraction = material.uniforms.fraction; + Cesium.Color.clone(tint(0.8), material.uniforms.color); + Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color); } return { replaceHistory, @@ -205,12 +276,15 @@ export function createContactTrailRenderer(scene) { }, setVisible(next) { visible = next; - heads.show = next; + if (headPrimitive) headPrimitive.show = next && head.show; if (body) body.show = next; }, destroy() { - if (body) scene.primitives.remove(body); - scene.primitives.remove(heads); + if (body) collection.remove(body); + if (headPrimitive) collection.remove(headPrimitive); + headPrimitive = null; + material.destroy(); + depthMaterial.destroy(); body = null; segments = []; }, @@ -222,6 +296,12 @@ export function createContactTrailRenderer(scene) { body, head, backing, + headPrimitive, + headStrategy: ground + ? 'draped corridor with material clip' + : 'depth-fail corridor with material clip', + ground, + headRebuilds, entitiesAdded: 0, }; }, diff --git a/src/data/contactTrailRenderer.test.mjs b/src/data/contactTrailRenderer.test.mjs index 04c7cc617..f5204c6ef 100644 --- a/src/data/contactTrailRenderer.test.mjs +++ b/src/data/contactTrailRenderer.test.mjs @@ -1,12 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; +import { readFileSync } from 'node:fs'; +import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js'; import { createContactTrailRenderer, trailAlpha, } from './contactTrailRenderer.js'; -function fixture(t) { +function fixture(t, ground = false) { for (const key of [ 'HTMLCanvasElement', 'HTMLImageElement', @@ -20,9 +22,21 @@ function fixture(t) { else globalThis[key] = prior; }); } + const width = Cesium.ContextLimits._maximumAliasedLineWidth; + Cesium.ContextLimits._maximumAliasedLineWidth = 1; + t.after(() => { + Cesium.ContextLimits._maximumAliasedLineWidth = width; + }); const primitives = new Cesium.PrimitiveCollection(); t.after(() => primitives.destroy()); - return { primitives }; + const groundPrimitives = new Cesium.PrimitiveCollection(); + t.after(() => groundPrimitives.destroy()); + return { + primitives, + groundPrimitives, + frameState: { context: { depthTexture: ground } }, + globe: { show: false }, + }; } test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => { const scene = fixture(t), @@ -62,7 +76,9 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta for (const instance of body.geometryInstances) { assert.deepEqual( instance.attributes.depthFailColor.value, - instance.attributes.color.value, + Cesium.ColorGeometryInstanceAttribute.toValue( + Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55), + ), ); } body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 }; @@ -81,7 +97,18 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta ); // Simulate Cesium's completed upload; exercise public attribute writes. body._ready = true; - body.getGeometryInstanceAttributes = (id) => attributes.get(id); + body.getGeometryInstanceAttributes = (id) => { + const stored = attributes.get(id), + accessor = {}; + for (const name of ['show', 'color', 'depthFailColor']) + Object.defineProperty(accessor, name, { + get: () => stored[name].slice(), + set: (value) => { + stored[name] = value.slice(); + }, + }); + return accessor; + }; const marker = Cesium.Cartesian3.lerp( positions[1], positions[2], @@ -95,10 +122,19 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta [1, 1, 0, 0], ); for (const a of attributes.values()) - assert.deepEqual(a.depthFailColor, a.color); + assert.deepEqual( + a.depthFailColor, + Cesium.ColorGeometryInstanceAttribute.toValue( + Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55), + ), + ); const result = renderer.diagnostics(); assert.equal(result.body, body); assert.equal(result.rebuilds, 1); + assert.equal(result.headRebuilds, 1, '120 frames retain head geometry'); + assert.equal(result.head.material.uniforms.fraction, 0.5); + assert.equal(result.head.width, 3); + assert.equal(result.backing.width, 5); assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker)); assert.equal(result.head.positions.length, 2); renderer.setDisplaySample( @@ -120,24 +156,130 @@ test('trail age curve uses the specified four alpha anchors', () => { assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10); }); -test('the maximum prepared body stays below the 2 MiB geometry budget', () => { - const geometry = Cesium.PolylineGeometry.createGeometry( - new Cesium.PolylineGeometry({ - positions: [ - Cesium.Cartesian3.fromDegrees(-71, 42, 11.5), - Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5), - ], - width: 2, - arcType: Cesium.ArcType.NONE, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), +test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => { + Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse( + readFileSync( + new URL( + import.meta + .resolve('@cesium/engine/Source/Assets/approximateTerrainHeights.json'), + ), + ), ); - const bytes = - Object.values(geometry.attributes).reduce( - (total, attribute) => total + (attribute?.values?.byteLength || 0), + const positions = [ + Cesium.Cartesian3.fromDegrees(-97.7431, 30.267), + Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672), + ]; + const start = performance.now(); + const instances = async (count) => + Promise.all( + Array.from( + { length: count }, + async (_, i) => + new Cesium.GeometryInstance({ + id: i, + geometry: await Cesium.GroundPolylineGeometry.createGeometry( + new Cesium.GroundPolylineGeometry({ + positions, + width: i % 2 ? 3 : 5, + granularity: 0, + }), + ), + }), + ), + ); + const pipelineBytes = async (count) => { + // Match GroundPolylinePrimitive's internal Primitive options and the + // application's morph-capable scene, including batch IDs and encoding. + const result = Cesium.PrimitivePipeline.combineGeometry({ + instances: await instances(count), + projection: new Cesium.GeographicProjection(), + ellipsoid: Cesium.Ellipsoid.WGS84, + modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + elementIndexUintSupported: true, + scene3DOnly: false, + vertexCacheOptimize: false, + compressVertices: false, + createPickOffsets: false, + }); + return result.geometries.reduce( + (sum, geometry) => + sum + + Object.values(geometry.attributes).reduce( + (n, a) => n + (a?.values?.byteLength || 0), + 0, + ) + + geometry.indices.byteLength, 0, - ) + geometry.indices.byteLength; - assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024); + ); + }; + const bytes = await pipelineBytes(1); + const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1); + const bodyBytes = await pipelineBytes(bodyInstances); + assert.equal(bytes, 1576, 'measure final encoded and batched geometry'); + assert.equal(bodyBytes, 2014128); + const instanceReserve = 64 * bodyInstances; + assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024); + assert.ok( + bytes <= 2048, + 'subdivision crossings stay below 2 KiB of head geometry', + ); + console.log( + `trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`, + ); +}); + +test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => { + const scene = fixture(t, true), + renderer = createContactTrailRenderer(scene); + const positions = [ + Cesium.Cartesian3.fromDegrees(-97.7431, 30.267), + Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672), + ]; + renderer.replaceHistory({ + revision: 1, + segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }], + }); + const first = renderer.diagnostics(); + assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive); + assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH); + assert.ok(scene.groundPrimitives.contains(first.body)); + for (const instance of first.body.geometryInstances) + assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry); + for (let i = 1; i <= 120; i++) + renderer.setDisplaySample( + { displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 }, + positions[1], + ); + const result = renderer.diagnostics(); + assert.equal(result.body, first.body); + assert.equal(result.headRebuilds, 1); + assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive); + assert.equal(result.head.material.uniforms.fraction, 0.6); + assert.match(result.head.material.shaderSource, /discard/); + for (const stack of ['Google', 'OSM', 'Bing', 'Google']) { + scene.globe.show = stack !== 'Google'; + renderer.setDisplaySample( + { displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 }, + positions[1], + ); + const retained = renderer.diagnostics(); + assert.equal(retained.body, result.body, `${stack}: retained history`); + assert.equal(retained.headPrimitive, result.headPrimitive); + for (const primitive of [retained.body, retained.headPrimitive]) { + assert.equal( + primitive.classificationType, + Cesium.ClassificationType.BOTH, + stack, + ); + assert.equal(primitive.show, true, stack); + } + } + renderer.setVisible(false); + assert.equal(result.body.show, false); + assert.equal(result.headPrimitive.show, false); + renderer.destroy(); + assert.equal(scene.groundPrimitives.length, 0); + assert.equal(scene.primitives.length, 0); }); test('subdivision crossings only write the changed show range and age colors once per second', (t) => { @@ -206,5 +348,10 @@ test('subdivision crossings only write the changed show range and age colors onc display(1200); assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 }); for (const a of attributes.values()) - assert.deepEqual(a.color, a.depthFailColor); + assert.deepEqual( + a.depthFailColor, + Cesium.ColorGeometryInstanceAttribute.toValue( + Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55), + ), + ); }); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..dde7d3b62 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -42,6 +42,17 @@ export const DATA_CREDITS = [ 'adsb.lol ' + '(ODbL 1.0)', }, + { + key: 'adsbdb', + html: + 'Aircraft type, registration & flight routes: ' + + 'adsbdb ' + + '· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' + + 'Guillaume Michel · route data is the work of ' + + 'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' + + 'copied, published, or incorporated into other databases without the ' + + 'explicit permission of David J Taylor, Edinburgh', + }, { key: 'aisstream', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs new file mode 100644 index 000000000..5172867c4 --- /dev/null +++ b/src/data/dataCredits.test.mjs @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DATA_CREDITS } from './dataCredits.js'; + +test('every credit carries a unique key and some markup to render', () => { + const keys = DATA_CREDITS.map((entry) => entry.key); + assert.equal( + new Set(keys).size, + keys.length, + 'a duplicate key would silently shadow one provider’s credit', + ); + for (const entry of DATA_CREDITS) { + assert.ok(entry.key, 'a credit without a key cannot be registered'); + assert.ok( + entry.html && entry.html.trim().length > 0, + `credit ${entry.key} has nothing to show`, + ); + } +}); + +test('adsbdb is credited and carries its published route-data restriction', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb'); + assert.ok( + credit, + 'adsbdb supplies aircraft type and routes and must be credited', + ); + // adsbdb publishes this restriction for its route data. Pin the provider's + // credits and restriction here so a later edit cannot silently remove them. + assert.match(credit.html, /David Taylor, Edinburgh/); + assert.match(credit.html, /Jim Mason, Glasgow/); + assert.match( + credit.html, + /may not be\s+copied, published, or incorporated into other databases/, + ); + assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/); + assert.match(credit.html, /PlaneBase/); + assert.match(credit.html, /Guillaume Michel/); + assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/); +}); diff --git a/src/data/feedState.js b/src/data/feedState.js index f65bbd9bd..d0e8b187f 100644 --- a/src/data/feedState.js +++ b/src/data/feedState.js @@ -3,7 +3,7 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; /** * Normalize heterogeneous layer stats into one honest control-chip state. * @param {object|null} stats Layer getStats() result. - * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state. + * @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state. */ export function layerFeedState(stats = {}) { const state = stats || {}; @@ -50,5 +50,6 @@ export function layerFeedState(stats = {}) { state.available === false ) return 'degraded'; + if (state.partial === true) return 'partial'; return 'nominal'; } diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 4bd80ff32..ff5142133 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -123,12 +123,51 @@ export function deriveFetchCenter({ return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' }; } +/** + * Wrapped longitude span between two meridians, in degrees. + * + * Longitude is cyclic, so a box crossing the antimeridian (±180°) has + * `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west` + * goes negative in that case; wrap through 360° instead. + * + * @param {number} west - West edge longitude (degrees). + * @param {number} east - East edge longitude (degrees). + * @returns {number} Span in degrees, always >= 0. + */ +function longitudeSpanDeg(west, east) { + const raw = east - west; + return raw >= 0 ? raw : raw + 360; +} + +/** Keep a monotonic longitude range inside the traffic source's legal domain. */ +function boundedLongitudeRange(center, span) { + let west = center - span / 2; + let east = center + span / 2; + if (east > 180) { + west -= east - 180; + east = 180; + } + if (west < -180) { + east += -180 - west; + west = -180; + } + return { west, east }; +} + /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * * Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km) * but centers the box on the derived look-at point instead of the view - * rectangle's midpoint. Idempotent when `center` is the box's own midpoint. + * rectangle's midpoint. At the antimeridian the whole box shifts just far + * enough to remain inside [-180, 180], because the road source accepts one + * monotonic Overpass bounding box. Idempotent when `center` is the box's own + * midpoint. + * + * Longitude is handled as a cyclic axis for the span calculation, so the + * input span wraps correctly across the antimeridian. Output remains + * monotonic because callers such as `getBoundsCenter` and `boundsOverlap` + * rely on `west <= east`. * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). @@ -138,11 +177,14 @@ export function deriveFetchCenter({ */ export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) { const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg); - const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg); + const lonSpan = Math.min( + longitudeSpanDeg(bounds.west, bounds.east), + maxSpanDeg, + ); + const longitude = boundedLongitudeRange(center.lon, lonSpan); return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: center.lon - lonSpan / 2, - east: center.lon + lonSpan / 2, + ...longitude, }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..0ff38628d 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -108,3 +108,19 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re- const twice = clampBoundsAroundCenter(once, midpoint, 0.05); assert.deepEqual(twice, once); }); + +test('span clamp keeps antimeridian bounds monotonic and request-safe', () => { + for (const centerLon of [179.99, -179.99]) { + const result = clampBoundsAroundCenter({ + south: -0.02, + north: 0.02, + west: 179.98, + east: -179.98, + }, { lat: 0, lon: centerLon }, 0.05); + + assert.ok(result.west >= -180 && result.east <= 180); + assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`); + assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12); + assert.ok(result.west <= centerLon && centerLon <= result.east); + } +}); diff --git a/src/data/transitIcons.js b/src/data/transitIcons.js index 6d5d5066f..ec480530d 100644 --- a/src/data/transitIcons.js +++ b/src/data/transitIcons.js @@ -189,20 +189,21 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) { let uri = _iconCache.get(key); if (!uri) { const size = VIEW + 2 * frame.pad; - // Sensor signatures use a broad rounded envelope, without dark interior - // joints or a second outline eating into the hot core at display resolution. - const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47; - const halfH = 48; - const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14; - const capsule = ``; - const body = profile === 'mono' ? capsule : BODIES[k]; + // Reuse the exact outer path. Sensors fill its interior without panel ink. + const silhouette = BODIES[k].match(/` + : BODIES[k]; const halo = frame.units > 0 - ? body.replace( - //, - (_m, d) => - ``, - ) + ? profile === 'mono' + ? `` + : body.replace( + //, + (_m, d) => + ``, + ) : ''; const svg = `` + diff --git a/src/data/transitIcons.test.mjs b/src/data/transitIcons.test.mjs index 17cfb20c4..8f2571017 100644 --- a/src/data/transitIcons.test.mjs +++ b/src/data/transitIcons.test.mjs @@ -32,10 +32,10 @@ test('a haloed glyph draws the dark ring under the shipped white body', () => { assert.notEqual(plain, haloed); assert.match( haloed, - /stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/, + /stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/, ); assert.ok( - haloed.indexOf('#000000') < haloed.indexOf('fill="white"'), + haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'), 'ring first, body on top', ); assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame'); @@ -97,40 +97,144 @@ test('all 36 variants retain the specified final halo at both display sizes', as assert.equal(transitIconCacheSize(), 36); }); -test('sensor bodies cover sixty percent of the padded display with opaque white', async () => { +test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => { const sharp = (await import('sharp')).default; const { presetSpriteScale, presetSpriteOutlinePx } = await import('./transitPresetStyle.js'); + const rasterBody = async (svg) => { + // Compare fills in the same frame; the separately tested external halo is excluded. + const d = + [...svg.matchAll(/`, + ), + ) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const mask = []; + let minX = info.width, + minY = info.height, + maxX = 0, + maxY = 0; + for (let y = 0; y < info.height; y++) + for (let x = 0; x < info.width; x++) { + const alpha = data[(y * info.width + x) * 4 + 3]; + mask.push(alpha); + if (alpha > 127) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) }; + }; for (const selected of [false, true]) + for (const kind of TRANSIT_ICON_KINDS) { + const normal = await rasterBody( + decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })), + ); + for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { + const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); + const mono = await rasterBody(svg); + assert.deepEqual( + mono.mask, + normal.mask, + `${kind}/${style}: no envelope`, + ); + assert.ok( + Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, + `${kind}/${style}: aspect`, + ); + const display = + (selected ? 30 : 20) * presetSpriteScale(style, selected); + const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected); + assert.equal(display, crt); + const frame = haloFrame( + presetSpriteOutlinePx(style, selected), + display, + ); + const crtFrame = haloFrame( + presetSpriteOutlinePx('retro', selected), + crt, + ); + assert.equal( + (display * frame.ratio) / frame.ratio, + (crt * crtFrame.ratio) / crtFrame.ratio, + 'unpadded silhouette matches CRT; only external halo padding grows', + ); + assert.equal( + (svg.match(//); + } + } +}); + +test('sensor raster halo stays dark across its screen-space band on a white roof', async () => { + const sharp = (await import('sharp')).default; + const { presetSpriteScale, presetSpriteOutlinePx } = + await import('./transitPresetStyle.js'); + const { reduceSensorContrast } = + await import('../layers/transit/qaMetrics.js'); + const sides = { + bus: 17, + tram: 10, + subway: 11.5, + rail: 10.5, + ferry: 12.5, + unknown: 10, + }; + for (const selected of [false, true]) { + const pixels = []; for (const kind of TRANSIT_ICON_KINDS) { const display = (selected ? 30 : 20) * presetSpriteScale('thermal', selected); - assert.equal(display, selected ? 36 : 30); - assert.equal(presetSpriteOutlinePx('thermal', selected), 3); + const frame = haloFrame( + presetSpriteOutlinePx('thermal', selected), + display, + ); + const size = Math.round(display * frame.ratio * 8); const svg = decode( transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }), ); - const frame = haloFrame(3, display); - const width = Math.round(display * frame.ratio); const { data, info } = await sharp(Buffer.from(svg)) - .resize(width, width) - .ensureAlpha() + .resize(size, size) + .flatten({ background: '#ffffff' }) .raw() .toBuffer({ resolveWithObject: true }); - let white = 0; - for (let i = 0; i < data.length; i += 4) - if ( - data[i] >= 240 && - data[i + 1] >= 240 && - data[i + 2] >= 240 && - data[i + 3] >= 240 - ) - white++; - assert.ok( - white / (info.width * info.height) >= 0.6, - `${kind}/${display}: white ${white / (info.width * info.height)}`, - ); - const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4; - assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]); + const sample = (offsetPx) => { + const x = Math.floor( + size / 2 + (offsetPx * size) / (display * frame.ratio), + ); + const i = (Math.floor(size / 2) * info.width + x) * info.channels; + return ( + (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255 + ); + }; + // The body stays at CRT size; the ring must cover this fixed external band. + const ringMin = sample((sides[kind] * display) / 96 + 1.25); + pixels.push({ + key: kind, + verified: true, + centre: sample(0), + ringMin, + ringMax: 1 - ringMin, + background: 1, + }); } + const white = reduceSensorContrast(pixels, 'white', 'thermal'); + assert.ok(white.pass, JSON.stringify({ selected, pixels, white })); + const black = reduceSensorContrast( + pixels.map((p) => ({ ...p, centre: 1 - p.centre })), + 'black', + 'thermal', + ); + assert.ok(black.pass, JSON.stringify({ selected, black })); + } }); diff --git a/src/data/transitPresetStyle.js b/src/data/transitPresetStyle.js index 4848924f4..8c79d7c05 100644 --- a/src/data/transitPresetStyle.js +++ b/src/data/transitPresetStyle.js @@ -12,7 +12,7 @@ * Encoding per profile, the traffic-dot precedent applied to a glyph: * - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the * sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow - * ramp, and the brightest phosphor under NVG — drawn larger, with a dark + * ramp, and the brightest phosphor under NVG — at CRT size, with a thin dark * halo sized in SCREEN pixels so it keeps local contrast on a bright road * through any luma mapping. Black-hot FLIR inverts it, deliberately: a * black sprite with a light ring is what a hot object looks like there. @@ -52,8 +52,8 @@ const PROFILE_BY_STYLE = Object.freeze({ const SPRITE_STYLE = Object.freeze({ mono: Object.freeze({ rgba: Object.freeze([255, 255, 255, 1]), - scale: 1.5, - outlinePx: 3, + scale: 1.3, + outlinePx: 2, }), crt: Object.freeze({ scale: 1.3, @@ -111,8 +111,6 @@ export function presetSpriteRgba(styleName, mode) { * @returns {number} 1 under the normal profile. */ export function presetSpriteScale(styleName, selected = false) { - if (transitStyleProfile(styleName) === 'mono') - return selected ? 36 / 30 : 30 / 20; return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1; } diff --git a/src/data/transitPresetStyle.test.mjs b/src/data/transitPresetStyle.test.mjs index db9f413a8..fc8e81323 100644 --- a/src/data/transitPresetStyle.test.mjs +++ b/src/data/transitPresetStyle.test.mjs @@ -45,13 +45,13 @@ test('mono: every mode is a white core, larger, with a dark halo in screen pixel `${style} ${mode}`, ); } - assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`); - // At least a whole screen pixel and a half of ring around a five-pixel - // body: thinner than that vanishes through blur and pixelation. - assert.ok( - presetSpriteOutlinePx(style) >= 1.5, - `${style} halo is ${presetSpriteOutlinePx(style)} px`, - ); + for (const selected of [false, true]) { + assert.equal( + presetSpriteScale(style, selected), + presetSpriteScale('retro', selected), + ); + assert.equal(presetSpriteOutlinePx(style, selected), 2); + } } }); diff --git a/src/director/authoring.js b/src/director/authoring.js new file mode 100644 index 000000000..22a082971 --- /dev/null +++ b/src/director/authoring.js @@ -0,0 +1,54 @@ +import { parseSceneDocument, stringifySceneDocument } from './document.js'; + +/** Build a validated immutable edit while preserving IDs, existing content and provenance. */ +export function editSceneDetails( + project, + sceneId, + shotId, + sceneDetails, + shotDetails, +) { + const copy = parseSceneDocument(stringifySceneDocument(project)); + const scene = copy.scenes.find((s) => s.id === sceneId), + shot = scene?.shots.find((s) => s.id === shotId); + if (!scene || !shot) throw new Error('Select a scene and shot first'); + for (const key of Object.keys(sceneDetails)) + if (!['anchors', 'dataPacks'].includes(key)) + throw new Error('Unsupported scene detail'); + for (const key of Object.keys(shotDetails)) + if ( + ![ + 'camera', + 'move', + 'durationSec', + 'holdSec', + 'dataPackIds', + 'interactions', + ].includes(key) + ) + throw new Error('Unsupported shot detail'); + for (const key of ['anchors', 'dataPacks']) { + delete scene[key]; + if (Object.hasOwn(sceneDetails, key)) scene[key] = sceneDetails[key]; + } + for (const key of [ + 'camera', + 'move', + 'durationSec', + 'holdSec', + 'dataPackIds', + 'interactions', + ]) { + delete shot[key]; + if (Object.hasOwn(shotDetails, key)) shot[key] = shotDetails[key]; + } + return parseSceneDocument(stringifySceneDocument(copy)); +} + +/** Export exactly one authored scene; no live state or service configuration is consulted. */ +export function selectSceneDocument(project, sceneId) { + const copy = parseSceneDocument(stringifySceneDocument(project)); + const scene = copy.scenes.find((s) => s.id === sceneId); + if (!scene) throw new Error('Select a scene first'); + return { ...copy, scenes: [scene] }; +} diff --git a/src/director/camera.js b/src/director/camera.js new file mode 100644 index 000000000..059c7a873 --- /dev/null +++ b/src/director/camera.js @@ -0,0 +1,54 @@ +/** Portable camera poses and authored motion. Angles are degrees; heights are ellipsoidal meters. */ + +/** Resolve an inline pose or a scene-local geographic anchor without modifying authored state. */ +export function resolveCameraPose(scene, camera) { + if (!camera) return null; + const position = camera.anchorId + ? scene?.anchors?.find(({ id }) => id === camera.anchorId) + : camera; + if (!position) throw new Error('Camera anchor is unavailable'); + return { + lat: position.lat, + lon: position.lon, + alt: position.alt, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? -35, + roll: camera.roll ?? 0, + }; +} + +/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */ +export function resolveCameraMove(scene, shot) { + if (!shot?.move) return null; + return { + from: resolveCameraPose(scene, shot.move.from), + to: resolveCameraPose(scene, shot.camera), + easing: shot.move.easing, + durationSec: shot.durationSec, + }; +} + +/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ +export function sampleCameraMove(move, progress) { + const t = Math.max(0, Math.min(1, Number(progress) || 0)); + if (t === 0) return { ...move.from }; + if (t === 1) return { ...move.to }; + const eased = + move.easing === 'linear' + ? t + : t < 0.5 + ? 4 * t ** 3 + : 1 - (-2 * t + 2) ** 3 / 2; + const lerp = (a, b) => a + (b - a) * eased; + const angle = (a, b) => + a + (((((b - a + 540) % 360) + 360) % 360) - 180) * eased; + const { from, to } = move; + return { + lat: lerp(from.lat, to.lat), + lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + alt: lerp(from.alt, to.alt), + heading: angle(from.heading, to.heading), + pitch: lerp(from.pitch, to.pitch), + roll: angle(from.roll, to.roll), + }; +} diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs new file mode 100644 index 000000000..cf7c43bb0 --- /dev/null +++ b/src/director/camera.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSceneDocument, stringifySceneDocument } from './document.js'; +import { normalizeProject } from '../scenes/project.js'; +import { resolveCameraMove, sampleCameraMove } from './camera.js'; +import { sceneSeekState } from './timeline.js'; + +const fixture = () => ({ + version: 4, + scenes: [ + { + id: 'scene', + title: 'Cross the dateline', + anchors: [ + { + id: 'west', + title: 'Start', + lat: 10, + lon: 179, + alt: 400, + altitudeReference: 'ellipsoid', + }, + { + id: 'east', + title: 'End', + lat: 12, + lon: -179, + alt: 800, + altitudeReference: 'ellipsoid', + }, + ], + shots: [ + { + id: 'move', + title: 'Cross', + durationSec: 4, + holdSec: 2, + camera: { anchorId: 'east', heading: 10, pitch: 0, roll: 0 }, + move: { + from: { anchorId: 'west', heading: 350, pitch: -40, roll: 0 }, + easing: 'linear', + }, + }, + ], + }, + ], +}); +test('version 4 retains anchor identity, references and explicit move edits through normalization', () => { + const raw = fixture(); + const project = normalizeProject(parseSceneDocument(JSON.stringify(raw))); + assert.deepEqual(project.scenes[0].anchors, raw.scenes[0].anchors); + assert.deepEqual( + project.scenes[0].shots[0].move, + raw.scenes[0].shots[0].move, + ); + assert.deepEqual( + project.scenes[0].shots[0].camera, + raw.scenes[0].shots[0].camera, + ); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(project))), + project, + ); +}); +test('v3 unversioned bloom already uses scale 2 and is not migrated again in later formats', () => { + const project = normalizeProject( + parseSceneDocument( + JSON.stringify({ + version: 3, + scenes: [ + { shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] }, + ], + }), + ), + ); + assert.equal(project.version, 6); + assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50); +}); +test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => { + const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture()))) + .scenes[0]; + const move = resolveCameraMove(scene, scene.shots[0]); + const midpoint = sampleCameraMove(move, 0.5); + assert.equal(midpoint.lon, -180); + assert.equal(midpoint.heading, 360); + assert.equal(midpoint.lat, 11); + assert.equal(midpoint.alt, 600); + assert.equal(midpoint.pitch, -20); + const seek = (p) => + sceneSeekState( + scene, + p, + (_, shot) => shot.durationSec + shot.holdSec, + (_, shot) => shot.holdSec, + ); + assert.deepEqual(seek(1 / 3).camera, midpoint); + assert.deepEqual(seek(0).camera, move.from); + assert.deepEqual(seek(5 / 6).camera, move.to); + assert.equal(seek(5 / 6).holdElapsedSec, 1); + assert.deepEqual( + seek(1 / 3).camera, + midpoint, + 'backward seek is independent of previous viewport state', + ); + assert.equal( + sampleCameraMove({ ...move, easing: 'cubic-in-out' }, 0.25).lat, + 10.125, + ); + assert.deepEqual(sampleCameraMove(move, 1), move.to); +}); +test('unknown anchors, duplicate IDs, mixed references, bad easing and unspecified altitude are rejected', () => { + for (const mutate of [ + (s) => { + s.shots[0].camera.anchorId = 'missing'; + }, + (s) => { + s.anchors[1].id = 'west'; + }, + (s) => { + s.anchors[0].altitudeReference = 'terrain'; + }, + (s) => { + delete s.anchors[0].altitudeReference; + }, + (s) => { + s.shots[0].camera.lon = 1; + }, + (s) => { + s.shots[0].move.easing = 'bounce'; + }, + (s) => { + s.shots[0].move.from = { lat: 1, lon: 2, alt: 3 }; + }, + (s) => { + s.shots[0].durationSec = 0; + }, + (s) => { + delete s.shots[0].holdSec; + }, + ]) { + const project = fixture(); + mutate(project.scenes[0]); + assert.throws(() => parseSceneDocument(JSON.stringify(project)), { + name: 'SceneDocumentError', + }); + } + const old = fixture(); + old.version = 3; + assert.throws( + () => parseSceneDocument(JSON.stringify(old)), + /unsupported field/, + ); +}); diff --git a/src/director/cameraDocument.js b/src/director/cameraDocument.js new file mode 100644 index 000000000..1579d54a8 --- /dev/null +++ b/src/director/cameraDocument.js @@ -0,0 +1,88 @@ +import { + fields, + string, + number, + optional, + array, + fail, + uniqueId, +} from './documentFields.js'; + +const POSITION = { lat: [-90, 90], lon: [-180, 180], alt: [-12000, 1e9] }; +const ORIENTATION = { + heading: [-360, 360], + pitch: [-90, 90], + roll: [-360, 360], +}; +function coordinates(value, path, bounds, required, legacy) { + for (const [key, range] of Object.entries(bounds)) { + if (required || Object.hasOwn(value, key)) + number(value[key], `${path}.${key}`, ...range, legacy); + } +} +function reference(value, path) { + if (value !== 'ellipsoid') + fail( + path, + 'expected ellipsoid height in meters; terrain-relative heights are not supported', + ); +} +function pose(value, path, version, anchorIds, explicit = false) { + const anchored = version >= 4 && Object.hasOwn(value || {}, 'anchorId'); + fields(value, path, [ + ...Object.keys(ORIENTATION), + ...(anchored + ? ['anchorId'] + : [ + ...Object.keys(POSITION), + ...(version >= 4 ? ['altitudeReference'] : []), + ]), + ]); + coordinates(value, path, ORIENTATION, false, version < 3); + if (anchored) { + string(value.anchorId, `${path}.anchorId`); + if (!anchorIds.has(value.anchorId)) + fail(`${path}.anchorId`, 'unknown scene anchor'); + } else { + coordinates(value, path, POSITION, explicit, version < 3); + if (explicit || Object.hasOwn(value, 'altitudeReference')) + reference(value.altitudeReference, `${path}.altitudeReference`); + } +} + +/** Validate scene-local anchor IDs, camera references and explicit motion without reading terrain or applying state. */ +export function validateSceneCameras(scene, path, version) { + const anchorIds = new Set(); + optional(scene, 'anchors', path, (anchors, field) => { + array(anchors, field, 1024); + anchors.forEach((anchor, i) => { + const at = `${field}[${i}]`; + fields(anchor, at, [ + 'id', + 'title', + ...Object.keys(POSITION), + 'altitudeReference', + ]); + string(anchor.id, `${at}.id`); + uniqueId(anchor, at, anchorIds); + optional(anchor, 'title', at, (v, p) => string(v, p, 4096)); + coordinates(anchor, at, POSITION, true, false); + reference(anchor.altitudeReference, `${at}.altitudeReference`); + }); + }); + scene.shots.forEach((shot, index) => { + const at = `${path}.shots[${index}]`; + optional(shot, 'move', at, (move, field) => { + fields(move, field, ['from', 'easing']); + if (!['linear', 'cubic-in-out'].includes(move.easing)) + fail(`${field}.easing`, 'expected linear or cubic-in-out'); + pose(move.from, `${field}.from`, version, anchorIds, true); + pose(shot.camera, `${at}.camera`, version, anchorIds, true); + number(shot.durationSec, `${at}.durationSec`, 0.2, 86400, false); + number(shot.holdSec, `${at}.holdSec`, 0, 86400, false); + }); + optional(shot, 'camera', at, (camera, field) => + pose(camera, field, version, anchorIds), + ); + }); +} diff --git a/src/director/clock.js b/src/director/clock.js new file mode 100644 index 000000000..fa83047a3 --- /dev/null +++ b/src/director/clock.js @@ -0,0 +1,301 @@ +const clamp01 = (value) => Math.max(0, Math.min(1, value)); + +/** Own playback timers and scene-clock subscriptions independently of renderer and editor state. */ +export function createPlaybackClock(options) { + return new PlaybackClock(options); +} + +class PlaybackClock { + constructor({ + isRunning, + timingForShot, + onProgress, + now = () => Date.now(), + schedule = (callback, ms) => setInterval(callback, ms), + cancel = (timer) => clearInterval(timer), + }) { + this.isRunning = isRunning; + this.timingForShot = timingForShot; + this.onProgress = onProgress; + this.now = now; + this.schedule = schedule; + this.cancel = cancel; + this._progressTimer = null; + this._shotProgressTimer = null; + this._sceneClockTimer = null; + this._sceneClockListeners = new Set(); + this._sceneClockSnapshot = null; + this._destroyed = false; + this._waitGeneration = 0; + this._shotGeneration = 0; + this._sceneGeneration = 0; + this._waiters = new Set(); + } + + get snapshot() { + return this._sceneClockSnapshot ? { ...this._sceneClockSnapshot } : null; + } + get activeTimers() { + return ( + [ + this._progressTimer, + this._shotProgressTimer, + this._sceneClockTimer, + ].filter((timer) => timer !== null).length + this._waiters.size + ); + } + + stopShot() { + this._shotGeneration++; + this.cancel(this._shotProgressTimer); + this._shotProgressTimer = null; + } + stopSceneTimer() { + this._sceneGeneration++; + this.cancel(this._sceneClockTimer); + this._sceneClockTimer = null; + } + finish() { + this.cancel(this._progressTimer); + this._progressTimer = null; + this.stopSceneTimer(); + } + stop() { + this.cancelWaits(); + this.stopShot(); + this.finish(); + if ( + !this._sceneClockSnapshot || + this._sceneClockSnapshot.stopped || + this._destroyed + ) + return; + this._sceneClockSnapshot = { + ...this._sceneClockSnapshot, + running: false, + stopped: true, + }; + for (const listener of this._sceneClockListeners) { + try { + listener(this.snapshot); + } catch { + /* Observers cannot block cancellation. */ + } + } + } + destroy() { + this.cancelWaits(); + this._destroyed = true; + this.stopShot(); + this.finish(); + this._sceneClockListeners.clear(); + this._sceneClockSnapshot = null; + } + + cancelWaits() { + this._waitGeneration++; + for (const finish of [...this._waiters]) finish(); + } + + /** Own cancellable hold delays; Stop and destroy settle pending waiters immediately. */ + async wait(ms, token) { + const generation = this._waitGeneration; + const endAt = this.now() + ms; + const current = () => + !this._destroyed && + generation === this._waitGeneration && + !token.cancelled && + !token.signal?.aborted; + while (current() && this.now() < endAt) { + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + token.signal?.removeEventListener('abort', finish); + this._waiters.delete(finish); + resolve(); + }; + const timer = setTimeout(finish, 70); + this._waiters.add(finish); + token.signal?.addEventListener('abort', finish, { once: true }); + }); + } + } + + subscribe(listener) { + if (this._destroyed || typeof listener !== 'function') return () => {}; + this._sceneClockListeners.add(listener); + if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + return () => this._sceneClockListeners.delete(listener); + } + + publish( + scene, + shot, + sceneElapsedSec, + { running = this.isRunning(), seeking = false } = {}, + ) { + if (this._destroyed || !scene || !shot) return; + const timing = this.timingForShot(scene, shot); + const elapsedSec = Math.max( + 0, + Math.min(timing.totalSec, Number(sceneElapsedSec) || 0), + ); + this._sceneClockSnapshot = { + sceneId: scene.id, + shotId: shot.id, + shotIndex: timing.shotIndex, + shotCount: scene.shots.length, + sceneElapsedSec: elapsedSec, + sceneDurationSec: timing.totalSec, + sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0, + running: Boolean(running), + seeking: Boolean(seeking), + }; + for (const listener of this._sceneClockListeners) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } + } + + startRunProgress(totalSec) { + this.cancel(this._progressTimer); + if (this._destroyed) return; + const startMs = this.now(); + const totalMs = Math.max(1000, totalSec * 1000); + const timer = this.schedule(() => { + if (this._destroyed || this._progressTimer !== timer || !this.isRunning()) + return; + const elapsedMs = this.now() - startMs; + this.onProgress(elapsedMs / totalMs); + }, 100); + this._progressTimer = timer; + } + + startShotProgress(token, seconds, from, to, sceneClock = null) { + this.stopShot(); + const generation = this._shotGeneration; + if ( + generation !== this._shotGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted || + this.isRunning() + ) + return; + this.onProgress(from); + if (generation !== this._shotGeneration || this._destroyed) return; + if (sceneClock) { + this.publish( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedFrom, + { running: false }, + ); + } + if ( + generation !== this._shotGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted || + this.isRunning() + ) + return; + if (seconds <= 0) { + this.onProgress(to); + if (generation !== this._shotGeneration || this._destroyed) return; + if (sceneClock) { + this.publish( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedTo, + { running: false }, + ); + } + return; + } + const startedAt = this.now(); + const timer = this.schedule(() => { + if (this._shotProgressTimer !== timer) return; + if ( + generation !== this._shotGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted || + this.isRunning() + ) { + this.cancel(timer); + if (this._shotProgressTimer === timer) this._shotProgressTimer = null; + return; + } + const fraction = clamp01((this.now() - startedAt) / (seconds * 1000)); + this.onProgress(from + (to - from) * fraction); + if (generation !== this._shotGeneration || this._destroyed) return; + if (sceneClock) { + this.publish( + sceneClock.scene, + sceneClock.shot, + sceneClock.sceneElapsedFrom + + (sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) * + fraction, + { running: false }, + ); + } + if (fraction >= 1) { + this.cancel(timer); + if (this._shotProgressTimer === timer) this._shotProgressTimer = null; + } + }, 100); + timer.unref?.(); + this._shotProgressTimer = timer; + } + + startScene(scene, shot, token) { + this.stopSceneTimer(); + const generation = this._sceneGeneration; + const timing = this.timingForShot(scene, shot); + if ( + generation !== this._sceneGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted + ) + return timing; + const startedAt = this.now(); + this.publish(scene, shot, timing.startElapsedSec, { + running: true, + }); + if ( + generation !== this._sceneGeneration || + this._destroyed || + token.cancelled || + token.signal?.aborted + ) + return timing; + const timer = this.schedule(() => { + if (this._sceneClockTimer !== timer) return; + if ( + this._destroyed || + token.cancelled || + token.signal?.aborted || + !this.isRunning() + ) { + this.cancel(timer); + if (this._sceneClockTimer === timer) this._sceneClockTimer = null; + return; + } + const shotElapsedSec = Math.min( + timing.durationSec, + (this.now() - startedAt) / 1000, + ); + this.publish(scene, shot, timing.startElapsedSec + shotElapsedSec, { + running: true, + }); + }, 50); + timer.unref?.(); + this._sceneClockTimer = timer; + return timing; + } +} diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs new file mode 100644 index 000000000..c259c10ec --- /dev/null +++ b/src/director/clock.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPlaybackClock } from './clock.js'; + +const scene = { id: 'scene', shots: [{ id: 'shot' }] }; +const shot = scene.shots[0]; +const timing = { + shotIndex: 0, + totalSec: 4, + durationSec: 4, + startElapsedSec: 0, +}; +function fixture() { + const timers = new Map(); + let id = 0, + now = 0, + running = true; + const progress = []; + const clock = createPlaybackClock({ + isRunning: () => running, + timingForShot: () => timing, + onProgress: (value) => progress.push(value), + now: () => now, + schedule: (callback) => { + timers.set(++id, callback); + return id; + }, + cancel: (handle) => timers.delete(handle), + }); + return { + clock, + timers, + progress, + time: (value) => { + now = value; + }, + running: (value) => { + running = value; + }, + }; +} + +test('Stop releases every clock timer and queued callbacks cannot publish later', () => { + const f = fixture(); + const observed = []; + f.clock.subscribe((snapshot) => observed.push(snapshot)); + f.clock.startRunProgress(4); + f.clock.startScene(scene, shot, { cancelled: false }); + f.time(2000); + for (const tick of f.timers.values()) tick(); + assert.equal(f.clock.snapshot.sceneElapsedSec, 2); + const stale = [...f.timers.values()]; + f.clock.stop(); + assert.equal(f.clock.activeTimers, 0); + assert.equal(f.timers.size, 0); + assert.equal(f.clock.snapshot.stopped, true); + const count = observed.length, + progressCount = f.progress.length; + f.time(3000); + for (const tick of stale) tick(); + assert.equal(observed.length, count); + assert.equal(f.progress.length, progressCount); +}); + +test('replacing a shot clock rejects the old callback without clearing the replacement', () => { + const f = fixture(); + f.clock.startScene(scene, shot, {}); + const stale = [...f.timers.values()][0]; + f.time(1000); + f.clock.startScene(scene, shot, {}); + f.time(2000); + stale(); + assert.equal(f.clock.snapshot.sceneElapsedSec, 0); + assert.equal(f.clock.activeTimers, 1); + [...f.timers.values()][0](); + assert.equal(f.clock.snapshot.sceneElapsedSec, 1); + f.clock.destroy(); +}); + +test('direct-load progress finishes once and snapshots cannot mutate the clock', () => { + const f = fixture(); + f.running(false); + f.clock.startShotProgress({}, 2, 0.25, 0.75, { + scene, + shot, + sceneElapsedFrom: 1, + sceneElapsedTo: 3, + }); + const tick = [...f.timers.values()][0]; + f.time(1000); + tick(); + assert.equal(f.progress.at(-1), 0.5); + assert.equal(f.clock.snapshot.sceneElapsedSec, 2); + const copy = f.clock.snapshot; + copy.sceneElapsedSec = 99; + assert.equal(f.clock.snapshot.sceneElapsedSec, 2); + f.time(2000); + tick(); + assert.equal(f.progress.at(-1), 0.75); + assert.equal(f.clock.activeTimers, 0); + const count = f.progress.length; + tick(); + assert.equal(f.progress.length, count); +}); + +test('aborted starts and destruction during initial notification leave no timers or listeners', () => { + const f = fixture(); + const controller = new AbortController(); + controller.abort(); + f.clock.startScene(scene, shot, { signal: controller.signal }); + assert.equal(f.timers.size, 0); + let calls = 0; + f.clock.subscribe(() => { + calls++; + f.clock.destroy(); + }); + f.clock.startScene(scene, shot, {}); + assert.equal(calls, 1); + assert.equal(f.clock.activeTimers, 0); + f.clock.startRunProgress(4); + f.clock.publish(scene, shot, 1); + f.clock.subscribe(() => calls++); + assert.equal(calls, 1); + assert.equal(f.timers.size, 0); +}); + +test('Stop, abort and destroy settle long holds immediately and release their deadlines', async () => { + for (const action of ['stop', 'abort', 'destroy']) { + const clock = createPlaybackClock({ + isRunning: () => true, + timingForShot: () => timing, + onProgress() {}, + }); + const abort = new AbortController(); + const wait = clock.wait(60000, { signal: abort.signal }); + assert.equal(clock.activeTimers, 1); + if (action === 'abort') abort.abort(); + else clock[action](); + await wait; + assert.equal(clock.activeTimers, 0); + clock.destroy(); + } +}); + +test('a subscriber can Stop or replace the initial clock without the old start acquiring a timer', () => { + for (const action of ['stop', 'replace']) { + const f = fixture(); + let first = true; + f.clock.subscribe(() => { + if (!first) return; + first = false; + if (action === 'stop') f.clock.stop(); + else f.clock.startScene(scene, { id: 'replacement' }, {}); + }); + f.clock.startScene(scene, shot, {}); + assert.equal(f.timers.size, action === 'stop' ? 0 : 1); + assert.equal( + f.clock.snapshot.shotId, + action === 'stop' ? shot.id : 'replacement', + ); + f.clock.destroy(); + } + const f = fixture(); + f.running(false); + f.clock.subscribe(() => f.clock.stop()); + f.clock.startShotProgress({}, 10, 0, 1, { + scene, + shot, + sceneElapsedFrom: 0, + sceneElapsedTo: 4, + }); + assert.equal(f.timers.size, 0); +}); diff --git a/src/director/document.js b/src/director/document.js new file mode 100644 index 000000000..48804d83b --- /dev/null +++ b/src/director/document.js @@ -0,0 +1,186 @@ +/** 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'; +import { validateSceneInteractions } from './interactions/document.js'; +export const SCENE_DOCUMENT_VERSION = 6; + +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, 6].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'] : []), + ...(version >= 6 ? ['interactions'] : []), + ]); + 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); + validateSceneInteractions(scene, path); + }); + return project; +} + +/** Read bounded JSON. No URLs, modules or actions are executed during import. */ +export function parseSceneDocument(text) { + if ( + typeof text !== 'string' || + text.length > SCENE_DOCUMENT_LIMITS.bytes || + new TextEncoder().encode(text).byteLength > SCENE_DOCUMENT_LIMITS.bytes + ) + fail('$', 'file exceeds 5 MiB'); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + fail('$', 'invalid JSON'); + } + return validateSceneDocument(parsed); +} + +/** Export authored project fields only; callers pass the project, never run state. */ +export function stringifySceneDocument(project) { + // Round-trip drops optional undefined fields created by the editor. + const text = JSON.stringify(project, null, 2); + parseSceneDocument(text); + return text; +} diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs new file mode 100644 index 000000000..c0b8b0935 --- /dev/null +++ b/src/director/document.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseSceneDocument, + stringifySceneDocument, + validateSceneDocument, + SCENE_DOCUMENT_LIMITS, +} from './document.js'; +import { createDefaultProject, normalizeProject } from '../scenes/project.js'; + +const fixture = () => ({ + version: 3, + scenes: [ + { + id: 'scene', + title: 'My scene', + shots: [ + { + id: 'shot', + title: 'My shot', + durationSec: 3, + holdSec: 0, + camera: { lat: 1, lon: 2, alt: 42, pitch: 0 }, + layers: { + traffic: { enabled: true, params: { nested: [1, 'two', null] } }, + }, + }, + ], + }, + ], +}); +test('all built-in authored content exports and normalizes without edit loss', () => { + const project = createDefaultProject(); + const migrated = normalizeProject( + parseSceneDocument(stringifySceneDocument(project)), + ); + assert.deepEqual( + JSON.parse(stringifySceneDocument(migrated)), + JSON.parse(stringifySceneDocument(project)), + ); + const edited = normalizeProject(fixture()); + edited.scenes[0].appliedShotPacks = [ + { id: 'pack', version: 3, shotBindings: { Original: 'shot' } }, + ]; + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(edited))), + edited, + ); + assert.equal(edited.scenes[0].shots[0].camera.pitch, 0); + assert.equal(edited.scenes[0].shots[0].camera.alt, 42); +}); +test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds survive', () => { + for (const version of [undefined, 1, 2]) { + const project = fixture(); + project.version = version; + project.scenes[0].shots[0].visual = { + bloom: { enabled: true, intensity: 25 }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.equal(migrated.version, 6); + assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150); + assert.equal(migrated.scenes[0].shots[0].id, 'shot'); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); + } +}); +test('an intentionally empty project stays empty', () => { + assert.deepEqual( + normalizeProject(parseSceneDocument('{"version":3,"scenes":[]}')).scenes, + [], + ); +}); +test('missing legacy IDs become stable after the first saved migration', () => { + const project = { scenes: [{ shots: [{}] }] }; + const migrated = normalizeProject(validateSceneDocument(project)); + assert.ok(migrated.scenes[0].id); + assert.ok(migrated.scenes[0].shots[0].id); + assert.deepEqual( + normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))), + migrated, + ); +}); +test('invalid shapes, versions, unknown fields and unsafe keys fail with field paths', () => { + for (const text of [ + 'null', + '[]', + '{}', + '{"version":99,"scenes":[]}', + '{"version":3,"scenes":[],"run":{}}', + '{"scenes":[],"__proto__":{}}', + ]) { + assert.throws(() => parseSceneDocument(text), { + name: 'SceneDocumentError', + }); + } + const project = fixture(); + project.scenes[0].shots[0].camera.lat = 91; + assert.throws( + () => validateSceneDocument(project), + /\$\.scenes\[0\]\.shots\[0\]\.camera.lat/, + ); + project.scenes[0].shots[0].camera.lat = 1; + project.scenes[0].shots.push(structuredClone(project.scenes[0].shots[0])); + assert.throws(() => validateSceneDocument(project), /duplicate ID/); +}); +test('document byte, nesting, collection, finite-number and string bounds are enforced', () => { + assert.throws( + () => parseSceneDocument(' '.repeat(SCENE_DOCUMENT_LIMITS.bytes + 1)), + /5 MiB/, + ); + assert.throws( + () => + parseSceneDocument( + JSON.stringify({ + scenes: [], + createdAt: 'é'.repeat(SCENE_DOCUMENT_LIMITS.bytes / 2), + }), + ), + /5 MiB/, + ); + const project = fixture(); + project.scenes[0].shots[0].camera.lat = Infinity; + assert.throws(() => validateSceneDocument(project), /JSON value/); + project.scenes[0].shots[0].camera.lat = 1; + let nested = project.scenes[0].shots[0].layers.traffic.params; + for (let i = 0; i < 30; i++) nested = nested.child = {}; + assert.throws(() => validateSceneDocument(project), /complexity/); + assert.throws( + () => validateSceneDocument({ scenes: Array(257).fill({ shots: [] }) }), + /256/, + ); +}); + +test('captured scope and extended detection edits survive migration', () => { + const project = fixture(); + project.scenes[0].shots[0].visual = { + scope: { enabled: true, featherPct: 11 }, + detection: { + mode: 'DENSE', + density: 75, + allocation: 'ELASTIC', + fadePct: 7, + outsideOpacityPct: 1, + }, + }; + const migrated = normalizeProject( + parseSceneDocument(JSON.stringify(project)), + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.scope, + project.scenes[0].shots[0].visual.scope, + ); + assert.deepEqual( + migrated.scenes[0].shots[0].visual.detection, + project.scenes[0].shots[0].visual.detection, + ); +}); diff --git a/src/director/documentFields.js b/src/director/documentFields.js new file mode 100644 index 000000000..681cc6482 --- /dev/null +++ b/src/director/documentFields.js @@ -0,0 +1,94 @@ +/** Shared bounded JSON and field checks for scene formats. */ +export const SCENE_DOCUMENT_LIMITS = Object.freeze({ + bytes: 5 * 1024 * 1024, + scenes: 256, + shots: 10000, + collection: 10000, + depth: 24, + nodes: 200000, + string: 65536, +}); + +/** A project error identifies a field without echoing its supplied value. */ +export class SceneDocumentError extends Error { + constructor(path, reason) { + super(`${path}: ${reason}`); + this.name = 'SceneDocumentError'; + this.path = path; + } +} +export const fail = (path, reason) => { + throw new SceneDocumentError(path, reason); +}; +export const object = (value, path) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(path, 'expected an object'); +}; +export function fields(value, path, allowed) { + object(value, path); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field'); + } +} +export function string(value, path, max = 256) { + if (typeof value !== 'string' || !value.trim() || value.length > max) + fail(path, `expected nonempty text, at most ${max} characters`); +} +export function number(value, path, min, max, legacy) { + const numeric = + legacy && typeof value === 'string' && value.trim() ? Number(value) : value; + if ( + typeof numeric !== 'number' || + !Number.isFinite(numeric) || + numeric < min || + numeric > max + ) + fail(path, `expected a number from ${min} to ${max}`); +} +export function optional(value, key, path, check) { + if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`); +} +export function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) { + if (!Array.isArray(value) || value.length > max) + fail(path, `expected an array of at most ${max} entries`); +} +export function ids(value, path) { + array(value, path); + value.forEach((id, index) => string(id, `${path}[${index}]`)); +} +export function uniqueId(value, path, seen) { + if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration. + string(value.id, `${path}.id`); + if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID'); + seen.add(value.id); +} +export function jsonTree(value, path, budget, depth = 0) { + if ( + ++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes || + depth > SCENE_DOCUMENT_LIMITS.depth + ) + fail(path, 'project complexity limit exceeded'); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.length > SCENE_DOCUMENT_LIMITS.string) + fail(path, 'text is too long'); + return; + } + if (typeof value === 'number' && Number.isFinite(value)) return; + if (!value || typeof value !== 'object') fail(path, 'expected a JSON value'); + if ( + Object.getPrototypeOf(value) !== Object.prototype && + !Array.isArray(value) && + Object.getPrototypeOf(value) !== null + ) + fail(path, 'expected a JSON object'); + const entries = Object.entries(value); + if (entries.length > SCENE_DOCUMENT_LIMITS.collection) + fail(path, 'too many entries'); + for (const [key, child] of entries) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) + fail(path, 'unsafe object key'); + if (key.length > 256) fail(path, 'field name is too long'); + jsonTree(child, `${path}.${key}`, budget, depth + 1); + } +} diff --git a/src/director/interactions/document.js b/src/director/interactions/document.js new file mode 100644 index 000000000..7d1522608 --- /dev/null +++ b/src/director/interactions/document.js @@ -0,0 +1,96 @@ +import { + array, + fields, + string, + optional, + uniqueId, + fail, +} from '../documentFields.js'; + +/** Validate inert, scene-local actions. Feature existence is checked after asset loading. */ +export function validateSceneInteractions(scene, path) { + const packs = new Map((scene.dataPacks || []).map((p) => [p.id, p])); + const anchors = new Set((scene.anchors || []).map((a) => a.id)); + const shots = new Map(scene.shots.map((s) => [s.id, s])); + scene.shots.forEach((shot, index) => { + optional(shot, 'interactions', `${path}.shots[${index}]`, (items, at) => { + array(items, at, 64); + const seen = new Set(); + items.forEach((item, i) => { + const field = `${at}[${i}]`; + fields(item, field, ['id', 'label', 'target', 'action']); + string(item.id, field); + uniqueId(item, field, seen); + string(item.label, field, 256); + fields(item.target, `${field}.target`, ['packId', 'featureId']); + string(item.target.packId, field); + string(item.target.featureId, field); + if ( + packs.get(item.target.packId)?.format !== 'geojson' || + !shot.dataPackIds?.includes(item.target.packId) + ) + fail(field, 'target must belong to a selected GeoJSON pack'); + const a = item.action; + const specs = { + card: ['text', 'url'], + focus: ['anchorId'], + shot: ['shotId'], + layer: ['layerId', 'enabled'], + }; + if (!a || !Object.hasOwn(specs, a.type)) + fail(field, 'unsupported action'); + string(a.type, `${field}.action.type`); + fields(a, `${field}.action`, ['type', ...specs[a.type]]); + const reference = { + focus: 'anchorId', + shot: 'shotId', + layer: 'layerId', + }[a.type]; + if (reference) string(a[reference], `${field}.action.${reference}`); + if (a.type === 'card') { + string(a.text, field, 4096); + optional(a, 'url', field, (url, at) => { + string(url, at, 2048); + let parsed; + try { + parsed = new URL(url); + } catch { + fail(at, 'expected HTTPS source URL'); + } + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) + fail( + at, + 'expected HTTPS source URL without credentials, query or fragment', + ); + }); + } else if (a.type === 'focus' && !anchors.has(a.anchorId)) + fail(field, 'unknown anchor'); + else if (a.type === 'shot') { + const target = shots.get(a.shotId); + if (!target) fail(field, 'unknown shot'); + // A transition must restore every layer this shot can change. + for (const entry of items) + if ( + entry?.action?.type === 'layer' && + !Object.hasOwn(target.layers || {}, entry.action.layerId) + ) + fail( + field, + 'target shot must declare interactive layer baseline', + ); + } else if (a.type === 'layer') { + if (!Object.hasOwn(shot.layers || {}, a.layerId)) + fail(field, 'layer must have an explicit shot baseline'); + if (typeof a.enabled !== 'boolean') + fail(field, 'expected enabled boolean'); + } + }); + }); + }); +} diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs new file mode 100644 index 000000000..fa0aa10ae --- /dev/null +++ b/src/director/interactions/interactions.test.mjs @@ -0,0 +1,247 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSceneDocument, stringifySceneDocument } from '../document.js'; +import { normalizeProject } from '../../scenes/project.js'; +import { createInteractionSession } from './session.js'; + +const fixture = () => ({ + version: 6, + scenes: [ + { + id: 'scene', + anchors: [ + { + id: 'view', + lat: 1, + lon: 2, + alt: 3000, + altitudeReference: 'ellipsoid', + }, + ], + dataPacks: [ + { + id: 'data', + version: 1, + format: 'geojson', + source: { adapter: 'assets', path: 'test.json' }, + attribution: { text: 'Example', license: 'CC0' }, + placement: { altitudeReference: 'ellipsoid' }, + }, + ], + shots: [ + { + id: 'one', + layers: { traffic: false }, + dataPackIds: ['data'], + interactions: [ + { + id: 'card', + label: 'Read', + target: { packId: 'data', featureId: 'point' }, + action: { + type: 'card', + text: '', + url: 'https://example.org/source', + }, + }, + { + id: 'focus', + label: 'Focus', + target: { packId: 'data', featureId: 'point' }, + action: { type: 'focus', anchorId: 'view' }, + }, + { + id: 'layer', + label: 'Traffic', + target: { packId: 'data', featureId: 'point' }, + action: { type: 'layer', layerId: 'traffic', enabled: true }, + }, + { + id: 'next', + label: 'Next', + target: { packId: 'data', featureId: 'point' }, + action: { type: 'shot', shotId: 'two' }, + }, + ], + }, + { id: 'two', layers: { traffic: false } }, + ], + }, + ], +}); + +test('all four inert actions survive validation, migration and export without executing content', () => { + const input = fixture(); + const output = JSON.parse( + stringifySceneDocument( + normalizeProject(parseSceneDocument(JSON.stringify(input))), + ), + ); + assert.deepEqual( + output.scenes[0].shots[0].interactions, + input.scenes[0].shots[0].interactions, + ); +}); + +test('reject unknown fields, executable syntax, invalid references and missing reset baselines', () => { + const mutations = [ + (p) => (p.version = 5), + (p) => { + p.scenes[0].shots[0].layers.undefined = false; + delete p.scenes[0].shots[0].interactions[2].action.layerId; + }, + (p) => (p.scenes[0].shots[0].interactions[0].action.type = ['card']), + (p) => (p.scenes[0].shots[0].interactions[2].action.layerId = ['traffic']), + (p) => (p.scenes[0].shots[0].interactions = null), + (p) => + p.scenes[0].shots[0].interactions.push( + p.scenes[0].shots[0].interactions[0], + ), + (p) => (p.scenes[0].shots[0].interactions[0].action.code = 'alert(1)'), + (p) => + (p.scenes[0].shots[0].interactions[0].action.url = 'javascript:alert(1)'), + (p) => + (p.scenes[0].shots[0].interactions[0].action.url = + 'https://user:pass@example.org/'), + (p) => + (p.scenes[0].shots[0].interactions[0].action.url = + 'https://example.org/?token=secret'), + (p) => (p.scenes[0].shots[0].interactions[0].target.packId = 'missing'), + (p) => (p.scenes[0].shots[0].dataPackIds = []), + (p) => (p.scenes[0].shots[0].interactions[1].action.anchorId = 'missing'), + (p) => (p.scenes[0].shots[0].interactions[2].action.layerId = 'missing'), + (p) => (p.scenes[0].shots[0].interactions[2].action.enabled = 'yes'), + (p) => (p.scenes[0].shots[0].interactions[3].action.shotId = 'missing'), + (p) => (p.scenes[0].shots[1].layers = {}), + (p) => + (p.scenes[0].shots[0].interactions = Array.from( + { length: 65 }, + (_, i) => ({ ...p.scenes[0].shots[0].interactions[0], id: String(i) }), + )), + ]; + for (const mutate of mutations) { + const p = fixture(); + mutate(p); + assert.throws(() => parseSceneDocument(JSON.stringify(p))); + } +}); + +test('pending actions cancel promptly, refuse overlap and cannot update a replacement session', async () => { + let resolve, + signal, + calls = 0; + const session = createInteractionSession({ + execute: (_, s) => { + calls++; + signal = s; + return new Promise((r) => (resolve = r)); + }, + }); + session.activate([{ id: 'old' }]); + const work = session.dispatch('old'); + await Promise.resolve(); + assert.equal(await session.dispatch('old'), false); + assert.equal(await session.dispatch('missing'), false); + session.activate([{ id: 'new' }]); + assert.equal(signal.aborted, true); + assert.equal(await work, false); + resolve(true); + await Promise.resolve(); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: null, + count: 1, + }); + assert.equal(calls, 1); + session.clear(); + assert.equal(await session.dispatch('new'), false); +}); + +test('synchronous stop before execution prevents any side effect; rejection unlocks retry', async () => { + let calls = 0; + const session = createInteractionSession({ + execute: () => { + calls++; + throw new Error('refused'); + }, + }); + session.activate([{ id: 'a' }]); + const work = session.dispatch('a'); + session.clear(); + assert.equal(await work, false); + assert.equal(calls, 0); + session.activate([{ id: 'a' }]); + assert.equal(await session.dispatch('a'), false); + assert.equal(session.getState().busy, false); + assert.equal(await session.dispatch('a'), false); + assert.equal(calls, 2); +}); + +test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { + const { SceneDirector } = await import('../../scenes/director.js'); + const shot = { id: 'shot', dataPackIds: ['pack'] }; + const director = Object.assign(Object.create(SceneDirector.prototype), { + _destroyed: false, + _loadedSceneId: 'scene', + _selectedShotId: 'shot', + _claimCameraOwnership: () => + assert.fail('Must reload the released pack before camera seek'), + }); + assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false); +}); + +test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => { + const { SceneDirector } = await import('../../scenes/director.js'); + const owner = new AbortController(); + const director = Object.assign(Object.create(SceneDirector.prototype), { + _running: false, + _destroyed: false, + _interactionTransitions: 64, + _getShot: () => ({ scene: { id: 'scene' }, shot: { id: 'shot' } }), + _claimCameraOwnership: () => false, + _setCameraView: () => assert.fail('Refused camera cannot move'), + _updateStatus: () => {}, + _loadShot: () => assert.fail('Transition budget exceeded'), + dataManager: { + getAll: () => [{ id: 'traffic' }], + setEnabled: (id, enabled, options) => { + assert.equal(id, 'traffic'); + assert.equal(enabled, true); + assert.equal(options.signal, owner.signal); + assert.equal(options.origin, 'scene'); + return false; + }, + }, + }); + assert.equal( + await director._executeInteraction({ type: 'focus' }, owner.signal), + false, + ); + assert.equal( + await director._executeInteraction( + { type: 'shot', shotId: 'next' }, + owner.signal, + ), + false, + ); + assert.equal( + await director._executeInteraction( + { type: 'layer', layerId: 'traffic', enabled: true }, + owner.signal, + ), + false, + ); + assert.equal( + await director._executeInteraction( + { type: 'layer', layerId: 'unknown', enabled: true }, + owner.signal, + ), + false, + ); + owner.abort(); + assert.equal( + await director._executeInteraction({ type: 'focus' }, owner.signal), + false, + ); +}); diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js new file mode 100644 index 000000000..2f305b675 --- /dev/null +++ b/src/director/interactions/session.js @@ -0,0 +1,61 @@ +/** Own one user-triggered action at a time; cancellation settles even an uncooperative adapter. */ +export function createInteractionSession({ execute, changed = () => {} }) { + let controller, + active = false, + busy = false, + selected = null; + let actions = new Map(); + const state = () => ({ active, busy, selected, count: actions.size }); + function clear() { + active = false; + busy = false; + selected = null; + actions.clear(); + controller?.abort(); + controller = null; + changed(state()); + } + return { + clear, + getState: state, + activate(items) { + clear(); + actions = new Map(items.map((item) => [item.id, item])); + active = !!actions.size; + changed(state()); + }, + async dispatch(id) { + const item = actions.get(id); + if (!active || busy || !item) return false; + const current = new AbortController(); + controller = current; + busy = true; + selected = id; + changed(state()); + let abort; + try { + const cancelled = new Promise((resolve) => { + abort = () => resolve(false); + current.signal.addEventListener('abort', abort, { once: true }); + }); + const work = Promise.resolve().then(() => { + if (current.signal.aborted) return false; + return execute(item, current.signal); + }); + return ( + (await Promise.race([work, cancelled])) !== false && + !current.signal.aborted + ); + } catch { + return false; + } finally { + current.signal.removeEventListener('abort', abort); + if (controller === current) { + busy = false; + controller = null; + changed(state()); + } + } + }, + }; +} diff --git a/src/director/packs/geojson.js b/src/director/packs/geojson.js new file mode 100644 index 000000000..49f9f0f55 --- /dev/null +++ b/src/director/packs/geojson.js @@ -0,0 +1,62 @@ +import { PACK_LIMITS } from './manifest.js'; + +/** Decode a bounded geometry-only GeoJSON collection; properties never become HTML, styles or URLs. */ +export function decodePackGeoJSON(bytes) { + const value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ); + if ( + value?.type !== 'FeatureCollection' || + !Array.isArray(value.features) || + value.features.length > PACK_LIMITS.features + ) + throw new Error('Expected a bounded FeatureCollection'); + let positions = 0; + const ids = new Set(); + function position(p) { + if ( + !Array.isArray(p) || + ![2, 3].includes(p.length) || + p.some((v) => !Number.isFinite(v)) || + Math.abs(p[0]) > 180 || + Math.abs(p[1]) > 90 || + (p.length === 3 && (p[2] < -12000 || p[2] > 1e9)) || + ++positions > PACK_LIMITS.positions + ) + throw new Error('Invalid geographic position'); + return [p[0], p[1], p[2] ?? 0]; + } + function line(points, ring = false) { + if (!Array.isArray(points) || points.length < (ring ? 4 : 2)) + throw new Error('Invalid line'); + const normalized = points.map(position); + if (ring && normalized[0].some((v, i) => v !== normalized.at(-1)[i])) + throw new Error('Unclosed ring'); + return normalized; + } + return value.features.map((feature) => { + const id = feature?.id; + if ( + feature?.type !== 'Feature' || + typeof id !== 'string' || + !id.trim() || + id.length > 256 || + ids.has(id) + ) + throw new Error('Features require distinct string IDs'); + ids.add(id); + const g = feature.geometry; + let coordinates; + if (g?.type === 'Point') coordinates = position(g.coordinates); + else if (g?.type === 'LineString') coordinates = line(g.coordinates); + else if ( + g?.type === 'Polygon' && + Array.isArray(g.coordinates) && + g.coordinates.length && + g.coordinates.length <= 128 + ) + coordinates = g.coordinates.map((ring) => line(ring, true)); + else throw new Error('Unsupported geometry'); + return { id, type: g.type, coordinates }; + }); +} diff --git a/src/director/packs/manifest.js b/src/director/packs/manifest.js new file mode 100644 index 000000000..eff00eb12 --- /dev/null +++ b/src/director/packs/manifest.js @@ -0,0 +1,138 @@ +import { + fields, + string, + number, + array, + optional, + fail, +} from '../documentFields.js'; + +export const PACK_LIMITS = Object.freeze({ + packs: 8, + bytes: 8 * 1024 * 1024, + totalBytes: 32 * 1024 * 1024, + features: 2000, + positions: 50000, +}); + +/** Asset names are relative directory paths, never requests or executable modules. */ +export function validateAssetPath(value, path = 'source.path') { + string(value, path, 1024); + if ( + !value + .split('/') + .every((part) => /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(part)) + ) + fail( + path, + 'expected a relative asset path without URL syntax or traversal', + ); +} + +/** Validate one declarative pack; loading requires separately registered application adapters. */ +export function validateDataPack(pack, path, anchorIds) { + fields(pack, path, [ + 'id', + 'version', + 'format', + 'source', + 'attribution', + 'placement', + 'byteLength', + 'sha256', + ]); + string(pack.id, `${path}.id`); + if (pack.version !== 1) fail(`${path}.version`, 'unsupported pack version'); + if (!['geojson', 'image', 'media'].includes(pack.format)) + fail(`${path}.format`, 'unsupported pack format'); + fields(pack.source, `${path}.source`, ['adapter', 'path']); + string(pack.source.adapter, `${path}.source.adapter`); + validateAssetPath(pack.source.path, `${path}.source.path`); + fields(pack.attribution, `${path}.attribution`, ['text', 'license', 'url']); + string(pack.attribution.text, `${path}.attribution.text`, 4096); + string(pack.attribution.license, `${path}.attribution.license`, 4096); + optional(pack.attribution, 'url', `${path}.attribution`, (url, at) => { + string(url, at, 2048); + let parsed; + try { + parsed = new URL(url); + } catch { + fail(at, 'expected an HTTPS source link'); + } + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) + fail( + at, + 'expected an HTTPS source link without credentials, query or fragment', + ); + }); + optional(pack, 'byteLength', path, (v, at) => { + number(v, at, 1, PACK_LIMITS.bytes, false); + if (!Number.isInteger(v)) fail(at, 'expected an integer'); + }); + optional(pack, 'sha256', path, (v, at) => { + if (typeof v !== 'string' || !/^[a-f0-9]{64}$/.test(v)) + fail(at, 'expected a lowercase SHA-256 digest'); + }); + const p = pack.placement, + at = `${path}.placement`; + fields( + p, + at, + pack.format === 'image' + ? ['bounds', 'height', 'altitudeReference'] + : pack.format === 'media' + ? ['anchorId'] + : ['altitudeReference'], + ); + if (pack.format === 'media') { + if (!anchorIds.has(p.anchorId)) + fail(`${at}.anchorId`, 'unknown scene anchor'); + } else { + if (p.altitudeReference !== 'ellipsoid') + fail(`${at}.altitudeReference`, 'expected ellipsoid height in meters'); + if (pack.format === 'image') { + array(p.bounds, `${at}.bounds`, 4); + if (p.bounds.length !== 4) + fail(`${at}.bounds`, 'expected west, south, east, north'); + p.bounds.forEach((v, i) => + number( + v, + `${at}.bounds[${i}]`, + i % 2 ? -90 : -180, + i % 2 ? 90 : 180, + false, + ), + ); + if (p.bounds[0] >= p.bounds[2] || p.bounds[1] >= p.bounds[3]) + fail(`${at}.bounds`, 'expected increasing non-dateline bounds'); + number(p.height, `${at}.height`, -12000, 1e9, false); + } + } +} + +/** Validate scene-local pack IDs and shot references without acquiring assets. */ +export function validateSceneDataPacks(scene, path) { + const packs = Object.hasOwn(scene, 'dataPacks') ? scene.dataPacks : []; + array(packs, `${path}.dataPacks`, PACK_LIMITS.packs); + const anchors = new Set((scene.anchors || []).map((a) => a.id)); + const seen = new Set(); + packs.forEach((pack, i) => { + validateDataPack(pack, `${path}.dataPacks[${i}]`, anchors); + if (seen.has(pack.id)) + fail(`${path}.dataPacks[${i}].id`, 'duplicate pack ID'); + seen.add(pack.id); + }); + scene.shots.forEach((shot, i) => { + optional(shot, 'dataPackIds', `${path}.shots[${i}]`, (ids, at) => { + array(ids, at, PACK_LIMITS.packs); + if (new Set(ids).size !== ids.length || ids.some((id) => !seen.has(id))) + fail(at, 'expected distinct scene pack IDs'); + }); + }); +} diff --git a/src/director/packs/packs.test.mjs b/src/director/packs/packs.test.mjs new file mode 100644 index 000000000..ed5d42500 --- /dev/null +++ b/src/director/packs/packs.test.mjs @@ -0,0 +1,365 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSceneDocument, stringifySceneDocument } from '../document.js'; +import { normalizeProject } from '../../scenes/project.js'; +import { createDataPackSession } from './session.js'; +import { createAssetDirectorySource } from './source.js'; +import { decodePackGeoJSON } from './geojson.js'; + +const pack = () => ({ + id: 'outline', + version: 1, + format: 'geojson', + source: { adapter: 'assets', path: 'example/outline.geojson' }, + attribution: { text: 'Example author', license: 'CC0-1.0' }, + placement: { altitudeReference: 'ellipsoid' }, +}); +const project = () => ({ + version: 5, + scenes: [ + { + id: 'scene', + dataPacks: [pack()], + shots: [{ id: 'shot', dataPackIds: ['outline'] }], + }, + ], +}); +const asset = () => ({ + bytes: new Uint8Array([1, 2, 3]), + mimeType: 'application/json', +}); +const deferred = () => { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +test('v5 packs and shot references round trip through project migration without acquiring bytes', () => { + const normalized = normalizeProject( + parseSceneDocument(JSON.stringify(project())), + ); + assert.deepEqual( + JSON.parse(stringifySceneDocument(normalized)).scenes[0].dataPacks, + project().scenes[0].dataPacks, + ); + assert.deepEqual(normalized.scenes[0].shots[0].dataPackIds, ['outline']); + for (const version of [1, 2, 3, 4]) { + const old = normalizeProject( + parseSceneDocument( + JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }), + ), + ); + assert.equal(old.version, 6); + 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: '