mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e494d04dc6 | ||
|
|
17395749d4 | ||
|
|
28e0ba7104 | ||
|
|
5657668265 | ||
|
|
00e8e3b997 | ||
|
|
2f171c7d79 | ||
|
|
a1508a386e | ||
|
|
78fc5262f4 | ||
|
|
1c727842df | ||
|
|
fce328b7cd | ||
|
|
44d9455738 | ||
|
|
fb777c1c93 | ||
|
|
bd2342dc81 | ||
|
|
5c513684f5 | ||
|
|
ba5a1e25ee | ||
|
|
7fe95b76e9 | ||
|
|
3fc731c98b | ||
|
|
4e2e0d6900 | ||
|
|
2120130997 | ||
|
|
10916227cb | ||
|
|
a536e7879c | ||
|
|
29fb756a5c | ||
|
|
35c612c5f6 | ||
|
|
9b76ecfe03 | ||
|
|
bdf3848dbc | ||
|
|
86eca5abf3 | ||
|
|
0f51eb21ba | ||
|
|
9f6e0ef60f | ||
|
|
d80683c44b | ||
|
|
56c5d03b65 | ||
|
|
b8bfeb7aa2 | ||
|
|
7214f71878 | ||
|
|
8e635c9a3a | ||
|
|
1959b33c48 | ||
|
|
daaaa348b7 | ||
|
|
0f237a32d4 | ||
|
|
4ecbe54997 | ||
|
|
3d8e1ef00f | ||
|
|
326c41df9c | ||
|
|
beca41c676 | ||
|
|
a29d5a6874 | ||
|
|
839442b20b | ||
|
|
c174742e2c | ||
|
|
4c383214f7 | ||
|
|
5d480a5fc2 | ||
|
|
7712ecf6ed | ||
|
|
256d7c273e | ||
|
|
d921a1b801 | ||
|
|
ada7d8852f | ||
|
|
7844e765b4 | ||
|
|
f747c4828e | ||
|
|
a310cbc2f0 | ||
|
|
d983ef23d8 | ||
|
|
6390a3346f | ||
|
|
558b0275a4 | ||
|
|
50559723a5 | ||
|
|
d39256e700 | ||
|
|
97816a35de | ||
|
|
d06e485c12 | ||
|
|
7bf268ec42 | ||
|
|
661e1d7fa3 | ||
|
|
aec4f67ca9 | ||
|
|
5a87fb3b15 | ||
|
|
548e7f23d1 | ||
|
|
daff74fe04 | ||
|
|
a1bd7ff37c | ||
|
|
672191497a | ||
|
|
1c4c903ac6 | ||
|
|
c09865e582 | ||
|
|
83fc895442 | ||
|
|
e4f526415d | ||
|
|
f4a4df5044 | ||
|
|
3e23b4cd39 | ||
|
|
cefbad3c2a | ||
|
|
c1dd4171db | ||
|
|
c7ff33ac2e | ||
|
|
77768e9fff | ||
|
|
5d0c367315 | ||
|
|
db73c6908e | ||
|
|
2a6ba1180d | ||
|
|
c6706a5a52 | ||
|
|
c869b248b7 | ||
|
|
2b5a5128fe | ||
|
|
d604b97a4d | ||
|
|
8f6671b6c2 | ||
|
|
5acf254f8b | ||
|
|
60444cca53 | ||
|
|
1d418fd5af | ||
|
|
52863a9f42 | ||
|
|
8abc99aa0a | ||
|
|
4fde920665 | ||
|
|
202a310c7a | ||
|
|
acd75efc7c | ||
|
|
38b95fb3b1 | ||
|
|
9688f15af7 | ||
|
|
edf0392248 | ||
|
|
c90e212049 | ||
|
|
e312a007ab | ||
|
|
702ecc38e2 | ||
|
|
f11a532139 | ||
|
|
166190b710 | ||
|
|
392141bfc5 | ||
|
|
e2f9952867 | ||
|
|
361f1c0fb8 | ||
|
|
c29ccd55d6 | ||
|
|
19372875e2 | ||
|
|
d9e2c8a7e7 | ||
|
|
1371a86713 | ||
|
|
603f428397 | ||
|
|
2110d06e36 | ||
|
|
e38672b45d | ||
|
|
d19e9f8b93 | ||
|
|
ff799d1520 | ||
|
|
e214129e45 | ||
|
|
34d43c7e3f | ||
|
|
8e5e3cdd3b | ||
|
|
d313b6e62c | ||
|
|
48b889590d | ||
|
|
d59b4d05bb | ||
|
|
777aa62586 | ||
|
|
99264e80d6 | ||
|
|
83e6acc34d | ||
|
|
4682757e71 | ||
|
|
bd57cf5f43 | ||
|
|
db9b61dc55 | ||
|
|
c18af6ad1c | ||
|
|
35bfe203a6 | ||
|
|
36b675b928 | ||
|
|
09e2fd96a4 | ||
|
|
1db3290e0b | ||
|
|
affa2164e0 | ||
|
|
3808f1fa7e | ||
|
|
200844998b | ||
|
|
fd2a21c0c6 | ||
|
|
c78c872f92 | ||
|
|
7411ebaf1c | ||
|
|
209dda5507 | ||
|
|
94049c5667 | ||
|
|
07a239fd82 | ||
|
|
8706cafdd1 | ||
|
|
346245e8e7 | ||
|
|
ad58a0bdc4 | ||
|
|
b5a78826f8 | ||
|
|
37a96072fa | ||
|
|
0f41fb04cd | ||
|
|
7dec7883aa | ||
|
|
fbbc4cda5c | ||
|
|
bea76df454 | ||
|
|
2b7f832f45 | ||
|
|
a194da21dd | ||
|
|
abea3f2feb | ||
|
|
914df05346 | ||
|
|
cbd3b13afb | ||
|
|
8acb526d1f | ||
|
|
3c53d63def | ||
|
|
6a6703e0c6 | ||
|
|
58b7d2d95e | ||
|
|
05b13e9a78 | ||
|
|
c55d4d7c25 | ||
|
|
6439575d15 | ||
|
|
3c163d09a1 | ||
|
|
5c044a3d8d | ||
|
|
4fc813e175 | ||
|
|
b22e6d519a | ||
|
|
b79ab84a1a | ||
|
|
a4ce7381d8 | ||
|
|
f0e28a83cb | ||
|
|
be7187cea7 |
No files matched your search
+10
-8
@@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||
# keep them while debugging; delete the log when you are done.
|
||||
GEV_VOICE_LOG_CONTENT=
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
|
||||
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_LIVE_MODEL=gemini-3.8-live
|
||||
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
|
||||
# GEV_RATELIMIT_GEMINI_PER_MIN=30
|
||||
# Per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||
@@ -118,8 +124,8 @@ OPENSKY_AUTH_MODE=oauth
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
||||
# without a token.
|
||||
# Launch Library 2: leave blank for standard public access (no key needed).
|
||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
@@ -141,12 +147,6 @@ PORT=4173
|
||||
# wildcard entries are ignored.
|
||||
# GEV_ALLOWED_HOSTS=
|
||||
|
||||
# SCENES › VCAM (phone virtual camera, docs/VCAM.md): the HTTPS listener a
|
||||
# paired phone connects to. It starts only when you open a pairing session and
|
||||
# serves only the phone page and relay, not the app or its keys.
|
||||
# VCAM_PORT=4174
|
||||
# VCAM_HOST=0.0.0.0
|
||||
|
||||
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
|
||||
# browser — the client polls the /api/firms proxy). Free key:
|
||||
# https://firms.modaps.eosdis.nasa.gov/api/map_key/
|
||||
@@ -243,6 +243,8 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
|
||||
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
||||
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
|
||||
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
@@ -67,8 +67,6 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GEV_REQUIRE_ALLOCATION_GATE: '0'
|
||||
# Fail on a missed header press unless the panel itself moved.
|
||||
QA_FAIL_ON_RETRY: '1'
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
@@ -104,10 +102,6 @@ jobs:
|
||||
- name: Street Level gate with Mapillary fixtures
|
||||
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
|
||||
|
||||
- name: Portable panel gate
|
||||
if: success() || failure()
|
||||
run: npm run qa:panel-resize -- --url http://localhost:4173
|
||||
|
||||
- name: Preview server log
|
||||
if: failure()
|
||||
run: cat preview.log
|
||||
|
||||
@@ -16,6 +16,10 @@ output/
|
||||
.gev-cache/
|
||||
# Browser QA gate evidence (screenshots, console and network dumps).
|
||||
qa-artifacts/
|
||||
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
|
||||
.playwright-mcp/
|
||||
# Local Claude Code settings and preview configs.
|
||||
.claude/
|
||||
.DS_Store
|
||||
.gstack/
|
||||
3d-models/
|
||||
|
||||
+14
-20
@@ -317,9 +317,8 @@
|
||||
[NotProniss](https://github.com/NotProniss) (#1084).
|
||||
|
||||
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
|
||||
(orange; off by default, one provider at a time): from orbit dots show
|
||||
where the federated commons has pictures, and below about 60 km its
|
||||
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
||||
(orange; off by default, one provider at a time): below about 60 km the
|
||||
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
||||
line opens that sequence's picture nearest the click without moving the
|
||||
camera, flat or 360°, with cones along the sequence around it and ‹ › /
|
||||
← → following the picture's own links. The 360° and since filters apply,
|
||||
@@ -377,10 +376,10 @@
|
||||
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
|
||||
open Google's own panorama in
|
||||
the panel, with its arrows, the address and month in the caption, a link
|
||||
to Google Maps and FOLLOW. Share links carry it in the provider switches
|
||||
to Google Maps. Share links carry it in the provider switches
|
||||
(`r.google-1`). One provider is lit at a time (lighting one switches the
|
||||
other off), Mapillary by default since it is free. Street View's panoramas
|
||||
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
|
||||
are all 360°, so the IMAGERY and SINCE filters apply to
|
||||
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
|
||||
is a Google-blue view cone.
|
||||
|
||||
@@ -419,21 +418,16 @@
|
||||
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
|
||||
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
|
||||
|
||||
- Street Level: a street-level imagery layer modelled on the iD editor's photo
|
||||
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
|
||||
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
|
||||
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
|
||||
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
|
||||
down to a single street; on Google 3D they sit on the bare earth, refined
|
||||
against the rendered mesh near the camera, so trees and buildings hide them.
|
||||
Share links carry the provider switches, the filters and the panel's collapsed
|
||||
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
|
||||
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
|
||||
rejects the token or rate-limits.
|
||||
|
||||
- The CCTV and Street Level panels are portable: drag the header to float the
|
||||
panel, resize it from any edge or corner, and double-click the header to dock
|
||||
it again. The position persists across reloads.
|
||||
- Street Level: street-level imagery from providers, Mapillary first (free
|
||||
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
|
||||
360°/flat and captured-since filters and an embedded viewer with EXPAND and
|
||||
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
|
||||
Google 3D tiles; opening a photo frames it with one camera flight. Share
|
||||
links carry the provider switches, the filters and the panel's collapsed
|
||||
state. A server proxy adds the token to Mapillary coverage tiles, trims and
|
||||
caches them for 24 h, refuses cross-site requests and redirects, and limits
|
||||
load per IP and upstream. The panel reads KEY REQUIRED without a token and
|
||||
KEY REJECTED when Mapillary refuses it.
|
||||
|
||||
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
||||
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
|
||||
|
||||
+6
-8
@@ -54,9 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
|
||||
Runnable gate entrypoints document what they assert and how to invoke them.
|
||||
Most need a dev server; some also need a specific provider key or port. Follow
|
||||
the entrypoint's header rather than assuming every matching file accepts the
|
||||
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
|
||||
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
|
||||
`qa:transit`.
|
||||
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
|
||||
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
|
||||
|
||||
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
||||
for the feature: it names the gate for many of them, and it's the authoritative
|
||||
@@ -66,11 +65,10 @@ gate for the feature you changed.
|
||||
|
||||
> **CI covers only two of these for you.** The workflow runs the setup policy
|
||||
> checks, the formatting and package-boundary checks, the unit suite and the
|
||||
> production build, plus a Windows onboarding job, and against the built preview
|
||||
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
|
||||
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
|
||||
> server and a browser. Include the applicable local run in your PR's
|
||||
> validation evidence.
|
||||
> production build, plus a Windows onboarding job. It runs neither
|
||||
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
|
||||
> gate (`street-level-browser` job) — they need a live dev server and a
|
||||
> browser. Include the applicable local run in your PR's validation evidence.
|
||||
|
||||
## Share-link layer tokens
|
||||
|
||||
|
||||
+4
-2
@@ -20,10 +20,10 @@ How to read this:
|
||||
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
||||
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
|
||||
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
|
||||
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
|
||||
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
|
||||
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||
@@ -48,6 +48,7 @@ How to read this:
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
|
||||
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
|
||||
@@ -162,6 +163,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
||||
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
|
||||
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||
|
||||
@@ -66,7 +66,6 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||
- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave.
|
||||
- **🎥 Scene director:** Capture cinematic camera tours for clips and demos.
|
||||
- **📱 Phone virtual camera:** Scan a QR code and your phone becomes the camera — walk the room at 1:1, or set 1:1000 and one step crosses the city. Focal length, depth of field and recorded takes that land in the scene editor ([docs/VCAM.md](docs/VCAM.md)).
|
||||
- **🔗 Share Links:** Camera, style, layers, and even one tracked target serialize into a URL — a live target is a handoff, not a bookmark.
|
||||
- **🏠 Reset Globe:** One control — or one sentence — back to the full Earth.
|
||||
- **🌦️ Weather:** Animate GFS or ECMWF forecast wind, replay observed radar, satellite clouds and lightning on one timeline, and follow NHC/CPHC cyclone tracks. No key needed.
|
||||
@@ -308,20 +307,12 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
||||
|
||||
---
|
||||
|
||||
## 📱 Phone Virtual Camera
|
||||
|
||||
> Frame the shot with your hands, like a virtual-production VCAM.
|
||||
|
||||
Open **SCENES › VCAM** and scan the code with a phone on the same Wi-Fi. The phone shows the globe as a monitor and becomes the camera: on Android Chrome it tracks position and rotation through WebXR AR, and on iPhone Safari it tracks rotation while two thumb sticks dolly, truck, crane and pan. The motion scale sets the world under you, from 1:1 street level through 1:100 drone to 1:10000 orbit, and changing it never makes the camera jump.
|
||||
|
||||
Dial in a focal length from 12 to 400 mm, turn on depth of field and tap the picture to focus. Distances are scaled with the rig, so at 1:1000 the city falls out of focus like a tabletop miniature. Press **REC** and the move becomes a take: a shot in the selected scene, with every sample's position, orientation and focal length, which replays, seeks and retimes like any other.
|
||||
|
||||
Nothing extra is exposed by default: pairing starts a separate HTTPS listener that serves only the phone page and its relay, never the app or its keys. See [docs/VCAM.md](docs/VCAM.md) for trusting the local certificate on iPhone and for the protocol.
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ Talk to It
|
||||
|
||||
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||
|
||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
||||
|
||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||
|
||||
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
||||
@@ -390,15 +381,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
@@ -434,9 +425,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under SINCE; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
## 🎖️ Field Missions
|
||||
|
||||
@@ -540,9 +531,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
|
||||
| | Key | Why | Get it |
|
||||
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
|
||||
|
||||
Add these if you need higher polling allowances.
|
||||
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
|
||||
paid Patreon upgrades for higher limits; there is no free API-key signup.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
|
||||
+2
-3
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
|
||||
|
||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
|
||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
||||
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
|
||||
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
|
||||
|
||||
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
||||
|
||||
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
@@ -86,7 +86,6 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
||||
|
||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||
- **The phone camera (SCENES › VCAM) opens a separate, narrow LAN listener — only on request.** Creating a pairing session (a loopback, same-origin request from the app, behind the same gate as `/mcp`) starts an HTTPS listener on `VCAM_PORT` (default `4174`, all interfaces unless `VCAM_HOST` narrows it); it stops two minutes after the last session ends. It serves only the static phone page, a local CA certificate download and the phone side of a relay: never the app, its provider proxies or any key, so pairing a phone does not amount to the `HOST=0.0.0.0` opt-in. A phone joins with the six-character code shown on the desktop (about 30 bits; eight wrong codes rotate it) and receives a per-phone key; the relay forwards bounded JSON (4 KiB, 120/s) and one newest JPEG preview frame, and WebSockets on both ends must come from their own page's origin. The listener answers only IP addresses, `localhost` and `GEV_ALLOWED_HOSTS` names. Its TLS certificate is issued by a CA created in `.gev-cache/vcam/` (private keys owner-only), constrained to server certificates (path length 0); a phone that installs and trusts it trusts certificates from this machine's CA, so remove the profile when you no longer need it. **Limit:** anyone on the network who reads the code off your screen can drive your camera and see its preview until you press END.
|
||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||
- **Docker trusts one address, and only because the port is on loopback.**
|
||||
Docker delivers the host's own browser to the container from the container
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
|
||||
MCP client can ask which airlines, countries, aircraft types or ship types
|
||||
dominate an area, or the average altitude or speed, over every match instead
|
||||
of estimating from the returned rows. Airlines come from the ICAO designator
|
||||
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
|
||||
that report no operator; operator and type are counted only where the feed
|
||||
reports them. Ship types count by family, so AIS codes 70 to 79 are all
|
||||
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
|
||||
unknown. Altitude and speed stats cover airborne aircraft only, so parked
|
||||
aircraft do not pull the means down. Every vessel answer, `find_vessel`
|
||||
included, now reads AIS ship-type codes as their family and a destination
|
||||
with no letters as unknown (quanticsoul4772, #1056).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Share Launch Library request cache keys across installations refreshing in the
|
||||
same UTC minute, identify the app to the provider, and explain that standard
|
||||
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
|
||||
upgrades; existing local cache and server-only token handling are unchanged
|
||||
(rudycelekli, #1197).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
|
||||
launch data, and return to NOMINAL after a fresh response without discarding
|
||||
retained missions (rudycelekli, #1199).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
|
||||
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
|
||||
video camera could only be found by opening it. Stills stay cyan and the
|
||||
active camera stays amber, and focus de-emphasis fades the violet the same
|
||||
way (Danielslay86, #1200).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw CCTV stills at their source aspect on the monitor plane instead of
|
||||
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
|
||||
every still was widened; they are now centered and pillarboxed on the dark
|
||||
the placeholder already ends on (Danielslay86, #1201).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Street Level panel: SINCE is a dropdown of its nine windows (the tooltip
|
||||
names the cut-off date), IMAGERY and FIT / FILL are plain toggle buttons,
|
||||
and the panel opens by itself only when a photo opens or a click fails to
|
||||
load one, no longer when the layer is switched on. FIT / FILL starts at
|
||||
FIT each session (gekh, #1241).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
|
||||
the statewide ArcGIS catalog, read a page at a time under a byte cap with
|
||||
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
|
||||
FL511's terms limit its content to individual, personal use, and any other
|
||||
use (a public deployment included) needs FDOT's express written consent, so
|
||||
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
|
||||
catalog's direction codes, and camera ids come from the image channel, since
|
||||
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
|
||||
sets the cap (Danielslay86, #605).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
|
||||
5-decimal point was a permanent in-memory entry, a permanent line in an
|
||||
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
|
||||
nothing limiting the rate. The point cache now holds at most 50,000 entries
|
||||
and evicts oldest-first, a cache file above
|
||||
24 MB is not read back, and the route is limited to 90 requests/minute per
|
||||
client (270 global). Evicting a point costs one refetch, never a wrong
|
||||
answer, since terrain does not move. Ordinary use is unaffected: a full
|
||||
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Close the compact Radio disclosure when an adaptive tactical right-rail
|
||||
restore directly expands Context, before Space Missions can be covered.
|
||||
A repeated Context-open request also clears stale disclosure state.
|
||||
Real PanelChrome constructor wiring and the rail reflow path are covered
|
||||
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
|
||||
#804 report, asturksever's review, and quanticsoul4772's independent
|
||||
Windows diagnosis.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
|
||||
server-held API key, microphone streaming, shared map actions and captions.
|
||||
Switching providers ends the old conversation and preserves the input mode
|
||||
(Manjunath, #872).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Extract a reusable model-free World Packet from the existing voice
|
||||
`get_current_view_state` owner without restoring disabled layers to the
|
||||
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||
provenance, action result and view-state contract; read the layer roster once
|
||||
per packet; voice retains the speech/card envelope at its own boundary.
|
||||
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||
@@ -0,0 +1,6 @@
|
||||
- Street Level is rebuilt around one small provider registry: Mapillary,
|
||||
KartaView, Panoramax and Google Street View share one coverage drawer and
|
||||
one photo viewer, one source lit at a time. Floating and docked panels,
|
||||
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
|
||||
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
|
||||
deleted (gekh, #974).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Cross-site pages can no longer fill the terrain-height cache or spend the
|
||||
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
|
||||
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
|
||||
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
|
||||
the cost-bearing routes it still answers same-origin requests that arrive
|
||||
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
|
||||
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
|
||||
#975).
|
||||
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
|
||||
generic modules own DOM/listeners/timers and release them in `destroy()`.
|
||||
|
||||
Keep persistence with panel chrome: collapse uses
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
|
||||
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
|
||||
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
|
||||
Do not rename existing
|
||||
ids or reset persistence versions when adding a body. The WEATHER adapter is
|
||||
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
|
||||
|
||||
+125
-88
@@ -112,7 +112,14 @@ perimeters, terrain height, military installations and map features, plus a
|
||||
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
|
||||
opens the app over an area. Tools reuse the layers' portable source factories, take a
|
||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||
lists at 25 rows by default. Voice offers the same queries next to its app
|
||||
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
|
||||
count every match by a field (`group_by`: airline, read from the ICAO
|
||||
designator in the callsign, or operator, country or type for aircraft; type or
|
||||
destination for ships; the ten largest groups, then other and unknown counts;
|
||||
the summary says when a feed reports none of the field) and summarize airborne
|
||||
altitude and speed (`stats`: count, min, median, mean, max).
|
||||
Both cover all matches, not just the rows returned; voice leaves these two
|
||||
tools out, so they reach MCP clients only. Voice offers the same queries next to its app
|
||||
actions: the session lists them, and the browser runs them through the same
|
||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
@@ -429,20 +436,6 @@ 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.
|
||||
|
||||
SCENES › VCAM pairs a phone on the same network as a virtual camera. Opening
|
||||
the card creates a loopback-only session and starts a separate HTTPS phone
|
||||
listener (`VCAM_PORT`, default 4174) that serves only the phone page and relay;
|
||||
END, or two minutes after the last session, stops it. Android Chrome tracks
|
||||
6DoF through WebXR `immersive-ar`; iPhone Safari tracks rotation through
|
||||
DeviceOrientation with on-screen sticks, falling back from WebSocket to HTTPS
|
||||
requests under an untrusted certificate. The rig claims the camera through the
|
||||
navigation seam (Cockpit refuses it) and holds on any other navigation, scene
|
||||
run or globe pointer input. Motion scale, smoothing, height and horizon locks,
|
||||
focal length and scale-aware depth of field are shared by phone and card. REC
|
||||
writes version-7 track shots (`move.track`, up to 10,000 samples with focal
|
||||
length) into the selected scene; shots may also carry a `lens`. See
|
||||
[VCAM](VCAM.md); gate: `node scripts/qa-vcam.mjs`.
|
||||
|
||||
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
|
||||
@@ -503,6 +496,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||
non-voice consumers. It reads the same enabled-only state and feed
|
||||
provenance before voice adds speech/card fields, with no new network requests
|
||||
or mutations.
|
||||
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
|
||||
steps and the result. `resultDisplay.js` is the one adapter from a result to
|
||||
the card: builder results show as built, and analyst results show a title
|
||||
@@ -620,7 +618,9 @@ Reference feed construction is exported through `sources/reference`; the cable s
|
||||
|
||||
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
|
||||
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
@@ -651,7 +651,7 @@ The UI facade composes separate owners for navigation authority, share restorati
|
||||
visual settings and panel chrome. Navigation owns request generations and follow
|
||||
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
||||
visual settings own style/detection overrides and sensor cleanup; panel chrome
|
||||
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
|
||||
owns disclosure and Cockpit panel snapshots. Each takes explicit
|
||||
operations and reads instead of the complete shell. Existing controls, share
|
||||
formats and one-application-per-page behavior are preserved.
|
||||
|
||||
@@ -826,7 +826,11 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed
|
||||
stage coordinates, downrange distance and flight numbers remain unknown; the
|
||||
mission panel omits the flight-number label when no number was reported.
|
||||
Pending mission requests abort on disable/destruction and malformed launch
|
||||
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
|
||||
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
|
||||
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
|
||||
provenance. Hiding retained missions does not relabel them as fresh, and teardown
|
||||
clears the instance state. Injected getLaunches-only sources remain supported.
|
||||
Catalog groups, propagation cadence,
|
||||
tracking intent, replay timing, controls and source attribution remain unchanged.
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
@@ -973,10 +977,9 @@ start with current state, isolate listener failures and stop on disposal.
|
||||
|
||||
The standalone entry composes the UI with the application's existing layer,
|
||||
terrain, navigation and rendering operations. The shell receives those instances
|
||||
and assembles the controls. Panel layout scheduling, position preferences and
|
||||
active drags, loading notices, recording presentation and DOM lookup have focused
|
||||
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
|
||||
owners. Disposal revokes queued presentation and listeners before asynchronous
|
||||
Context restoration, cancels an unfinished drag without saving it, restores the
|
||||
Context restoration, restores the
|
||||
recording HUD and releases status decoration without replacing accessible text.
|
||||
The ordinary control snapshot includes the current 3D model toggle and mode.
|
||||
|
||||
@@ -1206,6 +1209,10 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||
writes. This extraction does not change panel positions or layout defaults.
|
||||
Context expansion through the panel owner or adaptive right-rail restoration
|
||||
closes an open compact Radio disclosure before Context/Space Missions becomes
|
||||
visible. The mini remains available while Context is collapsed. This fixes the
|
||||
Radio-overlap path reported in #804 without changing z-index or layout policy.
|
||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||
collapse can still be undone.
|
||||
@@ -1455,6 +1462,18 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
## Terrain height cache bound
|
||||
|
||||
The `/api/terrain/heights` route refuses cross-site browser requests before
|
||||
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
|
||||
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
|
||||
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
|
||||
for such a request is the visitor's own browser, so without the gate any page
|
||||
could spend the visitor's 90/min quota and leave the app's own lookups on
|
||||
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
|
||||
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
|
||||
and a `403` there would send every chunk to the geoid fallback for an instance
|
||||
served behind the documented reverse proxy. Same-origin app requests,
|
||||
non-browser local callers and proxied same-origin requests keep working.
|
||||
|
||||
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
|
||||
latitudes outside [-90, 90] for every client before upstream resolution. One
|
||||
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
|
||||
@@ -1492,6 +1511,14 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||
access. Callers retain validation, transport and response policy.
|
||||
|
||||
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
|
||||
window, so installations refreshing in the same minute share an upstream cache
|
||||
key. The upper bound trails the current time by less than one minute. Requests
|
||||
identify the app and repository through User-Agent; the existing 15-minute local
|
||||
cache, stale fallback and optional server-only Authorization remain unchanged.
|
||||
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
|
||||
standard public access needs no key, and its field can remain blank.
|
||||
|
||||
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||
complete set. A missing orbital line does not discard the later satellites in
|
||||
the catalog. A set whose two lines carry different catalog numbers, or a
|
||||
@@ -2678,6 +2705,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> tunings. Ambient overlap includes each sprite's own rendered extent. The
|
||||
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
|
||||
> settled dim contact always completes its release after tracking ends.
|
||||
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
|
||||
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
|
||||
> upstream call, four limits bound what a caller can grow. Coordinates outside
|
||||
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
|
||||
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
|
||||
> — terrain does not move, so an evicted point costs one refetch and never a
|
||||
> wrong answer; a cache file above 24 MB is not read back into memory (the
|
||||
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
|
||||
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
|
||||
> order of magnitude above measured demand — a full `npm run test:track` run
|
||||
> drove 10 requests, peaking at 8/min.
|
||||
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
|
||||
> 5-decimal points individually, reconstructs reordered/overlapping batches
|
||||
> in exact request order, and refreshes only missing or stale points. Upstream
|
||||
@@ -3409,7 +3447,7 @@ its criteria cannot be silently ignored.
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + 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 + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
@@ -3418,7 +3456,7 @@ its criteria cannot be silently ignored.
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
@@ -3450,8 +3488,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
|
||||
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
collapsed. It opens itself only when the user, voice or a tool switches the
|
||||
layer on or a photo opens, never on a restore, and those opens are not stored.
|
||||
Like the iD editor's photo overlay, it has one chip per registered provider,
|
||||
@@ -3463,31 +3501,30 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
|
||||
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
Mapillary coverage ranks unique tile addresses before applying the 16-tile
|
||||
overview budget. Overlapping dateline halves in a polar view therefore do not
|
||||
waste overview slots on the same tile; loading already skips duplicate keys.
|
||||
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
||||
the panel says to zoom in. Tiles come from the visible ground within range of
|
||||
the camera, nearest first, at most 9 (the halves of a view across
|
||||
the date line count a shared tile once). Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
|
||||
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
|
||||
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
|
||||
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
|
||||
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
|
||||
the mesh hides what is behind buildings and trees. A tile is drawn draped until
|
||||
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
|
||||
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
|
||||
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
|
||||
steep streets and roads under trees. It uses the mesh floor's rules (the
|
||||
visible tileset has finished streaming, a real bare-earth prior, the shared
|
||||
mesh window) and probes a cell again once the camera is half as far from it,
|
||||
down to 40 m, so coarse early samples are replaced as finer tiles load. A
|
||||
failed probe keeps the last good sample and distance and is retried after the
|
||||
miss cooldown. Roads on elevated decks are still drawn at
|
||||
ground level. Framing a photo ignores mesh samples far below the bare earth
|
||||
(unloaded tiles).
|
||||
The server proxy (`server/providers/mapillary.js`) adds the token to
|
||||
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
|
||||
API), serves only z11–14 coverage, refuses upstream redirects, strips the
|
||||
unused `image` layer, shares one fetch per tile, runs at most six at once
|
||||
(cancelling those every client has left) and keeps tiles in memory for 24 h
|
||||
(a body too big to be a trimmed tile is served but not kept). While Mapillary
|
||||
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
|
||||
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
|
||||
asking.
|
||||
|
||||
The header pill is the layer switch. Lighting a chip swaps the source;
|
||||
clicking the lit chip turns the layer off. A chip without a usable key
|
||||
@@ -3498,19 +3535,25 @@ their keys but a switched-off one would draw with a key already set (no
|
||||
Mapillary token, Street View on Google 3D with a Google key), the layer
|
||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||
line and the layer row's tooltip say to light it. The
|
||||
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
||||
stops when the stack changes. Camera moves go through the application's
|
||||
navigation (`attachNavigation`), which releases aircraft and satellite tracking
|
||||
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
|
||||
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
|
||||
when it starts opening and frames only if nothing newer took the camera while
|
||||
it loaded. SINCE is a stepped slider whose readout names the
|
||||
cut-off date. The panel is portable (`street-level-panel` spec, minimum
|
||||
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
|
||||
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
|
||||
docks it. At phone width (≤720 px) the viewer height is derived from the rail
|
||||
band so the whole photo fits.
|
||||
viewer sits under the header with EXPAND, FIT/FILL and close above the
|
||||
image; FIT/FILL starts at FIT each session. IMAGERY and FIT/FILL are rows of
|
||||
toggle buttons (`aria-pressed`), each its own tab stop. SINCE is a select of
|
||||
the nine windows; a link's day count between them gets its own option, and
|
||||
the tooltip names the cut-off date. The panel opens by itself only when a
|
||||
photo opens, or a click fails to open one or to load its sequence (the
|
||||
result, or its error, would otherwise be out of sight), never when the layer
|
||||
is switched on.
|
||||
|
||||
MapillaryJS loads on the first photo. Opening one frames it with a single
|
||||
camera flight through the application's deferred navigation (`begin` /
|
||||
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
|
||||
if newer navigation took the camera while the photo loaded; closing the photo
|
||||
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
|
||||
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
||||
popover instead (hidden where neither API exists). Clean View, recording and
|
||||
the cockpit take an expanded photo out of full screen: the shell calls the
|
||||
panel's `shrinkViewer()` as it enters them. At phone width the
|
||||
viewer height comes from the rail band so the whole photo fits.
|
||||
|
||||
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
||||
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
||||
@@ -3519,9 +3562,10 @@ add a provider: implement the definition, register it in
|
||||
`src/app/layers/streetLevel.js` and add its modules to
|
||||
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
|
||||
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
|
||||
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
|
||||
XYZ tiles near the ground, draped, the selected sequence drawn again over
|
||||
them) and its cones
|
||||
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
|
||||
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
|
||||
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
|
||||
layer's `requiresKeyId` is null and each chip reports its own key.
|
||||
|
||||
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
||||
@@ -3530,7 +3574,7 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
|
||||
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
|
||||
panorama load is billed to the key. Google's terms forbid Street View beside
|
||||
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
||||
off Google 3D it does not appear at all (no chip, legend swatch, globe
|
||||
off Google 3D it does not appear at all (no chip, globe
|
||||
credit, layer source or button, and Google's script is not loaded), lookups
|
||||
and opens skip it, and leaving Google 3D closes its panorama; the share
|
||||
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
|
||||
@@ -3552,9 +3596,8 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
|
||||
of GEV's amber pin. Its
|
||||
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
|
||||
(a provider's `filters` default to both `pano` and `since`): lookups ignore
|
||||
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
|
||||
honours (`filterControls`), and the legend lists only providers that draw
|
||||
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
|
||||
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
|
||||
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
|
||||
element in the viewer host, without Google's fullscreen, close and address
|
||||
controls (the address goes in the caption); a pose goes out at once and
|
||||
again when the panorama's month, photographer and address arrive (one
|
||||
@@ -3581,8 +3624,7 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
|
||||
photos and carry no sequence, date or projection, so `decode.js` chains
|
||||
segments that meet end to start (about four times fewer lines; linear time,
|
||||
at most 20,000 segments a tile and 5,000 points a line), they are
|
||||
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
|
||||
(`highlightsSequence: false`, so the legend has no "Selected"), and the
|
||||
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
|
||||
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
|
||||
streets in view · click a line for its nearest photo"). KartaView filters
|
||||
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
|
||||
@@ -3591,8 +3633,7 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
|
||||
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
|
||||
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
|
||||
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
|
||||
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
|
||||
until the new ones are in), keeps the JSON segments, and caches masks per
|
||||
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
|
||||
filter and tile. A dense filtered PNG can take KartaView over a minute the
|
||||
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
|
||||
asked again 4 s later, four tries in all; then the panel says it did not
|
||||
@@ -3604,7 +3645,7 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
|
||||
photos are near but none passes, the panel says so), and the cones pass on
|
||||
each photo's projection and upload date. The provider declares
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
|
||||
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
upload date" under the SINCE select (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
|
||||
KartaView is lit (`createTileCache`, dropped when it is switched off); one
|
||||
that does not answer in 20 s is asked once more, and one that is not a line
|
||||
@@ -3619,7 +3660,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
|
||||
sequence draws as cones from the 250-photo page holding it
|
||||
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
|
||||
(`countActivePhotos`, asked once per sequence). The camera direction (the
|
||||
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
|
||||
marker, cones and a panorama's centre) is the direction of travel
|
||||
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
|
||||
imagery, while its `heading` is the device compass, which reads 0 for whole
|
||||
drives without one and can sit a steady 110° off inside a car. Until the
|
||||
@@ -3676,23 +3717,18 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
|
||||
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
|
||||
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
|
||||
view draw their longest lines within 90,000 points (`maxPoints`,
|
||||
`keepOrder`), shared out once loading settles so a sparse tile keeps all
|
||||
it has and a dense one (a city centre) takes the rest; the selected
|
||||
sequence is always drawn. Every point becomes ~8 ground-line vertices:
|
||||
`keepOrder`), split evenly between the tiles in view (10,000 points
|
||||
each). Every point becomes ~8 ground-line vertices:
|
||||
unsimplified, the lines hung the tab. Decoded tiles are kept while the
|
||||
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
|
||||
and above
|
||||
60 km the `grid` layer's 0.1°
|
||||
density cells, binned to at most 96×96 points a tile (a z0 tile holds
|
||||
~70,000 cells) and not counted as sequences: the panel says to zoom in.
|
||||
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
|
||||
Above 60 km nothing is drawn and the panel says to zoom in.
|
||||
A tile decodes in slices of about 8 ms, the page getting a turn between
|
||||
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
|
||||
tiles back for its Retry-After, which the browser cannot read here (the API
|
||||
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
|
||||
answers; an answer may take as long as it keeps arriving (silent for 15 s
|
||||
is given up), so a slow link still gets its megabyte tiles. Both
|
||||
filters apply: lines carry their day and camera type, and a cell stays while
|
||||
it holds pictures of the kind asked for (cells are undated). A click on a
|
||||
filters apply: lines carry their day and camera type. A click on a
|
||||
line asks the core (`actions.openNearest`) for that sequence's picture
|
||||
nearest the click, within the camera height / 100 (50–400 m: from high up
|
||||
a click lands well off a line), without framing, then any sequence's within
|
||||
@@ -3741,8 +3777,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
|
||||
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
|
||||
against a production build served by `vite preview` with a dummy token.
|
||||
needs no network. It opens and closes a photo from Mapillary, KartaView and
|
||||
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
|
||||
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
|
||||
|
||||
Directions is a keyless front end to the routing the voice agent already
|
||||
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
||||
@@ -4380,7 +4417,7 @@ silently demoting every later lookup for the session.
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||
- **CCTV v2 foundation:** a pitched
|
||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only —
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
||||
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
||||
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
||||
@@ -4398,7 +4435,7 @@ silently demoting every later lookup for the session.
|
||||
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
|
||||
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
|
||||
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
|
||||
periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||
periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
||||
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
||||
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
||||
@@ -5035,7 +5072,7 @@ pins and share links already supply complete boxes.
|
||||
Pixel-mode pinch deltas receive bounded amplification before reaching the
|
||||
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
|
||||
unchanged. The application scene owns and removes the relay listener.
|
||||
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
|
||||
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
|
||||
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
||||
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
||||
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
||||
@@ -5246,8 +5283,8 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
samples and constant elevation during E/N drag; one shared-floor resolution on
|
||||
release; late one-shot shared-cell work is permitted during viewshed idle. The
|
||||
A+B harness intentionally excludes citywide LOD assertions.
|
||||
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
|
||||
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
||||
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
|
||||
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
||||
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
|
||||
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
|
||||
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
|
||||
|
||||
@@ -108,63 +108,3 @@ 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.
|
||||
|
||||
## Recorded tracks
|
||||
|
||||
Version 7 adds a second kind of explicit move: a recorded track, which is what
|
||||
[VCAM](VCAM.md) takes write. Instead of `from` and `easing`, `move.track` holds
|
||||
timed samples:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "take-1",
|
||||
"title": "Take 1",
|
||||
"durationSec": 6.4,
|
||||
"holdSec": 0,
|
||||
"camera": { "lat": 30.27, "lon": -97.74, "alt": 950, "heading": 20,
|
||||
"pitch": -25, "roll": 0, "altitudeReference": "ellipsoid" },
|
||||
"move": {
|
||||
"track": {
|
||||
"altitudeReference": "ellipsoid",
|
||||
"samples": [
|
||||
[0, -97.7431, 30.2672, 900, 10, -30, 0, 24],
|
||||
[6.4, -97.7400, 30.2700, 950, 20, -25, 0, 50]
|
||||
]
|
||||
}
|
||||
},
|
||||
"lens": { "focalMm": 50, "dof": { "enabled": true, "focusM": 300, "fStop": 2, "scale": 100 } }
|
||||
}
|
||||
```
|
||||
|
||||
Each sample is `[t, lon, lat, alt, heading, pitch, roll, focalMm?]`: seconds from
|
||||
the start of the take, then the same coordinate, height and orientation
|
||||
conventions as a pose, then an optional 35 mm-equivalent focal length (4 to
|
||||
2,000 mm). The first sample is at 0 seconds and times strictly increase. A track
|
||||
has 2 to 10,000 samples and requires the ellipsoid altitude reference. The shot
|
||||
`camera` stays the destination: the final sample, which Load, Capture and the
|
||||
ordinary flight from the previous shot use.
|
||||
|
||||
Playback stretches the recorded time to `durationSec`, so changing the duration
|
||||
retimes the take without touching its samples. Between samples, latitude, height,
|
||||
pitch and focal length interpolate linearly; longitude, heading and roll take
|
||||
the short arc. A time that lands on a sample returns that sample exactly,
|
||||
including both ends. Playback, Load, Replay and seek all use
|
||||
`sampleCameraMove`, which dispatches to `sampleCameraTrack` for tracks.
|
||||
|
||||
## Lens
|
||||
|
||||
Version 7 shots may carry an optional `lens`:
|
||||
|
||||
- `focalMm`: 35 mm-equivalent focal length across the frame's longer side
|
||||
(4 to 2,000 mm), applied to the camera's field of view. Cesium's default
|
||||
60-degree view is about 31 mm.
|
||||
- `dof`: depth of field, with `enabled`, `focusM` (focus distance in metres,
|
||||
0.1 to 10⁸), `fStop` (0.5 to 64) and `scale` (0.001 to 10⁷). The blur is a
|
||||
thin-lens circle of confusion computed after dividing distances by `scale`,
|
||||
so a scale of 1,000 renders the scene as a 1:1000 miniature.
|
||||
|
||||
A shot's lens is applied after its visual state. A shot without one restores
|
||||
the application's field of view and turns depth of field off, so older shots
|
||||
look as they always did. Capture and Update Shot store the live lens only when
|
||||
it differs from the default. A track's per-sample focal length overrides
|
||||
`focalMm` while the track plays.
|
||||
@@ -57,9 +57,8 @@ Related keys (current versions):
|
||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
|
||||
`'1'` closed, absent means the panel's own default. A view opened from a share
|
||||
link ignores the stored value entirely.
|
||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
|
||||
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
|
||||
the panel header, docks the panel again.
|
||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
|
||||
writes none.
|
||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||
|
||||
---
|
||||
|
||||
@@ -103,8 +103,3 @@ 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.
|
||||
|
||||
Version 7 is the write format. It additionally accepts recorded camera tracks
|
||||
(`move.track`) and an optional shot `lens` (focal length and depth of field), as
|
||||
defined in [camera directions](DIRECTOR-CAMERA.md#recorded-tracks). Versions 1
|
||||
to 6 import unchanged; their shots receive no lens and keep the default view.
|
||||
+2
-2
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
|
||||
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
|
||||
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
|
||||
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
|
||||
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
# VCAM: a phone as the virtual camera
|
||||
|
||||
VCAM turns a phone on the same network into a handheld camera for the globe,
|
||||
like Unreal's Live Link VCAM or Blender's VirtuCamera. Walk around the room
|
||||
holding the phone and the app's camera moves with it. A motion scale decides
|
||||
how big a step is: 1:1 walks the street, 1:100 is a drone, 1:1000 flies over
|
||||
the city. The phone shows the app's render as a monitor, and **REC** records the
|
||||
move as a take: a shot in the selected scene that replays, seeks and retimes
|
||||
like any other shot.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Open **SCENES › VCAM**. The card shows a QR code, a six-character code and
|
||||
the phone URL (`https://<this computer>:4174/`).
|
||||
2. Scan the code with the phone's camera app, or open the URL and type the
|
||||
code. The phone and the computer must be on the same network.
|
||||
3. The page warns about the certificate the first time (see [Trust](#trust));
|
||||
continue past it, or install the local certificate once.
|
||||
4. Choose **START AR CAMERA** (6DoF) or **START GYRO CAMERA** (3DoF).
|
||||
5. Hold the phone like a camera. The rig starts from wherever the globe camera
|
||||
is, so nothing jumps.
|
||||
6. **REC** on the phone or in the card records a take. Takes are added to the
|
||||
scene selected in the Scenes panel.
|
||||
|
||||
| Control | Phone | Card | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Motion scale | SCALE drawer | SCALE | World metres per phone metre, 0.01 to 100,000. Presets: 1:1 walk, 1:10 crane, 1:100 drone, 1:1000 flyover, 1:10000 orbit. Changing it never moves the camera. |
|
||||
| Move stick | left | | Dolly and truck along the camera's heading, at 2.5 m/s × scale. |
|
||||
| Crane · pan stick | right | | Raise or lower the rig; pan about the camera itself. |
|
||||
| Hold | HOLD | HOLD / RESUME | Freeze the camera to reposition yourself (a clutch). Release re-anchors where the camera is. |
|
||||
| Recenter | RECENTER | RECENTER | Keep the camera where it is and point the phone's current heading along it. |
|
||||
| Smoothing | SCALE drawer | | Damps hand shake (0 raw to 100 heavy). |
|
||||
| Height lock | SCALE drawer | | Ignore the phone's vertical motion. |
|
||||
| Horizon lock | SCALE drawer | | Keep the horizon level (no roll). |
|
||||
| Focal length | LENS drawer | LENS | 35 mm-equivalent, 12 to 400 mm. Recorded per sample. |
|
||||
| Depth of field | LENS drawer | DEPTH OF FIELD | Thin-lens blur; tap the phone's picture to focus. |
|
||||
| Aperture | LENS drawer | APERTURE | f/1.2 to f/22. |
|
||||
| Record | ● | ● REC | Start and stop a take. |
|
||||
|
||||
The rig yields the camera whenever something else takes it, as scene playback
|
||||
does: a search or route, a scene starting, Cockpit, or a mouse press or wheel on
|
||||
the globe. It then reads **Held**. Tap HOLD on the phone (or RESUME) to continue
|
||||
from wherever the camera now is. Cockpit refuses the camera, as it does for
|
||||
scenes.
|
||||
|
||||
### Tracking: what each phone gets
|
||||
|
||||
| Phone and browser | Mode | Position | Rotation |
|
||||
| --- | --- | --- | --- |
|
||||
| Android Chrome with Google Play Services for AR | AR (WebXR `immersive-ar`) | tracked (6DoF) | tracked |
|
||||
| iPhone Safari | Gyro (DeviceOrientation) | sticks only | tracked (3DoF) |
|
||||
| Other browsers without WebXR AR | Gyro | sticks only | tracked |
|
||||
|
||||
iPhone Safari does not offer WebXR AR, so a web page cannot read ARKit's
|
||||
position. Gyro mode tracks the phone's rotation and the sticks move it. WebXR
|
||||
browsers that do offer `immersive-ar` on iOS get the AR mode automatically.
|
||||
|
||||
### Depth of field and the miniature look
|
||||
|
||||
The blur follows a thin-lens circle of confusion. Distances are divided by the
|
||||
motion scale first, so the lens treats the world at the rig's size. At 1:1 a
|
||||
city a kilometre away is effectively sharp, as it is through a real lens. At
|
||||
1:1000 the same city focuses like a tabletop model and falls off like
|
||||
tilt-shift miniature photography. Tap the phone's picture to set the focus
|
||||
distance at that point.
|
||||
|
||||
## Takes in the Director
|
||||
|
||||
A take is a version-7 scene-document shot with a recorded track
|
||||
([camera directions](DIRECTOR-CAMERA.md#recorded-tracks)). Each sample stores
|
||||
time, position, heading, pitch, roll and focal length, about 30 per second. A
|
||||
take holds at most 10,000 samples (about 5½ minutes); recording stops and
|
||||
saves at that limit. Editing the shot's `durationSec` retimes the take: a longer
|
||||
duration plays the same path in slow motion. Depth of field is stored in the
|
||||
shot's `lens`. Takes export, import and share like any other shot.
|
||||
|
||||
Takes live in the browser's scene storage like other shots. A browser that
|
||||
refuses to save a long take keeps it for the session and shows the usual
|
||||
storage warning; use EXPORT PRESETS to keep it.
|
||||
|
||||
## Trust
|
||||
|
||||
Phones expose motion tracking only to secure (HTTPS) pages, so the phone
|
||||
listener serves HTTPS with a certificate from a local certificate authority it
|
||||
creates on first use (`.gev-cache/vcam/`, git-ignored, private keys owner-only).
|
||||
The server certificate names this computer's current LAN addresses and is
|
||||
reissued when they change; the CA stays the same.
|
||||
|
||||
- **Android Chrome:** continue past the warning once. AR and the live
|
||||
connection work.
|
||||
- **iPhone Safari:** the page loads past the warning, but Safari refuses its
|
||||
WebSocket, so the phone falls back to HTTPS requests (an amber dot rather
|
||||
than green). It works with a little more latency. To trust the computer:
|
||||
open **Phone certificate** in the card (or the link on the phone's pairing
|
||||
page), allow the profile, install it under **Settings › General › VPN &
|
||||
Device Management**, then enable it under **Settings › General › About ›
|
||||
Certificate Trust Settings**.
|
||||
|
||||
The CA is constrained to sign server certificates only (path length 0). Remove
|
||||
the profile from the phone, or delete `.gev-cache/vcam/`, to revoke it.
|
||||
|
||||
## How it is built
|
||||
|
||||
```
|
||||
phone (HTTPS :4174) desktop app (http://localhost:4173)
|
||||
server/vcam/phone/* ── pose JSON ──▶ relay ──▶ /api/vcam/host (WebSocket)
|
||||
◀── state ───── ◀── src/vcam/controller.js
|
||||
◀── JPEG pull ── newest frame only
|
||||
```
|
||||
|
||||
- **`server/vcam/plugin.js`**: on the app's server, loopback-only routes behind
|
||||
the `/mcp` gate (`POST`/`DELETE /api/vcam/session`, `/api/vcam/ca.crt`, the
|
||||
host WebSocket). It starts a separate HTTPS listener (`VCAM_PORT`, default
|
||||
4174, on `VCAM_HOST`, default all interfaces) only when a session is created,
|
||||
and stops it two minutes after the last one ends. That listener serves the
|
||||
static phone page and the phone side of the relay, never the app or its
|
||||
provider proxies.
|
||||
- **`server/vcam/relay.js`**: pairing and forwarding, independent of transport.
|
||||
The phone exchanges the six-character code (31 unambiguous characters, about
|
||||
30 bits, case and separators ignored) for its own key; eight wrong codes
|
||||
rotate every session's code and the card updates. Messages are JSON objects of
|
||||
at most 4 KiB, at most 120 a second with a burst of 240. The host's JPEG
|
||||
preview is held as the newest frame only; the phone pulls it, so a slow link
|
||||
drops frames instead of queueing them.
|
||||
- **`server/vcam/tls.js`**: the local CA and server certificate, DER-encoded with
|
||||
`node:crypto`; there is no OpenSSL or dependency.
|
||||
- **`server/vcam/phone/`**: the phone page. It uses a WebSocket when it can,
|
||||
otherwise POSTs poses (newest-wins) and listens on server-sent events. Poses
|
||||
are a quaternion and position in WebXR's Y-up frame (+x right, +y up, −z
|
||||
forward); gyro mode converts DeviceOrientation into the same frame.
|
||||
- **`src/vcam/rig.js`**: the renderer-independent mapping from phone pose to an
|
||||
east-north-up camera offset and basis around a geographic anchor. Recenter,
|
||||
scale changes and stick moves shift the anchor rather than the phone's origin,
|
||||
so the camera stays continuous.
|
||||
- **`src/vcam/controller.js`**: session lifecycle, camera ownership, per-frame
|
||||
placement (`scene.preUpdate`), recording and preview capture
|
||||
(`scene.postRender`, 720 px JPEG at 12 frames a second).
|
||||
- **`src/scenes/cameraLens.js`**: focal length (frustum field of view) and the
|
||||
depth-of-field post-process stage, shared with scene playback.
|
||||
- **`src/vcam/qr.js`**: a QR encoder (byte mode, level M, versions 1–10).
|
||||
|
||||
`node scripts/qa-vcam.mjs` drives all of it end to end against a running dev
|
||||
server: the real phone page in gyro mode over the HTTPS fallback, a scripted
|
||||
6DoF phone over a WebSocket, a take recorded and replayed, and camera handoff.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | |
|
||||
| --- | --- | --- |
|
||||
| `VCAM_PORT` | `4174` | Phone listener port. |
|
||||
| `VCAM_HOST` | `0.0.0.0` | Phone listener interface. Set an address to restrict it. |
|
||||
|
||||
The Docker image publishes only the app's port on loopback, so VCAM needs the
|
||||
phone port published as well (see [SECURITY.md](../SECURITY.md)).
|
||||
|
||||
## Next
|
||||
|
||||
- Preview over WebRTC (`canvas.captureStream`) for lower latency and higher
|
||||
resolution, with the JPEG pull kept as the fallback.
|
||||
- Bookmarks and a take browser in the phone UI; slate and take naming.
|
||||
- Focus pulls and animated aperture recorded per sample.
|
||||
- A full-quality offline render of a take (fixed-step, every frame).
|
||||
- iPhone 6DoF through an ARKit companion (an App Clip or a small native app
|
||||
speaking the same relay protocol).
|
||||
- Phone page translation through the app's catalogs.
|
||||
+2
-2
@@ -49,7 +49,6 @@
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
|
||||
"qa:street-level": "node scripts/qa-street-level.mjs",
|
||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||
@@ -277,6 +276,7 @@
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js"
|
||||
"./view": "./src/view/index.js",
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
|
||||
nearestLookups = 0;
|
||||
}
|
||||
|
||||
/** Nearest lookups answered since the last reset (the first got a 429). */
|
||||
export function kartaViewLookups() {
|
||||
return nearestLookups;
|
||||
}
|
||||
|
||||
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||
export function kartaViewPhotos() {
|
||||
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Mapillary Graph API and image CDN answered from fixtures, covering every
|
||||
* request the provider and MapillaryJS make while opening, stepping and
|
||||
* closing a photo, so the gate needs no network and no real token.
|
||||
* request the provider and MapillaryJS make while opening and closing a
|
||||
* photo, so the gate needs no network and no real token.
|
||||
*
|
||||
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
|
||||
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
|
||||
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
|
||||
north: 38.591,
|
||||
});
|
||||
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
||||
export const PHOTO_SPACING_M = 30;
|
||||
const PHOTO_SPACING_M = 30;
|
||||
/** Fixture thumbnail host (answered, never reached). */
|
||||
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||
|
||||
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
|
||||
height: 700,
|
||||
pitch: -1.4,
|
||||
});
|
||||
export const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_SPACING_M = 10;
|
||||
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
||||
const API = 'https://api.panoramax.xyz/api';
|
||||
|
||||
+30
-33
@@ -46,9 +46,6 @@
|
||||
"scripts/qa-voice-auth-focus.mjs",
|
||||
"src/codexOauthRealtime.test.mjs",
|
||||
"src/voice/cloudVoiceAuth.test.mjs",
|
||||
"scripts/qa-panel-resize.mjs",
|
||||
"scripts/qa-panelDrag.mjs",
|
||||
"src/tooling/panelResizeQa.test.mjs",
|
||||
"scripts/qa-terrain-429.mjs",
|
||||
"src/app/application.js",
|
||||
"src/app/application.test.mjs",
|
||||
@@ -96,6 +93,7 @@
|
||||
"server/providers/space/launch-library.js",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
"src/tooling/spaceProviders.test.mjs",
|
||||
"src/tooling/ll2Integration.test.mjs",
|
||||
"server/providers/terrain.js",
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/firms.js",
|
||||
@@ -181,7 +179,6 @@
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelResize.test.mjs",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.test.mjs",
|
||||
@@ -284,6 +281,7 @@
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelPositionControls.test.mjs",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
@@ -446,6 +444,7 @@
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -555,6 +554,8 @@
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/cctv/frames.test.mjs",
|
||||
"src/layers/cctv/rendering.test.mjs",
|
||||
"src/data/alprCameras.js",
|
||||
"src/data/alprCameras.test.mjs",
|
||||
"src/data/dataCredits.test.mjs",
|
||||
@@ -635,6 +636,7 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sourceSlot.js",
|
||||
@@ -690,6 +692,7 @@
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/ui/panelChrome.js",
|
||||
"src/ui/panelChrome.radioOwnership.test.mjs",
|
||||
"src/testSupport/readShellSource.mjs",
|
||||
"src/ui/stateOwners.test.mjs",
|
||||
"src/layers/flights/records.js",
|
||||
@@ -906,27 +909,32 @@
|
||||
"src/ui/streetLevelPresentation.test.mjs",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/tooling/mapillaryProvider.test.mjs",
|
||||
"src/app/layers/streetLevel.test.mjs",
|
||||
"src/data/idSwitches.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||
"src/layers/streetLevel/providerSwitches.test.mjs",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/uiState.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
||||
"src/streetLevelMarkup.test.mjs",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/groundCast.js",
|
||||
"src/layers/streetLevel/meshSampler.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
@@ -939,7 +947,6 @@
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/groundClick.test.mjs",
|
||||
"src/app/layers/streetLevel.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
||||
@@ -949,8 +956,6 @@
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
||||
@@ -979,8 +984,6 @@
|
||||
"src/testSupport/kartaViewFakes.mjs",
|
||||
"src/testSupport/googleStreetViewFakes.mjs",
|
||||
"server/providers/mapillary.js",
|
||||
"server/providers/mapillary/routes.js",
|
||||
"server/providers/mapillary/constants.js",
|
||||
"server/providers/mapillary/tiles.js",
|
||||
"server/providers/mapillary/trim.js",
|
||||
"src/layers/streetLevel/tileMath.test.mjs",
|
||||
@@ -993,23 +996,15 @@
|
||||
"scripts/fixtures/street-level/kartaView.mjs",
|
||||
"scripts/fixtures/street-level/panoramax.mjs",
|
||||
"src/layers/streetLevel/filter.test.mjs",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||
"src/layers/streetLevel/uiState.test.mjs",
|
||||
"src/layers/streetLevel/groundCast.test.mjs",
|
||||
"src/layers/streetLevel/meshSampler.test.mjs",
|
||||
"src/layers/streetLevel/view.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/cameraFollow.test.mjs",
|
||||
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/ui/panelDock.test.mjs",
|
||||
"src/layers/streetLevel/marker.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
|
||||
"src/layers/streetLevel/sequenceCones.test.mjs",
|
||||
"src/layers/streetLevel/selection.test.mjs",
|
||||
"src/layers/streetLevel/index.test.mjs",
|
||||
"scripts/qa-overpass-offload.mjs",
|
||||
@@ -1026,6 +1021,7 @@
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
"src/tools/catalog.test.mjs",
|
||||
"src/tools/area.test.mjs",
|
||||
"src/tools/results.test.mjs",
|
||||
"src/tools/queries/queries.test.mjs",
|
||||
"src/tools/mcp/mcp.test.mjs",
|
||||
"src/tools/localServer.test.mjs",
|
||||
@@ -1062,5 +1058,6 @@
|
||||
"src/ui/flyToDataSource.test.mjs",
|
||||
"scripts/assemble-changelog.mjs",
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs"
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs"
|
||||
]
|
||||
@@ -114,7 +114,11 @@
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
"src/data/terrainHeightsProxy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -210,8 +214,6 @@
|
||||
"exports": ["./server/providers/mapillary"],
|
||||
"modules": [
|
||||
"server/providers/mapillary.js",
|
||||
"server/providers/mapillary/routes.js",
|
||||
"server/providers/mapillary/constants.js",
|
||||
"server/providers/mapillary/tiles.js",
|
||||
"server/providers/mapillary/trim.js",
|
||||
"server/providers/common/same-site.js",
|
||||
@@ -720,7 +722,6 @@
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelResize.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/radio.js",
|
||||
@@ -753,6 +754,7 @@
|
||||
"src/ui/shellFacade.js",
|
||||
"src/ui/streetLevelControls.js",
|
||||
"src/ui/streetLevelPresentation.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/ui/localSdrControls.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/ui/localSdrPresentation.js",
|
||||
@@ -1040,6 +1042,7 @@
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
@@ -1378,15 +1381,13 @@
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/groundCast.js",
|
||||
"src/layers/streetLevel/meshSampler.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
@@ -1394,9 +1395,8 @@
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
@@ -1423,14 +1423,13 @@
|
||||
"src/layers/streetLevel/imageSize.js",
|
||||
"src/layers/streetLevel/tileCache.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"scripts/qa-street-level.mjs",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
},
|
||||
@@ -1628,7 +1627,15 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCommands.js",
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1841,6 +1848,7 @@
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
"src/hud.js",
|
||||
"src/hudLocality.js",
|
||||
@@ -2189,7 +2197,6 @@
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelResize.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/radio.js",
|
||||
@@ -2215,6 +2222,7 @@
|
||||
"src/voice/commands.js",
|
||||
"src/voice/control.js",
|
||||
"src/voice/gevActions.js",
|
||||
"src/agent/worldPacket.js",
|
||||
"src/voice/speech.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/voice/earcon.js",
|
||||
@@ -2408,24 +2416,22 @@
|
||||
"src/ui/streetLevelControls.js",
|
||||
"src/ui/streetLevelPresentation.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/groundCast.js",
|
||||
"src/layers/streetLevel/meshSampler.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"scripts/qa-street-level.mjs",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
@@ -2452,8 +2458,6 @@
|
||||
"src/layers/streetLevel/imageSize.js",
|
||||
"src/layers/streetLevel/tileCache.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
@@ -2466,7 +2470,6 @@
|
||||
"src/data/cameraArrival.js",
|
||||
"src/layers/alpr/floors.js",
|
||||
"src/data/militaryNames.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
@@ -2491,12 +2494,12 @@
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/ui/statusText.js",
|
||||
"src/scenes/cameraLens.js",
|
||||
"src/director/lens.js",
|
||||
"src/vcam/controller.js",
|
||||
"src/vcam/rig.js",
|
||||
"src/vcam/qr.js",
|
||||
"src/ui/vcamPanel.js"
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/voice/providerCommands.js",
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2635,6 +2638,7 @@
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/alpr/index.js",
|
||||
@@ -2990,24 +2994,22 @@
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/groundCast.js",
|
||||
"src/layers/streetLevel/meshSampler.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"scripts/qa-street-level.mjs",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
@@ -3034,8 +3036,6 @@
|
||||
"src/layers/streetLevel/imageSize.js",
|
||||
"src/layers/streetLevel/tileCache.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
@@ -3048,7 +3048,6 @@
|
||||
"src/data/cameraArrival.js",
|
||||
"src/layers/alpr/floors.js",
|
||||
"src/data/militaryNames.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
@@ -3066,7 +3065,8 @@
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/layerNames.js"
|
||||
"src/ui/layerNames.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3230,7 +3230,6 @@
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelResize.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
@@ -3383,7 +3382,8 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCapabilities.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3788,6 +3788,7 @@
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/tools/queries/share.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/sources/gbfsStations.js",
|
||||
@@ -3949,5 +3950,18 @@
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"agent-world-packet": {
|
||||
"exports": ["./agent/world-packet"],
|
||||
"modules": [
|
||||
"src/agent/worldPacket.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Console messages and failed requests kept per page. */
|
||||
export const EVIDENCE_LIMIT = 200;
|
||||
const EVIDENCE_LIMIT = 200;
|
||||
|
||||
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
||||
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||
|
||||
/** Mask tokens in a URL or message before it is printed or saved. */
|
||||
export function redact(text) {
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser QA for the floating CCTV panel: lift, resize from every handle,
|
||||
* minimum size, reload, and dock again. `--fail-on-retry` (or
|
||||
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
|
||||
import {
|
||||
describePress,
|
||||
dockPanelByDoubleClick,
|
||||
dragPointer,
|
||||
liftPanelByHeader,
|
||||
nextFrames,
|
||||
} from './qa-panelDrag.mjs';
|
||||
import {
|
||||
hookRenderErrors,
|
||||
readRenderErrors,
|
||||
saveFailureArtifacts,
|
||||
watchPage,
|
||||
} from './qa-browserEvidence.mjs';
|
||||
|
||||
export const PANEL_ID = 'cctv-panel';
|
||||
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
|
||||
export const MIN_SIZE = { width: 300, height: 160 };
|
||||
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
|
||||
const VIEWPORT = { width: 1400, height: 900 };
|
||||
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
|
||||
|
||||
/** Box edges a resize in `dir` must leave exactly where they were. */
|
||||
export function pinnedEdges(dir) {
|
||||
return Object.entries(EDGE_BY_SIDE)
|
||||
.filter(([side]) => !dir.includes(side))
|
||||
.map(([, edge]) => edge);
|
||||
}
|
||||
|
||||
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
|
||||
export function growthDelta(dir, amount) {
|
||||
return {
|
||||
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
|
||||
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport point inside the handle for `dir`, given the panel's client rect.
|
||||
* Edge strips straddle the border by 3px; corners and the grip sit inside.
|
||||
*/
|
||||
export function handlePoint(rect, dir) {
|
||||
const inset = dir.length === 2 ? 4 : 1;
|
||||
const x = dir.includes('w')
|
||||
? rect.left + inset
|
||||
: dir.includes('e')
|
||||
? rect.right - inset
|
||||
: rect.left + rect.width / 2;
|
||||
const y = dir.includes('n')
|
||||
? rect.top + inset
|
||||
: dir.includes('s')
|
||||
? rect.bottom - inset
|
||||
: rect.top + rect.height / 2;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Pinned edges that moved during a resize in `dir`. */
|
||||
export function driftedEdges(before, after, dir, tolerance = 1.5) {
|
||||
const edges = (box) => ({
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
right: box.left + box.width,
|
||||
bottom: box.top + box.height,
|
||||
});
|
||||
const a = edges(before);
|
||||
const b = edges(after);
|
||||
return pinnedEdges(dir).filter(
|
||||
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
|
||||
);
|
||||
}
|
||||
|
||||
function near(actual, expected, tolerance, label) {
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
const args = process.argv.slice(2);
|
||||
const urlIndex = args.indexOf('--url');
|
||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
(await puppeteer.executablePath()),
|
||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const monitors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(VIEWPORT);
|
||||
const errors = [];
|
||||
monitors.push(watchPage(page, { name: 'panel', errors }));
|
||||
await hookRenderErrors(page);
|
||||
|
||||
const boot = async () => {
|
||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
|
||||
timeout: 90_000,
|
||||
});
|
||||
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.first-run-explore')?.click();
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('first-run-launcher')?.remove();
|
||||
for (const node of document.querySelectorAll('[class*=first-run]'))
|
||||
node.remove();
|
||||
});
|
||||
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
|
||||
await nextFrames(page);
|
||||
};
|
||||
const readPanel = () =>
|
||||
page.evaluate((id) => {
|
||||
const panel = document.getElementById(id);
|
||||
const rect = panel.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
// handlePoint() reads the far edges for s/e handles.
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
floating: panel.classList.contains('panel-floating'),
|
||||
collapsed: panel.classList.contains('collapsed'),
|
||||
inRail: panel.parentElement?.id === 'right-context-rail',
|
||||
allocated: panel.style.getPropertyValue(
|
||||
'--right-panel-allocated-height',
|
||||
),
|
||||
inlineWidth: panel.style.width,
|
||||
inlineHeight: panel.style.height,
|
||||
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
|
||||
};
|
||||
}, PANEL_ID);
|
||||
/**
|
||||
* Best effort: wait for the box to hold still for half a second. The lift
|
||||
* is checked against the box at pointerdown, so a timeout only costs time.
|
||||
*/
|
||||
const settle = async () => {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const box = [r.left, r.top, r.width, r.height]
|
||||
.map(Math.round)
|
||||
.join(',');
|
||||
const now = performance.now();
|
||||
if (window.__qaLastBox !== box) {
|
||||
window.__qaLastBox = box;
|
||||
window.__qaSince = now;
|
||||
}
|
||||
return now - window.__qaSince >= 500;
|
||||
},
|
||||
{ polling: 100, timeout: 20_000 },
|
||||
PANEL_ID,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
console.log('note: the CCTV panel was still moving; lifting anyway');
|
||||
}
|
||||
};
|
||||
const headerPoint = async () => {
|
||||
const rect = await page.evaluate((id) => {
|
||||
const title = document.querySelector(
|
||||
`#${id} .panel-header .panel-title`,
|
||||
);
|
||||
const r = title.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}, PANEL_ID);
|
||||
return rect;
|
||||
};
|
||||
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
|
||||
|
||||
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate((key) => {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
|
||||
}, STORAGE_KEY);
|
||||
await boot();
|
||||
|
||||
await page.evaluate((id) => {
|
||||
const panel = document.getElementById(id);
|
||||
if (panel.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
|
||||
.click();
|
||||
}, PANEL_ID);
|
||||
await page.waitForFunction(
|
||||
(id) => !document.getElementById(id).classList.contains('collapsed'),
|
||||
{},
|
||||
PANEL_ID,
|
||||
);
|
||||
// The rail animates panel heights, and on a slow software renderer the
|
||||
// panels above CCTV can still be moving after a fixed sleep.
|
||||
await settle();
|
||||
const docked = await readPanel();
|
||||
assert.equal(docked.floating, false);
|
||||
assert.equal(docked.inRail, true);
|
||||
|
||||
// Names whatever above CCTV in the rail shifted it, for a retry note.
|
||||
const railAbove = () =>
|
||||
page.evaluate((id) => {
|
||||
const out = [];
|
||||
for (const node of document.getElementById('right-context-rail')
|
||||
?.children || []) {
|
||||
if (node.id === id) break;
|
||||
out.push(
|
||||
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
|
||||
);
|
||||
}
|
||||
return out.join(' ');
|
||||
}, PANEL_ID);
|
||||
const aboveAtDock = await railAbove();
|
||||
// On CI a panel above CCTV grows after load and can shift the header out
|
||||
// from under the press; the helper retries only such a provable miss.
|
||||
const attempt = await liftPanelByHeader(page, PANEL_ID, {
|
||||
dx: -200,
|
||||
dy: -100,
|
||||
note: async () =>
|
||||
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
|
||||
});
|
||||
const { pressed } = attempt;
|
||||
const lifted = await readPanel();
|
||||
assert.equal(
|
||||
lifted.floating,
|
||||
true,
|
||||
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
|
||||
);
|
||||
assert.equal(
|
||||
lifted.collapsed,
|
||||
false,
|
||||
'the drag-ending click must not collapse',
|
||||
);
|
||||
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
|
||||
near(
|
||||
lifted.left,
|
||||
Math.max(6, pressed.left - 200),
|
||||
2,
|
||||
`lifted left (${moved})`,
|
||||
);
|
||||
near(
|
||||
lifted.top,
|
||||
Math.max(6, pressed.top - 100),
|
||||
2,
|
||||
`lifted top (${moved})`,
|
||||
);
|
||||
near(lifted.width, pressed.width, 2, 'lifted width');
|
||||
// The rail lets go of the lifted panel on its next layout pass.
|
||||
await page
|
||||
.waitForFunction(
|
||||
(id) =>
|
||||
document
|
||||
.getElementById(id)
|
||||
.style.getPropertyValue('--right-panel-allocated-height') === '',
|
||||
{ timeout: 5_000 },
|
||||
PANEL_ID,
|
||||
)
|
||||
.catch(() => {});
|
||||
const afterLayout = await readPanel();
|
||||
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
|
||||
const railExcludes = await page.evaluate((id) => {
|
||||
const rail = document.getElementById('right-context-rail');
|
||||
// Same rule as the rail: hidden panels take no room.
|
||||
const counted = [...rail.children].filter((panel) =>
|
||||
panel.matches(
|
||||
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
|
||||
),
|
||||
);
|
||||
return {
|
||||
expandedCount: rail.dataset.expandedCount,
|
||||
counted: counted.length,
|
||||
includesPanel: counted.some((panel) => panel.id === id),
|
||||
};
|
||||
}, PANEL_ID);
|
||||
assert.equal(railExcludes.includesPanel, false);
|
||||
if (railExcludes.expandedCount !== undefined)
|
||||
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
|
||||
const storedAfterLift = JSON.parse(afterLayout.stored);
|
||||
assert.equal(storedAfterLift.floating, true);
|
||||
assert.equal(
|
||||
'height' in storedAfterLift,
|
||||
false,
|
||||
'a drag alone must not freeze the measured height',
|
||||
);
|
||||
console.log('PASS: header drag lifts CCTV out of the rail');
|
||||
|
||||
// Park near the top: the voice dock would cover the south corners once
|
||||
// the loop below has grown the window.
|
||||
{
|
||||
const header = await headerPoint();
|
||||
await drag(header, 0, 120 - header.y);
|
||||
}
|
||||
|
||||
const limits = {
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
viewportWidth: VIEWPORT.width,
|
||||
viewportHeight: VIEWPORT.height,
|
||||
};
|
||||
for (const dir of RESIZE_DIRECTIONS) {
|
||||
const before = await readPanel();
|
||||
const { dx, dy } = growthDelta(dir, 40);
|
||||
const point = handlePoint(before, dir);
|
||||
const hit = await page.evaluate(({ x, y }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
if (!node) return 'nothing';
|
||||
const dirAttr = node.dataset?.dir
|
||||
? `[data-dir=${node.dataset.dir}]`
|
||||
: '';
|
||||
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
|
||||
}, point);
|
||||
await drag(point, dx, dy);
|
||||
const after = await readPanel();
|
||||
const expected = resizeBox(before, dir, dx, dy, limits);
|
||||
for (const key of ['left', 'top', 'width', 'height'])
|
||||
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
|
||||
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
|
||||
}
|
||||
console.log('PASS: eight resize handles keep the opposite edge pinned');
|
||||
|
||||
let before = await readPanel();
|
||||
await drag(handlePoint(before, 'se'), -2000, -2000);
|
||||
let after = await readPanel();
|
||||
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
|
||||
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
|
||||
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
|
||||
before = after;
|
||||
await drag(handlePoint(before, 'nw'), 2000, 2000);
|
||||
after = await readPanel();
|
||||
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
|
||||
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
|
||||
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
|
||||
console.log('PASS: minimum size holds from both corners');
|
||||
|
||||
const saved = await readPanel();
|
||||
const record = JSON.parse(saved.stored);
|
||||
assert.equal(record.floating, true);
|
||||
await boot();
|
||||
const restored = await readPanel();
|
||||
assert.equal(restored.floating, true, 'floating survives a reload');
|
||||
for (const key of ['left', 'top', 'width', 'height'])
|
||||
near(restored[key], record[key], 2, `restored ${key}`);
|
||||
console.log('PASS: reload restores position and size');
|
||||
|
||||
// A collapsed window keeps the height chosen for it, through a drag of
|
||||
// its header strip and a reload.
|
||||
const setCollapsed = async (collapsed) => {
|
||||
await page.evaluate(
|
||||
(id, want) => {
|
||||
const panel = document.getElementById(id);
|
||||
if (panel.classList.contains('collapsed') !== want)
|
||||
document
|
||||
.querySelector(
|
||||
`.panel-collapse-btn[data-collapse-target="${id}"]`,
|
||||
)
|
||||
.click();
|
||||
},
|
||||
PANEL_ID,
|
||||
collapsed,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(id, want) =>
|
||||
document.getElementById(id).classList.contains('collapsed') === want,
|
||||
{},
|
||||
PANEL_ID,
|
||||
collapsed,
|
||||
);
|
||||
await nextFrames(page);
|
||||
};
|
||||
// Grow it from the corner once the restored window holds still, with the
|
||||
// pointer really on the handle (a press beside it selects page text).
|
||||
let chosen = await readPanel();
|
||||
const tried = [];
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
|
||||
attempt++
|
||||
) {
|
||||
await settle();
|
||||
const box = await readPanel();
|
||||
const corner = handlePoint(box, 'se');
|
||||
const hit = await page.evaluate(({ x, y }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
return {
|
||||
dir: node?.dataset?.dir || null,
|
||||
target: node
|
||||
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
|
||||
: 'nothing',
|
||||
};
|
||||
}, corner);
|
||||
tried.push(
|
||||
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
|
||||
);
|
||||
if (hit.dir === 'se') await drag(corner, 0, 200);
|
||||
chosen = await readPanel();
|
||||
}
|
||||
assert.ok(
|
||||
chosen.height > MIN_SIZE.height + 150,
|
||||
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
|
||||
);
|
||||
await setCollapsed(true);
|
||||
const strip = await readPanel();
|
||||
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
|
||||
await drag(await headerPoint(), 40, 30);
|
||||
const movedStrip = await readPanel();
|
||||
assert.equal(movedStrip.floating, true, 'the strip stays a window');
|
||||
near(
|
||||
JSON.parse(movedStrip.stored).height,
|
||||
chosen.height,
|
||||
2,
|
||||
'stored height while collapsed',
|
||||
);
|
||||
await boot();
|
||||
await setCollapsed(false);
|
||||
near(
|
||||
(await readPanel()).height,
|
||||
chosen.height,
|
||||
2,
|
||||
'height after expanding',
|
||||
);
|
||||
console.log(
|
||||
'PASS: a collapsed window keeps its height through a drag and a reload',
|
||||
);
|
||||
|
||||
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
|
||||
const snapped = await readPanel();
|
||||
assert.equal(
|
||||
snapped.floating,
|
||||
false,
|
||||
`double-click snaps back (${describePress(dock)})`,
|
||||
);
|
||||
assert.equal(snapped.inRail, true);
|
||||
assert.equal(snapped.inlineWidth, '');
|
||||
assert.equal(snapped.inlineHeight, '');
|
||||
assert.equal(snapped.stored, null, 'the storage key is gone');
|
||||
console.log('PASS: header double-click returns the panel to the rail');
|
||||
|
||||
const renderErrors = await readRenderErrors(page);
|
||||
assert.deepEqual(
|
||||
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
|
||||
[],
|
||||
'no page errors and no Cesium render-loop errors',
|
||||
);
|
||||
console.log('PASS: no page errors and no render-loop errors');
|
||||
} catch (error) {
|
||||
const dir = await saveFailureArtifacts({
|
||||
dir: ARTIFACT_DIR,
|
||||
browser,
|
||||
monitors,
|
||||
error,
|
||||
}).catch(() => null);
|
||||
if (dir) console.error(`failure evidence saved in ${dir}/`);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
/**
|
||||
* Header press-and-verify for the QA gates that lift a rail panel into a
|
||||
* floating window and dock it again. The rail animates panel heights, so a
|
||||
* point read once can be under another panel by the time the pointer goes
|
||||
* down. Only a provable miss (a recorded press outside the header) is
|
||||
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
|
||||
* panel's own movement cannot explain fails the run instead.
|
||||
*/
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Retries after the first attempt, and the pause before each. */
|
||||
export const PRESS_RETRIES = 2;
|
||||
const RETRY_PAUSE_MS = 1000;
|
||||
|
||||
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
|
||||
export function failOnRetryDefault(
|
||||
env = process.env,
|
||||
argv = process.argv.slice(2),
|
||||
) {
|
||||
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed attempt whose press landed outside the header. No recorded press
|
||||
* is a failure, not a miss: a retry would only hide it.
|
||||
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
|
||||
*/
|
||||
export function pressMissed(attempt) {
|
||||
return !attempt.done && attempt.pressed?.inHeader === false;
|
||||
}
|
||||
|
||||
/** Whether the panel's box changed by more than 2 px between two reads. */
|
||||
export function panelMoved(before, after) {
|
||||
if (!before || !after) return false;
|
||||
return ['left', 'top', 'width', 'height'].some(
|
||||
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
|
||||
);
|
||||
}
|
||||
|
||||
/** The panel's box, rounded, as `left,top widthxheight`. */
|
||||
export function formatBox(box) {
|
||||
if (!box) return 'unknown';
|
||||
const r = (value) => Math.round(value ?? NaN);
|
||||
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
|
||||
}
|
||||
|
||||
function panelBox(page, panelId) {
|
||||
return page.evaluate((id) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
}, panelId);
|
||||
}
|
||||
|
||||
export function headerPoint(page, panelId) {
|
||||
return page.evaluate((id) => {
|
||||
const title = document.querySelector(`#${id} .panel-header .panel-title`);
|
||||
const r = title.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}, panelId);
|
||||
}
|
||||
|
||||
/** Name the element at a point, and whether it is the panel's header. */
|
||||
export function hitAt(page, point, panelId) {
|
||||
return page.evaluate(
|
||||
({ x, y, id }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
if (!node) return { target: 'nothing', inHeader: false };
|
||||
return {
|
||||
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
|
||||
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
|
||||
};
|
||||
},
|
||||
{ ...point, id: panelId },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve the header point until it hits the header or the wait runs out
|
||||
* (the press then records the miss).
|
||||
*/
|
||||
export async function headerTarget(
|
||||
page,
|
||||
panelId,
|
||||
{ tries = 20, intervalMs = 500 } = {},
|
||||
) {
|
||||
let point = await headerPoint(page, panelId);
|
||||
let hit = await hitAt(page, point, panelId);
|
||||
for (let i = 0; i < tries && !hit.inHeader; i++) {
|
||||
await delay(intervalMs);
|
||||
point = await headerPoint(page, panelId);
|
||||
hit = await hitAt(page, point, panelId);
|
||||
}
|
||||
const box = await panelBox(page, panelId);
|
||||
return { point, hit, timedOut: !hit.inHeader, box };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort: wait until the panel's box has held still for `stillMs`, so
|
||||
* the header does not move between the hit check and the press.
|
||||
*/
|
||||
export async function waitForStill(
|
||||
page,
|
||||
panelId,
|
||||
{ stillMs = 300, timeout = 5000 } = {},
|
||||
) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id, ms) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
|
||||
const now = performance.now();
|
||||
if (window.__qaStillBox !== box) {
|
||||
window.__qaStillBox = box;
|
||||
window.__qaStillSince = now;
|
||||
}
|
||||
return now - window.__qaStillSince >= ms;
|
||||
},
|
||||
{ polling: 100, timeout },
|
||||
panelId,
|
||||
stillMs,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the next pointerdown's target and the panel's box at that moment. */
|
||||
export function recordNextPress(page, panelId) {
|
||||
return page.evaluate((id) => {
|
||||
window.__qaPressBox = null;
|
||||
window.addEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
const target = event.target;
|
||||
window.__qaPressBox = {
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
|
||||
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
|
||||
};
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
}, panelId);
|
||||
}
|
||||
|
||||
function readPress(page) {
|
||||
return page.evaluate(() => window.__qaPressBox ?? null);
|
||||
}
|
||||
|
||||
export async function waitForFloating(
|
||||
page,
|
||||
panelId,
|
||||
floating,
|
||||
{ timeout = 3000 } = {},
|
||||
) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id, want) =>
|
||||
document.getElementById(id).classList.contains('panel-floating') ===
|
||||
want,
|
||||
{ polling: 50, timeout },
|
||||
panelId,
|
||||
floating,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'TimeoutError') throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Two paints: by then every layout change an input caused is on screen. */
|
||||
export function nextFrames(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
|
||||
await page.mouse.move(from.x + dx, from.y + dy, { steps });
|
||||
await page.mouse.up();
|
||||
await nextFrames(page);
|
||||
}
|
||||
|
||||
export function describeRetry(
|
||||
panelId,
|
||||
attempt,
|
||||
{ round, retries, extra = '' },
|
||||
) {
|
||||
const { hit, pressed, timedOut, still, box } = attempt;
|
||||
return [
|
||||
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
|
||||
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
|
||||
`stillness ${still === false ? 'timed out' : 'held'}`,
|
||||
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
|
||||
]
|
||||
.concat(extra ? [extra] : [])
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** Retry `press` only while it provably misses the header. */
|
||||
async function pressUntilDecided(
|
||||
page,
|
||||
panelId,
|
||||
press,
|
||||
{
|
||||
retries,
|
||||
log,
|
||||
note,
|
||||
pauseMs = RETRY_PAUSE_MS,
|
||||
failOnRetry = failOnRetryDefault(),
|
||||
},
|
||||
) {
|
||||
for (let round = 0; ; round++) {
|
||||
const still = await waitForStill(page, panelId);
|
||||
await recordNextPress(page, panelId);
|
||||
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
|
||||
const done = await press(point);
|
||||
const attempt = {
|
||||
point,
|
||||
hit,
|
||||
timedOut,
|
||||
still,
|
||||
box,
|
||||
done,
|
||||
pressed: await readPress(page),
|
||||
retries: round,
|
||||
};
|
||||
if (round >= retries || !pressMissed(attempt)) return attempt;
|
||||
const why = describeRetry(panelId, attempt, {
|
||||
round,
|
||||
retries,
|
||||
extra: note ? await note() : '',
|
||||
});
|
||||
if (failOnRetry && !panelMoved(box, attempt.pressed))
|
||||
throw new Error(
|
||||
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
|
||||
);
|
||||
log(`note: ${why}; retrying`);
|
||||
log(`::warning title=Panel press retried::${why}`);
|
||||
await delay(pauseMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a docked panel into a floating window with a header drag.
|
||||
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
|
||||
*/
|
||||
export function liftPanelByHeader(
|
||||
page,
|
||||
panelId,
|
||||
{
|
||||
dx,
|
||||
dy,
|
||||
retries = PRESS_RETRIES,
|
||||
log = console.log,
|
||||
note,
|
||||
pauseMs,
|
||||
failOnRetry,
|
||||
} = {},
|
||||
) {
|
||||
return pressUntilDecided(
|
||||
page,
|
||||
panelId,
|
||||
async (point) => {
|
||||
await dragPointer(page, point, dx, dy);
|
||||
return waitForFloating(page, panelId, true);
|
||||
},
|
||||
{ retries, log, note, pauseMs, failOnRetry },
|
||||
);
|
||||
}
|
||||
|
||||
/** Dock a floating panel with a header double-click; same result as lifting. */
|
||||
export function dockPanelByDoubleClick(
|
||||
page,
|
||||
panelId,
|
||||
{
|
||||
retries = PRESS_RETRIES,
|
||||
log = console.log,
|
||||
note,
|
||||
pauseMs,
|
||||
failOnRetry,
|
||||
} = {},
|
||||
) {
|
||||
return pressUntilDecided(
|
||||
page,
|
||||
panelId,
|
||||
async (point) => {
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 1 });
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 2 });
|
||||
return waitForFloating(page, panelId, false);
|
||||
},
|
||||
{ retries, log, note, pauseMs, failOnRetry },
|
||||
);
|
||||
}
|
||||
|
||||
/** Where an attempt pressed, for an assertion message. */
|
||||
export function describePress(attempt) {
|
||||
const { point, hit, pressed } = attempt;
|
||||
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
|
||||
if (!pressed)
|
||||
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
|
||||
const n = attempt.retries || 0;
|
||||
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
|
||||
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
|
||||
}
|
||||
+506
-2774
File diff suppressed because it is too large.
Load diff
@@ -43,11 +43,7 @@ try {
|
||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||
const watchObserver = (name) => {
|
||||
const owner =
|
||||
name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout;
|
||||
const observer = owner[name];
|
||||
const observer = ui._panelLayout[name];
|
||||
if (!observer) return false;
|
||||
const disconnect = observer.disconnect.bind(observer);
|
||||
counts[name] = 0;
|
||||
@@ -59,7 +55,6 @@ try {
|
||||
};
|
||||
const observed = [
|
||||
'_commandDockTrayObserver',
|
||||
'_draggableResizeObserver',
|
||||
'_leftStackResizeObserver',
|
||||
'_rightStackResizeObserver',
|
||||
'_leftStackMutationObserver',
|
||||
@@ -139,7 +134,6 @@ try {
|
||||
const focusBefore = document.activeElement;
|
||||
const stoppedBeforeRestoration =
|
||||
ui._panelLayout.destroyed &&
|
||||
ui._panelPosition.destroyed &&
|
||||
ui._feedback.destroyed &&
|
||||
ui._recording.destroyed &&
|
||||
cockpit.destroyed &&
|
||||
@@ -194,11 +188,7 @@ try {
|
||||
counts[field] === 1 && ui._contextControls[field] === null,
|
||||
),
|
||||
observersReleased: observed.every(
|
||||
(name) =>
|
||||
counts[name] === 1 &&
|
||||
(name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout)[name] === null,
|
||||
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
|
||||
),
|
||||
subscriptionReleased:
|
||||
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-vcam.mjs — phone virtual camera, end to end.
|
||||
*
|
||||
* Drives the real desktop app and the real phone page (served by the HTTPS
|
||||
* phone listener) in one browser, plus a scripted 6DoF phone over the same
|
||||
* relay:
|
||||
*
|
||||
* 1. SCENES ▸ VCAM starts a session and draws a QR code for the pairing URL.
|
||||
* 2. The phone page pairs from the URL fragment, falls back to HTTPS requests
|
||||
* when WebSockets are refused (as iOS does under a certificate warning),
|
||||
* starts the gyro camera and turns the desktop camera with device
|
||||
* orientation; the desktop's render arrives on the phone's monitor.
|
||||
* 3. The phone's lens preset and REC button reach the desktop; the recorded
|
||||
* take becomes a validated track shot in the selected scene and replays.
|
||||
* 4. A scripted 6DoF phone moves one metre at 1:100 and the camera moves
|
||||
* about a hundred metres; desktop navigation holds the rig.
|
||||
*
|
||||
* Needs the dev server (default http://localhost:4173, or QA_BASE_URL) and
|
||||
* its phone listener on VCAM_PORT (default 4174), reachable on loopback.
|
||||
* Screenshots go to qa-shots/vcam/.
|
||||
*
|
||||
* npm run dev # in another terminal
|
||||
* node scripts/qa-vcam.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const output = path.resolve('qa-shots/vcam');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
let failed = 0;
|
||||
function check(name, passed, detail = '') {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (!passed) failed++;
|
||||
}
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Separate browsers, like separate devices: a background tab would hide the
|
||||
// desktop page, which suspends its render loop.
|
||||
const launch = () =>
|
||||
puppeteer.launch({
|
||||
headless: true,
|
||||
acceptInsecureCerts: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage', '--ignore-certificate-errors'],
|
||||
});
|
||||
const browser = await launch();
|
||||
const phoneBrowser = await launch();
|
||||
const errors = [];
|
||||
try {
|
||||
const desk = await browser.newPage();
|
||||
desk.on('pageerror', (error) => errors.push(`desktop: ${error.message}`));
|
||||
if (process.env.QA_VCAM_DEBUG) desk.on('pageerror', (error) => console.log('DESKTOP ERROR', error.stack || error.message));
|
||||
await desk.setViewport({ width: 1280, height: 800 });
|
||||
await desk.goto(`${BASE}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await desk.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.vcam &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
// A shader that fails to compile stops Cesium's render loop.
|
||||
await desk.evaluate(() => {
|
||||
window.__vcamRenderErrors = [];
|
||||
window.__godsEyeView.viewer.scene.renderError.addEventListener((_scene, error) =>
|
||||
window.__vcamRenderErrors.push(String(error?.message || error)),
|
||||
);
|
||||
});
|
||||
// Start from a known city view.
|
||||
await desk.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyCameraState(
|
||||
{ lat: 30.2672, lon: -97.7431, alt: 1200, heading: 0, pitch: -30, roll: 0 },
|
||||
0.2,
|
||||
),
|
||||
);
|
||||
await sleep(800);
|
||||
|
||||
// 1. Pairing UI.
|
||||
await desk.evaluate(() => {
|
||||
const panel = document.getElementById('scene-panel');
|
||||
if (panel.classList.contains('collapsed'))
|
||||
panel.querySelector('.panel-collapse-btn')?.click();
|
||||
});
|
||||
await desk.click('#vcam-toggle-btn');
|
||||
if (process.env.QA_VCAM_DEBUG)
|
||||
await desk.evaluate(() => {
|
||||
window.__vcamSeen = [];
|
||||
window.__godsEyeView.vcam.subscribe((s) =>
|
||||
window.__vcamSeen.push([s.status, s.phoneConnected, s.driving, s.held, s.holdReason].join(',')),
|
||||
);
|
||||
});
|
||||
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().code, { timeout: 15000 });
|
||||
const pairing = await desk.evaluate(() => {
|
||||
const state = window.__godsEyeView.vcam.getState();
|
||||
const qr = document.getElementById('vcam-qr');
|
||||
return {
|
||||
state,
|
||||
shownCode: document.getElementById('vcam-code').textContent,
|
||||
qrDrawn: !qr.hidden && qr.width > 100,
|
||||
link: document.getElementById('vcam-url').textContent,
|
||||
};
|
||||
});
|
||||
check('VCAM opens a pairing session with a code and QR', pairing.qrDrawn && pairing.shownCode === pairing.state.code, pairing.link);
|
||||
const phoneUrl = `https://127.0.0.1:${pairing.state.port}/#${pairing.state.code}`;
|
||||
|
||||
// 2. The real phone page, with WebSockets refused as on iOS.
|
||||
const phone = await phoneBrowser.newPage();
|
||||
phone.on('pageerror', (error) => errors.push(`phone: ${error.message}`));
|
||||
if (process.env.QA_VCAM_DEBUG)
|
||||
phone.on('request', (request) => {
|
||||
if (request.url().includes('/api/') && !request.url().includes('/api/frame'))
|
||||
console.log('PHONE', request.method(), request.url().split('?')[0], request.postData()?.slice(0, 90) ?? '');
|
||||
});
|
||||
await phone.emulate({
|
||||
viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 },
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
});
|
||||
await phone.evaluateOnNewDocument(() => {
|
||||
window.WebSocket = class {
|
||||
static OPEN = 1;
|
||||
readyState = 0;
|
||||
constructor() {
|
||||
setTimeout(() => {
|
||||
this.onerror?.(new Event('error'));
|
||||
this.onclose?.({ code: 1006 });
|
||||
}, 50);
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
});
|
||||
await phone.goto(phoneUrl, { waitUntil: 'domcontentloaded' });
|
||||
await phone.waitForSelector('#start:not([hidden])', { timeout: 10000 });
|
||||
check('the phone pairs from the QR link and clears the code from the address bar',
|
||||
!(await phone.evaluate(() => location.hash)));
|
||||
await phone.click('#start-gyro');
|
||||
await phone.waitForSelector('#camera:not([hidden])', { timeout: 5000 });
|
||||
const orient = (alpha, beta = 90, gamma = 0) =>
|
||||
phone.evaluate((a, b, g) => {
|
||||
window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { alpha: a, beta: b, gamma: g }));
|
||||
}, alpha, beta, gamma);
|
||||
await orient(0);
|
||||
await desk
|
||||
.waitForFunction(() => window.__godsEyeView.vcam.getState().driving, { timeout: 10000 })
|
||||
.catch(async (error) => {
|
||||
const { status, phoneConnected, held, holdReason, mode } = await desk.evaluate(() =>
|
||||
window.__godsEyeView.vcam.getState(),
|
||||
);
|
||||
if (process.env.QA_VCAM_DEBUG)
|
||||
console.log('SEEN', await desk.evaluate(() => JSON.stringify([...new Set(window.__vcamSeen)])));
|
||||
throw new Error(`rig never drove: ${JSON.stringify({ status, phoneConnected, held, holdReason, mode })}`, {
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
const linkState = await phone.evaluate(() => document.getElementById('link-dot').className);
|
||||
check('the phone falls back to HTTPS requests when WebSockets fail', linkState.includes('slow'), linkState);
|
||||
const before = await desk.evaluate(() => window.__godsEyeView.styleManager.getCameraState());
|
||||
for (let alpha = 0; alpha <= 45; alpha += 3) {
|
||||
await orient(alpha);
|
||||
await sleep(40);
|
||||
}
|
||||
// Headless WebGL renders a few frames a second; wait for the rig to settle.
|
||||
const headingAfter = (target) =>
|
||||
desk
|
||||
.waitForFunction(
|
||||
(from, wanted) => {
|
||||
const { heading } = window.__godsEyeView.styleManager.getCameraState();
|
||||
return Math.abs((((from - heading + 540) % 360) - 180) - wanted) < 1;
|
||||
},
|
||||
{ timeout: 15000, polling: 100 },
|
||||
before.heading,
|
||||
target,
|
||||
)
|
||||
.catch(() => {});
|
||||
await headingAfter(45);
|
||||
const turned = await desk.evaluate(() => window.__godsEyeView.styleManager.getCameraState());
|
||||
const headingDelta = ((before.heading - turned.heading + 540) % 360) - 180;
|
||||
check('turning the phone 45° left turns the camera 45° left', Math.abs(headingDelta - 45) < 1,
|
||||
`heading ${before.heading.toFixed(1)} → ${turned.heading.toFixed(1)}`);
|
||||
check('an upright phone looks at the horizon', Math.abs(turned.pitch) < 5, `pitch ${turned.pitch.toFixed(1)}`);
|
||||
const vcamMode = await desk.evaluate(() => window.__godsEyeView.vcam.getState().mode);
|
||||
check('the desktop reports gyro (3DoF) tracking', vcamMode === '3dof');
|
||||
await phone.waitForFunction(() => document.getElementById('monitor').naturalWidth > 0, { timeout: 10000 });
|
||||
check('the desktop render reaches the phone monitor', true);
|
||||
|
||||
// 3. Lens and a take from the phone.
|
||||
await phone.click('#lens-btn');
|
||||
await phone.click('#focal-presets button[data-focal="85"]');
|
||||
await desk.waitForFunction(() => Math.abs(window.__godsEyeView.lens.getFocalLength() - 85) < 0.01, { timeout: 5000 });
|
||||
const fov = await desk.evaluate(() => window.__godsEyeView.viewer.camera.frustum.fov);
|
||||
check('the phone lens preset sets the desktop focal length', Math.abs(fov - 2 * Math.atan(18 / 85)) < 1e-6);
|
||||
await phone.click('#lens-btn');
|
||||
const shotsBefore = await desk.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
return d._getSelectedScene().shots.length;
|
||||
});
|
||||
await phone.click('#record-btn');
|
||||
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().recording, { timeout: 5000 });
|
||||
for (let alpha = 45; alpha >= -20; alpha -= 2) {
|
||||
await orient(alpha, 80);
|
||||
await sleep(50);
|
||||
}
|
||||
await phone.click('#record-btn');
|
||||
await desk.waitForFunction(() => !window.__godsEyeView.vcam.getState().recording, { timeout: 5000 });
|
||||
const take = await desk.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._getSelectedScene();
|
||||
const shot = scene.shots.at(-1);
|
||||
return {
|
||||
count: scene.shots.length,
|
||||
title: shot.title,
|
||||
samples: shot.move?.track?.samples?.length || 0,
|
||||
duration: shot.durationSec,
|
||||
focal: shot.move?.track?.samples?.[0]?.[7],
|
||||
listed: document.getElementById('scene-shot-list').textContent.includes(shot.title),
|
||||
saved: JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((s) => s.shots.some((x) => x.id === shot.id)),
|
||||
sceneId: scene.id,
|
||||
shotId: shot.id,
|
||||
last: shot.move?.track?.samples?.at(-1),
|
||||
};
|
||||
});
|
||||
// One sample per rendered frame, at most 30 a second.
|
||||
check('REC on the phone adds a track shot to the selected scene',
|
||||
take.count === shotsBefore + 1 && take.samples >= 2 && take.duration > 2 &&
|
||||
take.samples <= take.duration * 30 + 2 && take.listed && take.saved,
|
||||
`${take.title}: ${take.samples} samples over ${take.duration}s`);
|
||||
check('the take records the focal length per sample', take.focal === 85);
|
||||
await desk.screenshot({ path: path.join(output, 'desktop-take.png') });
|
||||
await phone.screenshot({ path: path.join(output, 'phone-camera.png') });
|
||||
|
||||
// Replaying the take ends exactly on its last sample.
|
||||
await desk.evaluate(() => window.__godsEyeView.vcam.hold());
|
||||
await desk.evaluate(({ sceneId, shotId }) =>
|
||||
window.__godsEyeView.sceneDirector.loadShot(sceneId, shotId), take);
|
||||
await desk.waitForFunction(({ last }) => {
|
||||
const c = window.__godsEyeView.styleManager.getCameraState();
|
||||
return Math.abs(c.lat - last[2]) < 1e-5 && Math.abs(c.lon - last[1]) < 1e-5;
|
||||
}, { timeout: 20000 }, take).catch(() => {});
|
||||
const replayed = await desk.evaluate(() => window.__godsEyeView.styleManager.getCameraState());
|
||||
check('loading the take plays it to its final recorded pose',
|
||||
Math.abs(replayed.lat - take.last[2]) < 1e-5 && Math.abs(replayed.alt - take.last[3]) < 0.5,
|
||||
`alt ${replayed.alt.toFixed(1)} vs ${take.last[3]}`);
|
||||
await phone.close();
|
||||
|
||||
// 4. A scripted 6DoF phone: translation follows the motion scale.
|
||||
const join = await new Promise((resolve) => {
|
||||
const request = https.request(
|
||||
`https://127.0.0.1:${pairing.state.port}/api/join`,
|
||||
{ method: 'POST', rejectUnauthorized: false, headers: { 'Content-Type': 'application/json' } },
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => (body += chunk));
|
||||
response.on('end', () => resolve({ ok: response.statusCode === 200, body }));
|
||||
},
|
||||
);
|
||||
request.on('error', () => resolve({ ok: false }));
|
||||
request.end(JSON.stringify({ code: pairing.state.code }));
|
||||
});
|
||||
let sixDof = null;
|
||||
if (join.ok) {
|
||||
const { phoneKey } = JSON.parse(join.body);
|
||||
sixDof = new WebSocket(`wss://127.0.0.1:${pairing.state.port}/ws?key=${phoneKey}`, {
|
||||
origin: `https://127.0.0.1:${pairing.state.port}`,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
sixDof.once('open', resolve);
|
||||
sixDof.once('error', reject);
|
||||
});
|
||||
}
|
||||
check('a second phone can pair over a WebSocket', !!sixDof);
|
||||
if (sixDof) {
|
||||
const pose = (z, extra = {}) =>
|
||||
sixDof.send(JSON.stringify({ type: 'pose', t: Date.now(), q: [0, 0, 0, 1], p: [0, 1.5, z], j: [0, 0, 0, 0], ...extra }));
|
||||
sixDof.send(JSON.stringify({ type: 'set', key: 'scale', value: 100 }));
|
||||
sixDof.send(JSON.stringify({ type: 'set', key: 'smoothing', value: 0 }));
|
||||
sixDof.send(JSON.stringify({ type: 'cmd', cmd: 'release' }));
|
||||
for (let i = 0; i < 10; i++) {
|
||||
pose(0);
|
||||
await sleep(30);
|
||||
}
|
||||
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().driving && window.__godsEyeView.vcam.getState().mode === '6dof', { timeout: 5000 });
|
||||
const position = () =>
|
||||
desk.evaluate(() => {
|
||||
const { x, y, z } = window.__godsEyeView.viewer.camera.positionWC;
|
||||
return [x, y, z];
|
||||
});
|
||||
const start = await position();
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
pose(-i / 20);
|
||||
await sleep(30);
|
||||
}
|
||||
await desk
|
||||
.waitForFunction(
|
||||
(from) => {
|
||||
const { x, y, z } = window.__godsEyeView.viewer.camera.positionWC;
|
||||
return Math.abs(Math.hypot(x - from[0], y - from[1], z - from[2]) - 100) < 1;
|
||||
},
|
||||
{ timeout: 15000, polling: 100 },
|
||||
start,
|
||||
)
|
||||
.catch(() => {});
|
||||
const end = await position();
|
||||
const moved = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]);
|
||||
check('one metre of phone motion at 1:100 moves the camera 100 m', Math.abs(moved - 100) < 1, `${moved.toFixed(1)} m`);
|
||||
|
||||
// Depth of field at a miniature scale, focused by a tap at the centre.
|
||||
sixDof.send(JSON.stringify({ type: 'set', key: 'scale', value: 1000 }));
|
||||
sixDof.send(JSON.stringify({ type: 'set', key: 'dof', value: true }));
|
||||
sixDof.send(JSON.stringify({ type: 'set', key: 'fStop', value: 4 }));
|
||||
// Pitch down 30 degrees, let the rig settle, then focus on the ground ahead.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
pose(-1, { q: [-0.2588, 0, 0, 0.9659] });
|
||||
await sleep(50);
|
||||
}
|
||||
await desk
|
||||
.waitForFunction(() => window.__godsEyeView.styleManager.getCameraState().pitch < -29, {
|
||||
timeout: 15000,
|
||||
polling: 100,
|
||||
})
|
||||
.catch(() => {});
|
||||
sixDof.send(JSON.stringify({ type: 'focus', x: 0.5, y: 0.5 }));
|
||||
await sleep(2000);
|
||||
const dof = await desk.evaluate(() => {
|
||||
const { lens, viewer } = window.__godsEyeView;
|
||||
const stage = viewer.scene.postProcessStages;
|
||||
let found = null;
|
||||
for (let i = 0; i < stage.length; i++)
|
||||
if (stage.get(i).name === 'godsEyeView_depthOfField') found = stage.get(i);
|
||||
return {
|
||||
state: lens.getDepthOfField(),
|
||||
enabled: !!found?.enabled,
|
||||
ready: !!found?.ready,
|
||||
errors: window.__vcamRenderErrors,
|
||||
};
|
||||
});
|
||||
check('tap-to-focus enables depth of field at the rig scale',
|
||||
dof.enabled && dof.state.scale === 1000 && dof.state.focusM > 1000 && dof.state.focusM < 5000 && dof.state.fStop === 4,
|
||||
`focus ${dof.state.focusM?.toFixed?.(0)} m at 1:${dof.state.scale}`);
|
||||
check('the depth-of-field shader compiles and renders', dof.ready && dof.errors.length === 0, dof.errors.join(' | '));
|
||||
await desk.screenshot({ path: path.join(output, 'desktop-dof.png') });
|
||||
|
||||
// Mouse navigation on the globe holds the rig instead of fighting it.
|
||||
await desk.mouse.click(640, 400);
|
||||
const held = await desk.evaluate(() => window.__godsEyeView.vcam.getState());
|
||||
check('desktop mouse input holds the rig', held.held && !held.driving, held.holdReason);
|
||||
sixDof.close();
|
||||
}
|
||||
await desk.click('#vcam-end-btn');
|
||||
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().status === 'idle', { timeout: 5000 });
|
||||
check('END closes the session', true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failed++;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await phoneBrowser.close();
|
||||
}
|
||||
const relevant = errors.filter((message) => !/Failed to fetch|NetworkError|tile|imagery/i.test(message));
|
||||
check('no page errors', relevant.length === 0, relevant.slice(0, 3).join(' | '));
|
||||
console.log(failed ? `\n${failed} check(s) failed` : '\nAll VCAM checks passed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -144,6 +145,14 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
||||
load: loadLithuaniaSources,
|
||||
},
|
||||
{
|
||||
// FL511's terms limit its content to individual, personal use (other use
|
||||
// needs FDOT's written consent), so the pack is off unless the operator
|
||||
// opts in (see DATA_SOURCES.md).
|
||||
name: 'florida',
|
||||
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
|
||||
load: loadFloridaSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -113,6 +113,27 @@ export const CALTRANS_ANCHORS = [
|
||||
{ lat: 32.7157, lon: -117.1611 }, // San Diego
|
||||
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
|
||||
];
|
||||
export const FLORIDA_CCTV_URL =
|
||||
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
|
||||
export const FLORIDA_IMAGE_HOSTS = [
|
||||
'images-dis.divas.cloud',
|
||||
'snapshots.divas.cloud',
|
||||
];
|
||||
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
|
||||
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
|
||||
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
|
||||
export const FLORIDA_MAX_CATALOG_PAGES = 40;
|
||||
export const FLORIDA_ANCHORS = [
|
||||
{ lat: 25.7617, lon: -80.1918 }, // Miami
|
||||
{ lat: 28.5384, lon: -81.3789 }, // Orlando
|
||||
{ lat: 27.9506, lon: -82.4572 }, // Tampa
|
||||
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
|
||||
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
|
||||
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
|
||||
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
|
||||
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
|
||||
];
|
||||
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
|
||||
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
||||
export const TFL_IMAGE_ORIGIN =
|
||||
|
||||
@@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
|
||||
export function isLikelyFloridaCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 24.3 &&
|
||||
lat <= 31.1 &&
|
||||
lon >= -87.7 &&
|
||||
lon <= -79.8
|
||||
);
|
||||
}
|
||||
|
||||
/** New York City's five boroughs, with slack for the harbor and city line. */
|
||||
export function isLikelyNycCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -55,6 +55,12 @@ import {
|
||||
DEFAULT_CALGARY_MAX_SOURCES,
|
||||
CALGARY_DOWNTOWN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_IMAGE_HOSTS,
|
||||
DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
FLORIDA_ANCHORS,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
CATALONIA_IMAGE_HOSTS,
|
||||
@@ -113,6 +119,7 @@ import {
|
||||
isLikelyTexasCoordinate,
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
@@ -2784,3 +2791,203 @@ export async function loadTorontoSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */
|
||||
const FLORIDA_DIRECTION_HEADINGS = Object.freeze({
|
||||
N: 0,
|
||||
E: 90,
|
||||
S: 180,
|
||||
W: 270,
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable camera id from a frame URL. FL511's ID column repeats across regions
|
||||
* (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the
|
||||
* key, as Calgary keys on its frame filename. Falls back to a path slug.
|
||||
*
|
||||
* @param {string} imageUrl - A host-pinned FL511 frame URL.
|
||||
* @returns {?string} Provider-stable id, or null when underivable.
|
||||
*/
|
||||
export function floridaCameraId(imageUrl) {
|
||||
let path;
|
||||
try {
|
||||
path = new URL(String(imageUrl ?? '')).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path);
|
||||
if (channel) return `fl-${channel[1]}`;
|
||||
const slug = path
|
||||
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.toLowerCase();
|
||||
return slug ? `fl-${slug}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a
|
||||
* one-letter code the free-text parser won't read, so a table maps it.
|
||||
*
|
||||
* @param {object} feature - ArcGIS feature from the FL511 camera layer.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function floridaCameraToSource(feature) {
|
||||
const a = feature?.attributes;
|
||||
if (!a || typeof a !== 'object') return null;
|
||||
const lat = toFiniteNumber(a.LATITUDE);
|
||||
const lon = toFiniteNumber(a.LONGITUDE);
|
||||
if (!isLikelyFloridaCoordinate(lat, lon)) return null;
|
||||
|
||||
const imageUrl = String(a.IMAGE || '');
|
||||
let image;
|
||||
try {
|
||||
image = new URL(imageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Official image hosts only, HTTPS, no embedded credentials.
|
||||
if (
|
||||
image.protocol !== 'https:' ||
|
||||
!FLORIDA_IMAGE_HOSTS.includes(image.host) ||
|
||||
image.username ||
|
||||
image.password
|
||||
)
|
||||
return null;
|
||||
|
||||
const cameraId = floridaCameraId(imageUrl);
|
||||
if (!cameraId) return null;
|
||||
|
||||
const heading =
|
||||
FLORIDA_DIRECTION_HEADINGS[
|
||||
String(a.DIRECTION || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
];
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`,
|
||||
city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'),
|
||||
cityId: 'florida',
|
||||
provider: 'FL511',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: 5, // Coastal prior; the client's ground snap corrects.
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'fl511-open-data',
|
||||
license:
|
||||
'FL511 (FDOT), individual personal use only; other use needs FDOT written consent',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged
|
||||
* up to 2,000 rows at a time, ordered by the layer's object id so offsets are
|
||||
* stable. Paging follows `exceededTransferLimit` and advances by the rows each
|
||||
* page returned, so a lower server-side max record count still reads the whole
|
||||
* layer. Frames are stills on the Divas Cloud hosts. A redirect on any page
|
||||
* drops the pack; any other failed page keeps the rows already read.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadFloridaSourcesFromOpenData() {
|
||||
try {
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
const pageSize = 2000;
|
||||
let offset = 0;
|
||||
// Page ceiling, in case the service ignores resultOffset.
|
||||
for (let page = 0; ; page += 1) {
|
||||
if (page >= FLORIDA_MAX_CATALOG_PAGES) {
|
||||
console.warn(
|
||||
`[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400).
|
||||
const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Release the body on every path that will not read it.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog redirected; redirects are not followed',
|
||||
);
|
||||
await discard();
|
||||
return [];
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Florida camera download failed:', resp.status);
|
||||
await discard();
|
||||
break;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page unreadable:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const feats = Array.isArray(body?.features) ? body.features : [];
|
||||
for (const feature of feats) {
|
||||
const camera = floridaCameraToSource(feature);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
// ArcGIS sets exceededTransferLimit while more rows remain. A page may
|
||||
// be shorter than pageSize when the layer's max record count is lower.
|
||||
if (body?.exceededTransferLimit !== true || feats.length === 0) break;
|
||||
offset += feats.length;
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(4200, Math.floor(maxRaw)))
|
||||
: DEFAULT_FLORIDA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,34 @@ async function readRequestBodyCapped(req, maxBytes) {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
||||
/** Optional cancellation discards buffered data while the request drains. */
|
||||
function readRequestBody(req, maxBytes = 1024 * 1024, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let total = 0;
|
||||
let bodyTooLarge = false;
|
||||
let settled = false;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
const stopReading = () => {
|
||||
if (!signal) return;
|
||||
req.off('data', data);
|
||||
req.off('end', end);
|
||||
signal.removeEventListener('abort', abort);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (signal) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
chunks.length = 0;
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const abort = () => fail(signal.reason);
|
||||
const data = (chunk) => {
|
||||
// Keep draining after the cap without retaining more bytes. Destroying
|
||||
// the request also destroys its socket, so the handler cannot deliver
|
||||
// the fixed error response it constructs for BODY_TOO_LARGE.
|
||||
if (bodyTooLarge) return;
|
||||
if (bodyTooLarge || settled) return;
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
// Same marker readRequestBodyCapped sets, so a caller can tell an
|
||||
@@ -32,15 +50,42 @@ function readRequestBody(req, maxBytes = 1024 * 1024) {
|
||||
chunks.length = 0;
|
||||
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
||||
error.code = 'BODY_TOO_LARGE';
|
||||
reject(error);
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
};
|
||||
const end = () => {
|
||||
if (settled) {
|
||||
stopReading();
|
||||
return;
|
||||
}
|
||||
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (signal) {
|
||||
settled = true;
|
||||
chunks.length = 0;
|
||||
stopReading();
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
fail(
|
||||
new DOMException('Request closed before its body ended', 'AbortError'),
|
||||
);
|
||||
stopReading();
|
||||
req.off('error', fail);
|
||||
req.off('close', close);
|
||||
};
|
||||
if (signal) {
|
||||
// Abort/overflow release retained bytes but keep draining. Retain an
|
||||
// error handler until close, including a late error after end/abort.
|
||||
req.on('close', close);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
}
|
||||
req.on('data', data);
|
||||
req.on('end', end);
|
||||
req.on('error', fail);
|
||||
if (signal && req.destroyed) close();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter, getEventListeners } from 'node:events';
|
||||
import { readRequestBody } from './request.js';
|
||||
|
||||
function fixture() {
|
||||
const req = new EventEmitter();
|
||||
req.pause = () => assert.fail('The reader must keep draining');
|
||||
req.destroy = () =>
|
||||
assert.fail('The reader must preserve the response socket');
|
||||
const owner = new AbortController();
|
||||
return { req, owner };
|
||||
}
|
||||
|
||||
function finish(req, owner) {
|
||||
assert.doesNotThrow(() => req.emit('error', new Error('late stream error')));
|
||||
req.emit('end');
|
||||
assert.doesNotThrow(() => req.emit('error', new Error('error before close')));
|
||||
req.emit('close');
|
||||
for (const event of ['data', 'end', 'error', 'close'])
|
||||
assert.equal(req.listenerCount(event), 0, event);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
}
|
||||
|
||||
test('default and cancellable readers preserve byte caps and UTF-8 across chunks', async () => {
|
||||
for (const useSignal of [false, true]) {
|
||||
for (const text of ['', 'hello', 'é']) {
|
||||
const { req, owner } = fixture();
|
||||
const bytes = Buffer.from(text);
|
||||
const body = readRequestBody(
|
||||
req,
|
||||
bytes.length,
|
||||
useSignal ? owner.signal : undefined,
|
||||
);
|
||||
for (const byte of bytes) req.emit('data', Buffer.from([byte]));
|
||||
req.emit('end');
|
||||
assert.equal(await body, text);
|
||||
if (useSignal) finish(req, owner);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('default overflow rejects before end and drains without pausing or destroying', async () => {
|
||||
const { req } = fixture();
|
||||
const rejection = assert.rejects(readRequestBody(req, 2), {
|
||||
code: 'BODY_TOO_LARGE',
|
||||
});
|
||||
req.emit('data', Buffer.from('é'));
|
||||
req.emit('data', Buffer.from('!'));
|
||||
await rejection;
|
||||
req.emit('data', Buffer.alloc(4096));
|
||||
assert.doesNotThrow(() => req.emit('error', new Error('late')));
|
||||
req.emit('end');
|
||||
});
|
||||
|
||||
test('partial abort settles before end, discards late data and guards errors until close', async () => {
|
||||
const { req, owner } = fixture();
|
||||
const reason = new Error('request deadline');
|
||||
const rejection = assert.rejects(
|
||||
readRequestBody(req, 1024, owner.signal),
|
||||
(error) => error === reason,
|
||||
);
|
||||
req.emit('data', Buffer.from('partial'));
|
||||
owner.abort(reason);
|
||||
await rejection;
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
req.emit('data', Buffer.alloc(4096));
|
||||
finish(req, owner);
|
||||
});
|
||||
|
||||
test('already-aborted and already-closed requests settle and release their listeners', async () => {
|
||||
for (const closed of [false, true]) {
|
||||
const { req, owner } = fixture();
|
||||
if (closed) req.destroyed = true;
|
||||
else owner.abort();
|
||||
await assert.rejects(readRequestBody(req, 1024, owner.signal), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
if (!closed) finish(req, owner);
|
||||
for (const event of ['data', 'end', 'error', 'close'])
|
||||
assert.equal(req.listenerCount(event), 0);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('overflow keeps its first failure through later abort and stream errors', async () => {
|
||||
const { req, owner } = fixture();
|
||||
const rejection = assert.rejects(readRequestBody(req, 2, owner.signal), {
|
||||
code: 'BODY_TOO_LARGE',
|
||||
});
|
||||
req.emit('data', Buffer.from('é!'));
|
||||
await rejection;
|
||||
owner.abort(new Error('later deadline'));
|
||||
req.emit('data', Buffer.alloc(4096));
|
||||
finish(req, owner);
|
||||
});
|
||||
|
||||
test('early close and request error reject once; successful end wins over later abort', async () => {
|
||||
for (const event of ['close', 'error', 'end']) {
|
||||
const { req, owner } = fixture();
|
||||
const reason = new Error('request failed');
|
||||
const body = readRequestBody(req, 32, owner.signal);
|
||||
const observed =
|
||||
event === 'end'
|
||||
? body.then((value) => assert.equal(value, 'ok'))
|
||||
: assert.rejects(
|
||||
body,
|
||||
event === 'close'
|
||||
? { name: 'AbortError' }
|
||||
: (error) => error === reason,
|
||||
);
|
||||
req.emit('data', Buffer.from('ok'));
|
||||
req.emit(event, reason);
|
||||
owner.abort(new Error('later abort'));
|
||||
await observed;
|
||||
if (event !== 'close') finish(req, owner);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a prior data observer can abort synchronously without retaining or resolving that chunk', async () => {
|
||||
const { req, owner } = fixture();
|
||||
const reason = new Error('observer stopped request');
|
||||
const external = () => owner.abort(reason);
|
||||
req.on('data', external);
|
||||
const rejection = assert.rejects(
|
||||
readRequestBody(req, 1024, owner.signal),
|
||||
(error) => error === reason,
|
||||
);
|
||||
req.emit('data', Buffer.from('must be discarded'));
|
||||
await rejection;
|
||||
req.emit('end');
|
||||
req.emit('close');
|
||||
assert.deepEqual(req.listeners('data'), [external]);
|
||||
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
|
||||
req.off('data', external);
|
||||
});
|
||||
@@ -41,3 +41,48 @@ export function sameSiteGated(handler) {
|
||||
return handler(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy header values arrive client-controlled; read one only when it is a
|
||||
* single scheme token, never a comma-separated chain.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {'http:'|'https:'}
|
||||
*/
|
||||
function forwardedProtocol(req) {
|
||||
if (req.socket?.encrypted) return 'https:';
|
||||
const proto = String(req.headers?.['x-forwarded-proto'] || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return proto === 'https' ? 'https:' : 'http:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-site gate for keyless routes the map needs in order to render
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
* and refusing them would break rendering for an instance served behind the
|
||||
* reverse proxy the README documents. A single `X-Forwarded-Proto` supplies
|
||||
* the scheme an `Origin` is compared against. That header cannot be attached
|
||||
* to a cross-site browser request without a CORS preflight this server never
|
||||
* answers, and `Sec-Fetch-Site` is still enforced regardless.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @returns {boolean} True when a 403 was sent.
|
||||
*/
|
||||
export function admitSameSiteKeyless(req, res) {
|
||||
const verdict = admitSameSiteRequest({
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: forwardedProtocol(req),
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createGeminiTokenHandler } from './gemini/realtime.js';
|
||||
|
||||
/** Mint constrained Live API credentials; the permanent key stays on Node. */
|
||||
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use(
|
||||
'/api/gemini/token',
|
||||
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: 'gemini-live-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { geminiLiveProxy };
|
||||
@@ -0,0 +1,53 @@
|
||||
import { realtimeInstructions } from '../openai/instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
|
||||
|
||||
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
|
||||
export const GEMINI_TOKEN_ENDPOINT =
|
||||
'https://generativelanguage.googleapis.com/v1beta/auth_tokens';
|
||||
export const GEMINI_LIVE_WEBSOCKET_URL =
|
||||
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained';
|
||||
|
||||
export function normalizeGeminiModel(value) {
|
||||
const model = String(value ?? GEMINI_LIVE_MODEL_DEFAULT)
|
||||
.trim()
|
||||
.replace(/^models\//, '');
|
||||
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
|
||||
}
|
||||
|
||||
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
|
||||
export function createGeminiLiveConfig(
|
||||
annotationGuidance,
|
||||
inputMode = 'open-mic',
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
) {
|
||||
if (!['open-mic', 'push-to-talk'].includes(inputMode))
|
||||
throw new Error('Invalid Gemini input mode');
|
||||
const config = {
|
||||
generationConfig: { responseModalities: ['AUDIO'] },
|
||||
systemInstruction: {
|
||||
parts: [{ text: realtimeInstructions(annotationGuidance) }],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: tools.map(
|
||||
({ name, description, parameters }) => ({
|
||||
name,
|
||||
description,
|
||||
// GEV's canonical schemas are JSON Schema, not Google's Schema.
|
||||
parametersJsonSchema: structuredClone(parameters),
|
||||
// App actions must finish before the model confirms their result.
|
||||
behavior: 'BLOCKING',
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
inputAudioTranscription: {},
|
||||
outputAudioTranscription: {},
|
||||
contextWindowCompression: { slidingWindow: {} },
|
||||
};
|
||||
if (inputMode === 'push-to-talk')
|
||||
config.realtimeInputConfig = {
|
||||
automaticActivityDetection: { disabled: true },
|
||||
};
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
|
||||
import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js';
|
||||
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
|
||||
import {
|
||||
GEMINI_TOKEN_ENDPOINT,
|
||||
GEMINI_LIVE_WEBSOCKET_URL,
|
||||
GEMINI_LIVE_MODEL_DEFAULT,
|
||||
createGeminiLiveConfig,
|
||||
normalizeGeminiModel,
|
||||
} from './config.js';
|
||||
import {
|
||||
isGeminiRequestOriginAllowed,
|
||||
readGeminiTokenRequest,
|
||||
} from './request.js';
|
||||
|
||||
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
|
||||
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
||||
|
||||
export function createGeminiTokenHandler({
|
||||
annotationGuidance,
|
||||
tools,
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey = () => process.env.GEMINI_API_KEY,
|
||||
resolveModel = () =>
|
||||
process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT,
|
||||
resolveHost = () => process.env.HOST,
|
||||
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
|
||||
resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30',
|
||||
now = Date.now,
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
let limiter;
|
||||
return async (req, res) => {
|
||||
if (req.aborted || res.destroyed || res.writableEnded) return;
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const reply = (status, errorOrPayload) => {
|
||||
if (res.destroyed || res.writableEnded) return;
|
||||
res.statusCode = status;
|
||||
if (status >= 400) res.setHeader('Connection', 'close');
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
typeof errorOrPayload === 'string'
|
||||
? { error: errorOrPayload }
|
||||
: errorOrPayload,
|
||||
),
|
||||
);
|
||||
};
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', 'POST');
|
||||
return reply(405, 'Method not allowed');
|
||||
}
|
||||
let apiKey;
|
||||
let model;
|
||||
try {
|
||||
if (
|
||||
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
|
||||
)
|
||||
return reply(403, 'Gemini voice requires a same-origin app request');
|
||||
apiKey = String(resolveApiKey() || '').trim();
|
||||
if (!apiKey)
|
||||
return reply(503, 'Add a Gemini API key in Provider Settings');
|
||||
model = normalizeGeminiModel(resolveModel());
|
||||
if (!model)
|
||||
return reply(503, 'The Gemini Live model configuration is invalid');
|
||||
if (limiter === undefined)
|
||||
limiter = makeCostRateLimiter(resolveRateLimit(), 30);
|
||||
if (limiter && !limiter(clientKey(req))) {
|
||||
res.setHeader('Retry-After', '60');
|
||||
return reply(
|
||||
429,
|
||||
'Gemini voice is starting too often. Try again shortly.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Connect does not observe the handler's returned promise. A synchronous
|
||||
// configuration resolver must fail with a bounded, fixed response.
|
||||
return reply(503, MINT_FAILURE);
|
||||
}
|
||||
|
||||
const lifetime = new AbortController();
|
||||
let disconnected = false;
|
||||
const disconnect = () => {
|
||||
disconnected = true;
|
||||
lifetime.abort(new DOMException('Request closed', 'AbortError'));
|
||||
};
|
||||
const responseClosed = () => {
|
||||
if (!res.writableEnded) disconnect();
|
||||
};
|
||||
req.once('aborted', disconnect);
|
||||
res.once('close', responseClosed);
|
||||
const deadline = setTimeout(() => {
|
||||
lifetime.abort(
|
||||
new DOMException('Token request timed out', 'TimeoutError'),
|
||||
);
|
||||
}, timeoutMs);
|
||||
deadline.unref?.();
|
||||
let acquiredResponse;
|
||||
try {
|
||||
const inputMode = await readGeminiTokenRequest(req, lifetime.signal);
|
||||
lifetime.signal.throwIfAborted();
|
||||
const config = createGeminiLiveConfig(
|
||||
annotationGuidance,
|
||||
inputMode,
|
||||
tools,
|
||||
);
|
||||
const issuedAt = now();
|
||||
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
|
||||
// input shape, converted before transport. With no fieldMask the complete
|
||||
// setup is locked to this server-owned configuration.
|
||||
const request = {
|
||||
uses: 1,
|
||||
expireTime: new Date(issuedAt + 30 * 60_000).toISOString(),
|
||||
newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(),
|
||||
bidiGenerateContentSetup: { model: `models/${model}`, ...config },
|
||||
};
|
||||
const acquisition = Promise.resolve(
|
||||
fetchImpl(GEMINI_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
signal: lifetime.signal,
|
||||
headers: {
|
||||
'x-goog-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
);
|
||||
// A fetch implementation can ignore abort. Bound this caller's wait and
|
||||
// discard a response that arrives after timeout/disconnection.
|
||||
void acquisition.then(
|
||||
(late) => {
|
||||
acquiredResponse = late;
|
||||
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const response = await waitForSignal(acquisition, lifetime.signal);
|
||||
lifetime.signal.throwIfAborted();
|
||||
if (
|
||||
response.redirected ||
|
||||
(response.url && response.url !== GEMINI_TOKEN_ENDPOINT)
|
||||
) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
throw new Error('Unexpected token response destination');
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Never forward upstream bodies, headers or exception messages.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
if (response.status === 429) {
|
||||
res.setHeader('Retry-After', '60');
|
||||
return reply(429, 'Gemini quota is unavailable. Try again later.');
|
||||
}
|
||||
if (response.status === 401 || response.status === 403)
|
||||
return reply(
|
||||
503,
|
||||
'Gemini access was refused. Check the API key and model access.',
|
||||
);
|
||||
if (response.status === 400 || response.status === 404)
|
||||
return reply(
|
||||
502,
|
||||
'Gemini Live is unavailable for the configured model.',
|
||||
);
|
||||
return reply(502, MINT_FAILURE);
|
||||
}
|
||||
const data = await readResponseJsonCapped(
|
||||
response,
|
||||
UPSTREAM_RESPONSE_BYTES,
|
||||
lifetime.signal,
|
||||
);
|
||||
lifetime.signal.throwIfAborted();
|
||||
if (
|
||||
!data ||
|
||||
Array.isArray(data) ||
|
||||
typeof data !== 'object' ||
|
||||
typeof data.name !== 'string' ||
|
||||
data.name.length > 8192 ||
|
||||
!/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) ||
|
||||
data.name.includes(apiKey)
|
||||
)
|
||||
throw new Error('Invalid ephemeral token');
|
||||
reply(200, {
|
||||
token: data.name,
|
||||
model,
|
||||
config,
|
||||
websocketUrl: GEMINI_LIVE_WEBSOCKET_URL,
|
||||
});
|
||||
} catch (error) {
|
||||
if (disconnected || req.aborted) return;
|
||||
if (error?.code === 'BODY_TOO_LARGE')
|
||||
return reply(413, 'Token request is too large');
|
||||
if (error?.code === 'BAD_CONTENT_TYPE')
|
||||
return reply(415, 'Use an empty JSON token request');
|
||||
if (error?.code === 'BAD_BODY')
|
||||
return reply(400, 'Use an empty JSON token request');
|
||||
reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE);
|
||||
} finally {
|
||||
// Abort can happen after acquisition resolves but before this handler
|
||||
// resumes and passes ownership to the body reader.
|
||||
if (lifetime.signal.aborted)
|
||||
void acquiredResponse?.body?.cancel().catch(() => {});
|
||||
clearTimeout(deadline);
|
||||
req.off('aborted', disconnect);
|
||||
res.off('close', responseClosed);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
|
||||
import {
|
||||
isAllowedHost,
|
||||
resolveAllowedHosts,
|
||||
} from '../../../build/allowedHosts.js';
|
||||
|
||||
/** Use the app's Host and request policies, with a required browser Origin. */
|
||||
export function isGeminiRequestOriginAllowed(
|
||||
req,
|
||||
configuredHost = '',
|
||||
configuredAllowedHosts,
|
||||
) {
|
||||
const host = req.headers?.host;
|
||||
const origin = req.headers?.origin;
|
||||
if (
|
||||
typeof host !== 'string' ||
|
||||
typeof origin !== 'string' ||
|
||||
!host ||
|
||||
!origin ||
|
||||
/[\s/?#@\\]/.test(host)
|
||||
)
|
||||
return false;
|
||||
const protocol = req.socket?.encrypted ? 'https:' : 'http:';
|
||||
if (
|
||||
!admitSameSiteRequest({
|
||||
hostHeader: host,
|
||||
protocol,
|
||||
origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: req.headers || {},
|
||||
}).ok
|
||||
)
|
||||
return false;
|
||||
// The shared gate compares a present Origin exactly with this authority;
|
||||
// this route also requires the Origin and an allowed Host.
|
||||
const boundHost = String(configuredHost || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return isAllowedHost(host, resolveAllowedHosts(configuredAllowedHosts), [
|
||||
boundHost,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Admit only the existing input gesture; callers cannot supply model/setup. */
|
||||
export async function readGeminiTokenRequest(req, signal, maxBytes = 1024) {
|
||||
const declared = Number(req.headers?.['content-length']);
|
||||
const fail = (code) =>
|
||||
Object.assign(new Error('Invalid token request'), { code });
|
||||
const body = await readRequestBody(
|
||||
req,
|
||||
maxBytes,
|
||||
Number.isFinite(declared) && declared > maxBytes
|
||||
? AbortSignal.abort(fail('BODY_TOO_LARGE'))
|
||||
: signal,
|
||||
);
|
||||
if (!body.trim()) return 'open-mic';
|
||||
if (
|
||||
!/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type'] || '')
|
||||
)
|
||||
throw fail('BAD_CONTENT_TYPE');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
throw fail('BAD_BODY');
|
||||
}
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
||||
throw fail('BAD_BODY');
|
||||
const keys = Object.keys(parsed);
|
||||
if (!keys.length) return 'open-mic';
|
||||
if (
|
||||
keys.length === 1 &&
|
||||
keys[0] === 'inputMode' &&
|
||||
parsed.inputMode === 'push-to-talk'
|
||||
)
|
||||
return 'push-to-talk';
|
||||
throw fail('BAD_BODY');
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||
import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
import { openAiRealtimeProxy } from './openai.js';
|
||||
import { geminiLiveProxy } from './gemini.js';
|
||||
import { googlePlacesContextProxy } from './places.js';
|
||||
import { mapillaryProxy } from './mapillary.js';
|
||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
@@ -30,9 +31,9 @@ import { windProxy } from './wind.js';
|
||||
|
||||
/**
|
||||
* Construct the local provider plugins in their established order.
|
||||
* `realtime` configures the voice session token endpoint.
|
||||
* `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live.
|
||||
*/
|
||||
function localProviderPlugins({ realtime } = {}) {
|
||||
function localProviderPlugins({ realtime, gemini } = {}) {
|
||||
return [
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
@@ -55,6 +56,7 @@ function localProviderPlugins({ realtime } = {}) {
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy({ realtime }),
|
||||
...(gemini ? [geminiLiveProxy({ realtime: gemini })] : []),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
weatherProxy(),
|
||||
|
||||
+101
-14
@@ -1,19 +1,106 @@
|
||||
import { installMapillaryRoutes } from './mapillary/routes.js';
|
||||
import { sameSiteGated } from './common/same-site.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import {
|
||||
fetchTile,
|
||||
mapillaryToken,
|
||||
parseTilePath,
|
||||
retryAfterWait,
|
||||
TileUpstreamError,
|
||||
} from './mapillary/tiles.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
|
||||
* status route that says whether a token is configured.
|
||||
* Tile requests per client IP per minute, cache hits included: over 60 new
|
||||
* 9-tile views a minute, more than a person flying the camera reaches.
|
||||
*/
|
||||
function mapillaryProxy() {
|
||||
return {
|
||||
name: 'mapillary-proxy',
|
||||
configureServer(server) {
|
||||
installMapillaryRoutes(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
installMapillaryRoutes(server.middlewares);
|
||||
},
|
||||
};
|
||||
export const TILE_ROUTE_MAX_PER_MIN = 600;
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export { mapillaryProxy };
|
||||
/** GET /api/mapillary/status — whether a token is configured, never its value. */
|
||||
function handleStatus(req, res) {
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — trimmed protobuf tile. */
|
||||
async function handleTile(req, res, allow) {
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
if (!allow(clientKey(req))) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
return sendJson(res, 429, {
|
||||
error: 'Too many tile requests',
|
||||
retryAfter: 5,
|
||||
});
|
||||
}
|
||||
const address = parseTilePath((req.url || '').split('?')[0]);
|
||||
if (!address)
|
||||
return sendJson(res, 400, {
|
||||
error: 'Tile path must be /coverage/{z}/{x}/{y} with z 11–14',
|
||||
});
|
||||
if (!mapillaryToken())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
||||
// A client that leaves (a tile the camera moved past) cancels its fetch.
|
||||
const left = new AbortController();
|
||||
res.on?.('close', () => {
|
||||
if (!res.writableEnded) left.abort();
|
||||
});
|
||||
try {
|
||||
const { bytes, source } = await fetchTile(address, { signal: left.signal });
|
||||
res.statusCode = bytes.length ? 200 : 204;
|
||||
res.setHeader('Content-Type', 'application/x-protobuf');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('X-Gev-Cache', source);
|
||||
res.end(bytes.length ? bytes : undefined);
|
||||
} catch (error) {
|
||||
if (left.signal.aborted) return;
|
||||
const status = error instanceof TileUpstreamError ? error.status : 0;
|
||||
// A rejected token is a key problem the panel can name, not a fault.
|
||||
if (status === 401 || status === 403)
|
||||
return sendJson(res, 403, {
|
||||
error: 'Mapillary rejected the access token',
|
||||
keyRejected: true,
|
||||
});
|
||||
if (status === 429) {
|
||||
const retryAfter = retryAfterWait(error.retryAfter);
|
||||
res.setHeader('Retry-After', String(retryAfter));
|
||||
return sendJson(res, 429, {
|
||||
error: 'Mapillary is rate-limiting tile requests',
|
||||
retryAfter,
|
||||
});
|
||||
}
|
||||
// An upstream 400 must not read as a malformed request to this proxy.
|
||||
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: Mapillary coverage tiles with the token added server-side, and
|
||||
* a status route. Both refuse cross-site requests so another page cannot
|
||||
* spend the token.
|
||||
*/
|
||||
export function mapillaryProxy() {
|
||||
const install = (middlewares) => {
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: TILE_ROUTE_MAX_PER_MIN,
|
||||
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
|
||||
});
|
||||
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
|
||||
middlewares.use(
|
||||
'/api/mapillary/tiles',
|
||||
sameSiteGated((req, res) => handleTile(req, res, allow)),
|
||||
);
|
||||
};
|
||||
return {
|
||||
name: 'mapillary-proxy',
|
||||
configureServer: (server) => install(server.middlewares),
|
||||
configurePreviewServer: (server) => install(server.middlewares),
|
||||
};
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */
|
||||
export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
|
||||
|
||||
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
|
||||
export const TILE_LAYERS = Object.freeze({
|
||||
// Region-sized z6–10 tiles are refused; the app never asks for them. The
|
||||
// z14 `image` layer is ~98% of a tile and unused (positions come from the
|
||||
// graph API), so it is dropped in transit.
|
||||
coverage: Object.freeze({
|
||||
upstream: 'mly1_public',
|
||||
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
|
||||
dropLayers: Object.freeze(['image']),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Disk cache root, alongside the other providers' caches. */
|
||||
export const MAPILLARY_CACHE_DIR = path.join(
|
||||
process.cwd(),
|
||||
'.gev-cache',
|
||||
'mapillary',
|
||||
);
|
||||
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
|
||||
|
||||
/**
|
||||
* Longer than the 15-minute upstream Cache-Control: tiles change only when new
|
||||
* imagery is processed, and a day avoids re-downloading a city per camera move.
|
||||
*/
|
||||
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A tile write starts a background sweep at most this often: expired files go
|
||||
* first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES.
|
||||
*/
|
||||
export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000;
|
||||
export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
/** Per-file cost on top of its size, so empty tiles still count. */
|
||||
export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096;
|
||||
|
||||
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */
|
||||
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
||||
|
||||
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
|
||||
/** Per-entry cost on top of its bytes, so empty tiles still count. */
|
||||
export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024;
|
||||
export const TILE_FETCH_TIMEOUT_MS = 60_000;
|
||||
/**
|
||||
* Upstream fetches at once; further misses queue. A view needs at most 25
|
||||
* tiles, and the cap stops a burst of misses opening thousands of requests.
|
||||
*/
|
||||
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
||||
/**
|
||||
* Tile requests per client IP per minute, cache hits included. 600 is 24 new
|
||||
* 25-tile views a minute, more than a person flying the camera reaches, but it
|
||||
* stops one page draining the shared token into a Mapillary rate limit.
|
||||
*/
|
||||
export const TILE_ROUTE_MAX_PER_MIN = 600;
|
||||
/**
|
||||
* How long a 401/403 answers tile misses before asking Mapillary again. A
|
||||
* changed token is tried at once.
|
||||
*/
|
||||
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
|
||||
/** After a 429 without a usable Retry-After, hold tile misses this long. */
|
||||
export const TILE_RATE_LIMIT_HOLD_MS = 60_000;
|
||||
/** The longest Retry-After honoured, so a bad header cannot stall coverage. */
|
||||
export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000;
|
||||
|
||||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||||
export function mapillaryToken() {
|
||||
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { sameSiteGated } from '../common/same-site.js';
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js';
|
||||
import { mapillaryToken, TILE_ROUTE_MAX_PER_MIN } from './constants.js';
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/status — whether a token is configured, never its value. */
|
||||
function handleStatus(req, res) {
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */
|
||||
async function handleTile(req, res, allow) {
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
if (!allow(clientKey(req))) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
return sendJson(res, 429, {
|
||||
error: 'Too many tile requests',
|
||||
retryAfter: 5,
|
||||
});
|
||||
}
|
||||
const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(
|
||||
(req.url || '').split('?')[0],
|
||||
);
|
||||
if (!match)
|
||||
return sendJson(res, 400, {
|
||||
error: 'Tile path must be /coverage/{z}/{x}/{y}',
|
||||
});
|
||||
if (!mapillaryToken())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
||||
const abandoned = new AbortController();
|
||||
req.on?.('aborted', () => abandoned.abort());
|
||||
res.on?.('close', () => abandoned.abort());
|
||||
try {
|
||||
const { bytes, source } = await fetchTile(
|
||||
{ layer: match[1], z: match[2], x: match[3], y: match[4] },
|
||||
{ signal: abandoned.signal },
|
||||
);
|
||||
if (res.writableEnded) return;
|
||||
res.statusCode = bytes.length ? 200 : 204;
|
||||
res.setHeader('Content-Type', 'application/x-protobuf');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('X-Gev-Cache', source);
|
||||
res.end(bytes.length ? bytes : undefined);
|
||||
} catch (error) {
|
||||
if (res.writableEnded || abandoned.signal.aborted) return;
|
||||
if (error instanceof TileRequestError)
|
||||
return sendJson(res, error.status, { error: error.message });
|
||||
if (error instanceof TileUpstreamError) {
|
||||
// A rejected token is a key problem the panel can name, not a fault.
|
||||
if (error.keyRejected)
|
||||
return sendJson(res, 403, {
|
||||
error: 'Mapillary rejected the access token',
|
||||
keyRejected: true,
|
||||
});
|
||||
if (error.status === 429) {
|
||||
const retryAfter = error.retryAfterSec || 60;
|
||||
res.setHeader('Retry-After', String(retryAfter));
|
||||
return sendJson(res, 429, {
|
||||
error: 'Mapillary is rate-limiting tile requests',
|
||||
retryAfter,
|
||||
});
|
||||
}
|
||||
// An upstream 400 must not read as a malformed request to this proxy.
|
||||
return sendJson(res, 502, { error: error.message });
|
||||
}
|
||||
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Mapillary routes. Both refuse cross-site requests so another page
|
||||
* cannot spend the token or provoke a 429 that holds every tile miss.
|
||||
*/
|
||||
export function installMapillaryRoutes(middlewares) {
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: TILE_ROUTE_MAX_PER_MIN,
|
||||
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
|
||||
});
|
||||
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
|
||||
middlewares.use(
|
||||
'/api/mapillary/tiles',
|
||||
sameSiteGated((req, res) => handleTile(req, res, allow)),
|
||||
);
|
||||
}
|
||||
+216
-469
@@ -1,518 +1,265 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { readResponseBytesCapped } from '../common/http.js';
|
||||
import { stripTileLayers } from './trim.js';
|
||||
import {
|
||||
MAPILLARY_TILE_HOST,
|
||||
TILE_LAYERS,
|
||||
TILE_DISK_DIR,
|
||||
TILE_DISK_TTL_MS,
|
||||
TILE_DISK_SWEEP_INTERVAL_MS,
|
||||
TILE_DISK_MAX_BYTES,
|
||||
TILE_DISK_FILE_OVERHEAD_BYTES,
|
||||
TILE_MAX_BYTES,
|
||||
TILE_MEMORY_BUDGET_BYTES,
|
||||
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
|
||||
TILE_FETCH_TIMEOUT_MS,
|
||||
TILE_UPSTREAM_CONCURRENCY,
|
||||
TILE_KEY_REJECTED_HOLD_MS,
|
||||
TILE_RATE_LIMIT_HOLD_MS,
|
||||
TILE_RATE_LIMIT_MAX_HOLD_MS,
|
||||
mapillaryToken,
|
||||
} from './constants.js';
|
||||
|
||||
/** Log without the stack; the token is never in scope here. */
|
||||
function warn(what, error) {
|
||||
console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error);
|
||||
/** Mapillary vector tiles, /{layer}/2/{z}/{x}/{y}: the only host the token goes to. */
|
||||
const TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
|
||||
/** Only the street zooms the app draws are proxied. */
|
||||
const MIN_ZOOM = 11;
|
||||
const MAX_ZOOM = 14;
|
||||
/**
|
||||
* The z14 `image` layer is ~98% of a tile (12 MB over a dense city) and unused
|
||||
* (positions come from the Graph API), so it is dropped in transit.
|
||||
*/
|
||||
const DROP_LAYERS = ['image'];
|
||||
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
|
||||
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
/** Upstream fetches at once; more wait their turn. */
|
||||
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
||||
/** Tiles change only when new imagery is processed. */
|
||||
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
|
||||
export const TILE_MEMORY_MAX_TILES = 120;
|
||||
/** A trimmed tile is ~80 KB; a body this big failed to trim and is not kept. */
|
||||
export const TILE_MEMORY_MAX_TILE_BYTES = 4 * 1024 * 1024;
|
||||
/** The longest upstream Retry-After honoured, in seconds. */
|
||||
export const MAX_RETRY_AFTER_SEC = 600;
|
||||
|
||||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||||
export function mapillaryToken() {
|
||||
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
|
||||
}
|
||||
|
||||
/** Thrown for a request this proxy refuses before contacting Mapillary. */
|
||||
export class TileRequestError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
super(message);
|
||||
this.name = 'TileRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mapillary answered with an error status; 401/403 set `keyRejected`. */
|
||||
/** Mapillary answered with an error status (or a size this proxy refuses). */
|
||||
export class TileUpstreamError extends Error {
|
||||
constructor(status, message, { retryAfterSec = null } = {}) {
|
||||
constructor(status, message, retryAfter = null) {
|
||||
super(message || `Mapillary tiles HTTP ${status}`);
|
||||
this.name = 'TileUpstreamError';
|
||||
this.status = status;
|
||||
this.keyRejected = status === 401 || status === 403;
|
||||
this.retryAfterSec = retryAfterSec;
|
||||
/** The upstream Retry-After header, as sent. */
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected token or rate limit that answers every tile miss until it expires
|
||||
* (or the token changes). Cached tiles are still served meanwhile.
|
||||
* @type {{status: number, until: number, token: string}|null}
|
||||
*/
|
||||
let _upstreamHold = null;
|
||||
|
||||
/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */
|
||||
function retryAfterMs(header, now = Date.now()) {
|
||||
/** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */
|
||||
export function retryAfterSeconds(header, now = Date.now()) {
|
||||
const text = String(header ?? '').trim();
|
||||
let ms = NaN;
|
||||
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
|
||||
else if (text) ms = Date.parse(text) - now;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS;
|
||||
return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS);
|
||||
if (!text) return null;
|
||||
const seconds = /^\d+$/.test(text)
|
||||
? Number(text)
|
||||
: (Date.parse(text) - now) / 1000;
|
||||
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : null;
|
||||
}
|
||||
|
||||
function heldRefusal(token) {
|
||||
const hold = _upstreamHold;
|
||||
if (!hold) return null;
|
||||
const left = hold.until - Date.now();
|
||||
if (left <= 0 || hold.token !== token) {
|
||||
_upstreamHold = null;
|
||||
/** The wait a 429 asks for, bounded; a missing or malformed header means a minute. */
|
||||
export function retryAfterWait(header, now = Date.now()) {
|
||||
return Math.min(retryAfterSeconds(header, now) ?? 60, MAX_RETRY_AFTER_SEC);
|
||||
}
|
||||
|
||||
/**
|
||||
* While Mapillary rate-limits, tile misses are refused here until the wait is
|
||||
* over, so the rest of a view (and the fetches queued behind the first 429)
|
||||
* do not go on asking. Scoped to the token that was refused.
|
||||
* @type {{until: number, token: string}|null}
|
||||
*/
|
||||
let refusal = null;
|
||||
|
||||
/** The refusal still in force for `token`, as the error to answer with. */
|
||||
function refusedNow(token) {
|
||||
if (!refusal) return null;
|
||||
const left = refusal.until - Date.now();
|
||||
if (left <= 0 || refusal.token !== token) {
|
||||
refusal = null;
|
||||
return null;
|
||||
}
|
||||
return new TileUpstreamError(hold.status, undefined, {
|
||||
retryAfterSec: Math.ceil(left / 1000),
|
||||
});
|
||||
return new TileUpstreamError(429, undefined, String(Math.ceil(left / 1000)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a tile address against the layer's zoom ranges.
|
||||
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
|
||||
* Parse `/coverage/{z}/{x}/{y}`; null unless z is 11–14 and x/y are on the grid.
|
||||
* @returns {{z: number, x: number, y: number, key: string}|null}
|
||||
*/
|
||||
export function normalizeTileAddress({ layer, z, x, y }) {
|
||||
const spec = Object.hasOwn(TILE_LAYERS, layer) ? TILE_LAYERS[layer] : null;
|
||||
if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`);
|
||||
const zi = Number(z);
|
||||
const xi = Number(x);
|
||||
const yi = Number(y);
|
||||
if (
|
||||
![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) ||
|
||||
!spec.zoomRanges.some(([min, max]) => zi >= min && zi <= max)
|
||||
)
|
||||
throw new TileRequestError(
|
||||
`Tile zoom for ${layer} must be ${spec.zoomRanges
|
||||
.map(([min, max]) => `${min}–${max}`)
|
||||
.join(' or ')}`,
|
||||
);
|
||||
const n = 2 ** zi;
|
||||
if (xi >= n || yi >= n)
|
||||
throw new TileRequestError('Tile address out of range');
|
||||
return {
|
||||
layer,
|
||||
upstream: spec.upstream,
|
||||
dropLayers: spec.dropLayers || [],
|
||||
z: zi,
|
||||
x: xi,
|
||||
y: yi,
|
||||
key: `${layer}/${zi}/${xi}/${yi}`,
|
||||
};
|
||||
export function parseTilePath(pathname) {
|
||||
const match = /^\/coverage\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const [z, x, y] = match.slice(1).map(Number);
|
||||
if (z < MIN_ZOOM || z > MAX_ZOOM || x >= 2 ** z || y >= 2 ** z) return null;
|
||||
return { z, x, y, key: `${z}/${x}/${y}` };
|
||||
}
|
||||
|
||||
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
|
||||
const _memory = new Map();
|
||||
let _memoryBytes = 0;
|
||||
const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
|
||||
/**
|
||||
* Upstream fetches shared by every request for the same tile; the last waiter
|
||||
* to leave cancels it (see `joinFlight`).
|
||||
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
|
||||
*/
|
||||
const _inFlight = new Map();
|
||||
|
||||
function memoryGet(key) {
|
||||
const hit = _memory.get(key);
|
||||
if (!hit) return null;
|
||||
// Same life as the disk copy, so a long-running server never serves stale
|
||||
// coverage.
|
||||
if (Date.now() - hit.at > TILE_DISK_TTL_MS) {
|
||||
_memory.delete(key);
|
||||
_memoryBytes -= memoryCost(hit.bytes);
|
||||
return null;
|
||||
}
|
||||
_memory.delete(key);
|
||||
_memory.set(key, hit);
|
||||
return hit.bytes;
|
||||
}
|
||||
|
||||
/** `at` is when Mapillary served the bytes (a disk tile's mtime). */
|
||||
function memoryPut(key, bytes, at = Date.now()) {
|
||||
if (memoryCost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
|
||||
const existing = _memory.get(key);
|
||||
if (existing) _memoryBytes -= memoryCost(existing.bytes);
|
||||
_memory.set(key, { bytes, at });
|
||||
_memoryBytes += memoryCost(bytes);
|
||||
while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) {
|
||||
const [oldest, entry] = _memory.entries().next().value;
|
||||
_memory.delete(oldest);
|
||||
_memoryBytes -= memoryCost(entry.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disk cache root; tests point it at a temporary directory. */
|
||||
let _diskDir = TILE_DISK_DIR;
|
||||
|
||||
function diskPath({ layer, z, x, y }, root = _diskDir) {
|
||||
return path.join(root, layer, String(z), `${x}-${y}.pbf`);
|
||||
}
|
||||
|
||||
async function readDisk(address) {
|
||||
const file = diskPath(address);
|
||||
function trim(bytes) {
|
||||
try {
|
||||
const stat = await fsp.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null;
|
||||
return { bytes: await fsp.readFile(file), at: stat.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsettled background writes and sweeps; requests never wait on them. */
|
||||
const _background = new Set();
|
||||
|
||||
function track(task) {
|
||||
const settled = task
|
||||
.catch(() => {})
|
||||
.finally(() => _background.delete(settled));
|
||||
_background.add(settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
/** @type {Map<string, number>} cache file to its newest write's sequence */
|
||||
const _diskWrites = new Map();
|
||||
let _diskWriteSeq = 0;
|
||||
|
||||
function diskWritePending(address) {
|
||||
return _diskWrites.has(diskPath(address));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a tile in the background. `at` keeps a rewrite's original fetch time
|
||||
* so it does not extend the tile's life. An overtaken write steps aside.
|
||||
*/
|
||||
function writeDisk(address, bytes, at = null) {
|
||||
// Sweep this write's root even if the cache dir has moved since.
|
||||
const root = _diskDir;
|
||||
const file = diskPath(address, root);
|
||||
const seq = ++_diskWriteSeq;
|
||||
const tmp = `${file}.${process.pid}-${seq}.tmp`;
|
||||
const newest = () => _diskWrites.get(file) === seq;
|
||||
_diskWrites.set(file, seq);
|
||||
return track(
|
||||
fsp
|
||||
.mkdir(path.dirname(file), { recursive: true })
|
||||
.then(() => fsp.writeFile(tmp, bytes))
|
||||
.then(() => at != null && fsp.utimes(tmp, new Date(), new Date(at)))
|
||||
.then(() => {
|
||||
if (!newest()) return fsp.rm(tmp, { force: true });
|
||||
return fsp.rename(tmp, file).then(() => scheduleSweep(root));
|
||||
})
|
||||
.catch(async (error) => {
|
||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
||||
warn('tile cache write failed', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (newest()) _diskWrites.delete(file);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let _lastSweepAt = 0;
|
||||
let _sweeping = null;
|
||||
let _sweepWarned = false;
|
||||
|
||||
function scheduleSweep(root) {
|
||||
if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS)
|
||||
return;
|
||||
_lastSweepAt = Date.now();
|
||||
const sweep = sweepTileDisk({ root })
|
||||
.catch((error) => {
|
||||
if (_sweepWarned) return;
|
||||
_sweepWarned = true;
|
||||
warn('tile cache sweep failed', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (_sweeping === tracked) _sweeping = null;
|
||||
});
|
||||
const tracked = track(sweep);
|
||||
_sweeping = tracked;
|
||||
}
|
||||
|
||||
/** Every file under `dir` with size and mtime; a missing dir is empty. */
|
||||
async function listCacheFiles(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
return stripTileLayers(bytes, DROP_LAYERS);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await listCacheFiles(file)));
|
||||
else if (entry.isFile()) {
|
||||
const stat = await fsp.stat(file).catch(() => null);
|
||||
if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired tiles, then the oldest until the rest fit in `maxBytes`.
|
||||
* @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept
|
||||
*/
|
||||
export async function sweepTileDisk({
|
||||
root = _diskDir,
|
||||
maxBytes = TILE_DISK_MAX_BYTES,
|
||||
} = {}) {
|
||||
const now = Date.now();
|
||||
const files = (await listCacheFiles(root)).sort((a, b) => a.at - b.at);
|
||||
const cost = (size) => size + TILE_DISK_FILE_OVERHEAD_BYTES;
|
||||
let bytes = files.reduce((sum, { size }) => sum + cost(size), 0);
|
||||
let removed = 0;
|
||||
for (const { file, size, at } of files) {
|
||||
if (now - at <= TILE_DISK_TTL_MS && bytes <= maxBytes) break;
|
||||
await fsp.rm(file, { force: true });
|
||||
bytes -= cost(size);
|
||||
removed++;
|
||||
}
|
||||
return { removed, bytes };
|
||||
}
|
||||
|
||||
/** The one origin the tile token may be sent to, at every redirect hop. */
|
||||
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
|
||||
export const TILE_MAX_REDIRECTS = 3;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
/**
|
||||
* Follow redirects by hand so every hop stays HTTPS on the tile origin and the
|
||||
* token in the query never reaches another host. A bad redirect is a 502.
|
||||
*/
|
||||
async function fetchPinned(url, init) {
|
||||
let next = url;
|
||||
for (let hop = 0; ; hop++) {
|
||||
const response = await fetch(next, { ...init, redirect: 'manual' });
|
||||
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (hop >= TILE_MAX_REDIRECTS)
|
||||
throw new TileUpstreamError(502, 'Mapillary tile redirected too often');
|
||||
let target = null;
|
||||
try {
|
||||
target = new URL(response.headers.get('location') ?? '', next);
|
||||
} catch {
|
||||
/* unparseable Location */
|
||||
}
|
||||
if (
|
||||
!response.headers.get('location') ||
|
||||
target?.protocol !== 'https:' ||
|
||||
target.origin !== TILE_ORIGIN ||
|
||||
target.username ||
|
||||
target.password
|
||||
)
|
||||
throw new TileUpstreamError(
|
||||
502,
|
||||
'Mapillary tile redirect left the tile origin',
|
||||
);
|
||||
next = target.href;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpstream(address, signal) {
|
||||
const token = mapillaryToken();
|
||||
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
|
||||
const held = heldRefusal(token);
|
||||
if (held) throw held;
|
||||
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
|
||||
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
|
||||
const oversize = new AbortController();
|
||||
const response = await fetchPinned(url, {
|
||||
signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)),
|
||||
headers: { Accept: 'application/x-protobuf' },
|
||||
});
|
||||
if (response.status === 404 || response.status === 204) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
const error = new TileUpstreamError(response.status);
|
||||
if (error.keyRejected)
|
||||
_upstreamHold = {
|
||||
status: response.status,
|
||||
until: Date.now() + TILE_KEY_REJECTED_HOLD_MS,
|
||||
token,
|
||||
};
|
||||
else if (response.status === 429) {
|
||||
const wait = retryAfterMs(response.headers.get('retry-after'));
|
||||
_upstreamHold = { status: 429, until: Date.now() + wait, token };
|
||||
error.retryAfterSec = Math.ceil(wait / 1000);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Chunked or compressed bodies have no Content-Length: the cap applies as
|
||||
// the body streams, and an oversized one also aborts the request.
|
||||
try {
|
||||
const bytes = await readResponseBytesCapped(
|
||||
response,
|
||||
TILE_MAX_BYTES,
|
||||
signal,
|
||||
);
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
|
||||
oversize.abort();
|
||||
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{active: number, queue: Array<() => void>}} upstream slots */
|
||||
let _slots = { active: 0, queue: [] };
|
||||
|
||||
/**
|
||||
* Resolve with a release function once a slot is free. An abort while
|
||||
* queued leaves the queue.
|
||||
* @returns {Promise<() => void>}
|
||||
*/
|
||||
function acquireUpstreamSlot(signal) {
|
||||
const slots = _slots;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) return reject(signal.reason);
|
||||
const onAbort = () => {
|
||||
const index = slots.queue.indexOf(grant);
|
||||
if (index !== -1) slots.queue.splice(index, 1);
|
||||
reject(signal.reason);
|
||||
};
|
||||
function grant() {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
slots.active++;
|
||||
let released = false;
|
||||
resolve(() => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
slots.active--;
|
||||
slots.queue.shift()?.();
|
||||
});
|
||||
}
|
||||
if (slots.active < TILE_UPSTREAM_CONCURRENCY) return grant();
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
slots.queue.push(grant);
|
||||
});
|
||||
}
|
||||
|
||||
function trim(address, bytes) {
|
||||
if (!address.dropLayers.length || !bytes.length) return bytes;
|
||||
try {
|
||||
return stripTileLayers(bytes, address.dropLayers);
|
||||
} catch (error) {
|
||||
warn('tile trim failed, serving raw', error);
|
||||
console.warn('[Mapillary Proxy] tile trim failed:', error?.message);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty
|
||||
* bytes mean no coverage.
|
||||
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
|
||||
*/
|
||||
export async function fetchTile(request, { signal } = {}) {
|
||||
const address = normalizeTileAddress(request);
|
||||
const memory = memoryGet(address.key);
|
||||
if (memory) return { bytes: memory, source: 'memory', address };
|
||||
const disk = await readDisk(address);
|
||||
if (disk) {
|
||||
// Older cache files may be untrimmed: rewrite once, keeping the fetch time.
|
||||
const bytes = trim(address, disk.bytes);
|
||||
if (bytes !== disk.bytes && !diskWritePending(address))
|
||||
writeDisk(address, bytes, disk.at);
|
||||
memoryPut(address.key, bytes, disk.at);
|
||||
return { bytes, source: 'disk', address };
|
||||
/** One tile from Mapillary, trimmed; empty bytes mean no coverage. */
|
||||
async function fetchUpstream({ z, x, y }, signal) {
|
||||
const token = mapillaryToken();
|
||||
const refused = refusedNow(token);
|
||||
if (refused) throw refused;
|
||||
const response = await fetch(
|
||||
`${TILE_HOST}/mly1_public/2/${z}/${x}/${y}?access_token=${encodeURIComponent(token)}`,
|
||||
{
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]),
|
||||
// The token is in the query: a redirect is never followed (a 3xx is a 502).
|
||||
redirect: 'manual',
|
||||
headers: { Accept: 'application/x-protobuf' },
|
||||
},
|
||||
);
|
||||
if (!response.ok || response.status === 204) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (response.status === 204 || response.status === 404)
|
||||
return Buffer.alloc(0);
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
if (response.status === 429)
|
||||
refusal = {
|
||||
token,
|
||||
until: Date.now() + retryAfterWait(retryAfter) * 1000,
|
||||
};
|
||||
throw new TileUpstreamError(response.status, undefined, retryAfter);
|
||||
}
|
||||
try {
|
||||
const bytes = await readResponseBytesCapped(response, TILE_MAX_BYTES);
|
||||
return trim(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
|
||||
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
||||
}
|
||||
// A caller gone while the disk was read must not start a fetch nobody joins.
|
||||
signal?.throwIfAborted();
|
||||
let flight = _inFlight.get(address.key);
|
||||
const joined = Boolean(flight) && !flight.controller.signal.aborted;
|
||||
if (!joined) flight = startFlight(address);
|
||||
const bytes = await joinFlight(flight, signal);
|
||||
return { bytes, source: joined ? 'inflight' : 'upstream', address };
|
||||
}
|
||||
|
||||
/** Start the shared upstream fetch; it holds a slot until the body is read. */
|
||||
let upstreamActive = 0;
|
||||
/** @type {Array<() => void>} fetches waiting for a slot, oldest first */
|
||||
const upstreamWaiting = [];
|
||||
|
||||
/**
|
||||
* Run `task` in an upstream slot, waiting for one unless `signal` aborts
|
||||
* first. A freed slot passes straight to the oldest waiter, still counted, so
|
||||
* a request arriving meanwhile cannot take it first.
|
||||
*/
|
||||
async function withUpstreamSlot(signal, task) {
|
||||
if (upstreamActive < TILE_UPSTREAM_CONCURRENCY) upstreamActive++;
|
||||
else
|
||||
await new Promise((resolve, reject) => {
|
||||
const wake = () => {
|
||||
signal.removeEventListener('abort', leave);
|
||||
resolve();
|
||||
};
|
||||
const leave = () => {
|
||||
const at = upstreamWaiting.indexOf(wake);
|
||||
if (at !== -1) upstreamWaiting.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
};
|
||||
upstreamWaiting.push(wake);
|
||||
signal.addEventListener('abort', leave, { once: true });
|
||||
});
|
||||
try {
|
||||
// Handed a slot after leaving: it goes on to the next waiter.
|
||||
signal.throwIfAborted();
|
||||
return await task();
|
||||
} finally {
|
||||
const next = upstreamWaiting.shift();
|
||||
if (next) next();
|
||||
else upstreamActive--;
|
||||
}
|
||||
}
|
||||
|
||||
/** `promise`, or a rejection as soon as `signal` aborts. */
|
||||
function unlessAborted(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const leave = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', leave, { once: true });
|
||||
promise
|
||||
.then(resolve, reject)
|
||||
.finally(() => signal.removeEventListener('abort', leave));
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
|
||||
const memory = new Map();
|
||||
/**
|
||||
* Upstream fetches shared per tile, with the requests still waiting on each.
|
||||
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
|
||||
*/
|
||||
const inFlight = new Map();
|
||||
|
||||
function remember(key, bytes, at = Date.now()) {
|
||||
if (bytes.length > TILE_MEMORY_MAX_TILE_BYTES) return;
|
||||
memory.delete(key);
|
||||
memory.set(key, { bytes, at });
|
||||
for (const oldest of memory.keys()) {
|
||||
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
|
||||
memory.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function recall(key) {
|
||||
const hit = memory.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.at > TILE_TTL_MS) {
|
||||
memory.delete(key);
|
||||
return null;
|
||||
}
|
||||
remember(key, hit.bytes, hit.at); // most recently used again
|
||||
return hit.bytes;
|
||||
}
|
||||
|
||||
/** Start the upstream fetch for a tile, shared until its last request leaves. */
|
||||
function startFlight(address) {
|
||||
const controller = new AbortController();
|
||||
const flight = { controller, waiters: 0, promise: null };
|
||||
flight.promise = acquireUpstreamSlot(controller.signal)
|
||||
.then((release) =>
|
||||
fetchUpstream(address, controller.signal).finally(release),
|
||||
)
|
||||
.then((raw) => trim(address, raw))
|
||||
const flight = { controller, waiters: 0 };
|
||||
flight.promise = withUpstreamSlot(controller.signal, () =>
|
||||
fetchUpstream(address, controller.signal),
|
||||
)
|
||||
.then((bytes) => {
|
||||
memoryPut(address.key, bytes);
|
||||
writeDisk(address, bytes);
|
||||
remember(address.key, bytes);
|
||||
return bytes;
|
||||
})
|
||||
.finally(() => {
|
||||
if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key);
|
||||
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
|
||||
});
|
||||
// Every waiter may have left before it settles; nobody awaits it then.
|
||||
// Every request may have left; the rejection is then nobody's.
|
||||
flight.promise.catch(() => {});
|
||||
_inFlight.set(address.key, flight);
|
||||
inFlight.set(address.key, flight);
|
||||
return flight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait on a shared flight. A caller's abort rejects only that caller; the
|
||||
* fetch is cancelled when the last waiter leaves.
|
||||
* One tile from memory, a shared in-flight fetch, or Mapillary. `signal`
|
||||
* aborts when the request's client leaves; the fetch stops once all have.
|
||||
* @param {{z: number, x: number, y: number, key: string}} address from parseTilePath
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{bytes: Buffer, source: 'memory'|'inflight'|'upstream'}>}
|
||||
*/
|
||||
function joinFlight(flight, signal) {
|
||||
signal?.throwIfAborted();
|
||||
export async function fetchTile(address, { signal } = {}) {
|
||||
const cached = recall(address.key);
|
||||
if (cached) return { bytes: cached, source: 'memory' };
|
||||
let flight = inFlight.get(address.key);
|
||||
const shared = Boolean(flight);
|
||||
flight ||= startFlight(address);
|
||||
flight.waiters++;
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
const leave = () => {
|
||||
if (done) return false;
|
||||
done = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
flight.waiters--;
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (!leave()) return;
|
||||
if (flight.waiters === 0) flight.controller.abort();
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
flight.promise.then(
|
||||
(bytes) => leave() && resolve(bytes),
|
||||
(error) => leave() && reject(error),
|
||||
);
|
||||
});
|
||||
try {
|
||||
const bytes = await unlessAborted(flight.promise, signal);
|
||||
return { bytes, source: shared ? 'inflight' : 'upstream' };
|
||||
} finally {
|
||||
if (--flight.waiters === 0) {
|
||||
// A later request for the tile must not join a cancelled fetch.
|
||||
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
|
||||
flight.controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget every cached tile held in memory. */
|
||||
export function _resetTileMemoryForTest() {
|
||||
_memory.clear();
|
||||
_memoryBytes = 0;
|
||||
_inFlight.clear();
|
||||
_upstreamHold = null;
|
||||
_slots = { active: 0, queue: [] };
|
||||
}
|
||||
|
||||
/** Test seam: how many tiles memory holds and the bytes charged for them. */
|
||||
export function _tileMemoryForTest() {
|
||||
return { entries: _memory.size, bytes: _memoryBytes };
|
||||
}
|
||||
|
||||
/** Test seam: wait for every background write and the sweeps they start. */
|
||||
export async function _settleTileWritesForTest() {
|
||||
while (_background.size) await Promise.allSettled([..._background]);
|
||||
}
|
||||
|
||||
/** Test seam: keep the disk cache in `dir` (null restores the default). */
|
||||
export function _setTileCacheDirForTest(dir) {
|
||||
_diskDir = dir || TILE_DISK_DIR;
|
||||
_lastSweepAt = 0;
|
||||
/** Test seam: forget every cached and in-flight tile. */
|
||||
export function _resetTileCacheForTest() {
|
||||
memory.clear();
|
||||
inFlight.clear();
|
||||
refusal = null;
|
||||
upstreamActive = 0;
|
||||
upstreamWaiting.length = 0;
|
||||
}
|
||||
@@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) {
|
||||
}, null);
|
||||
return dropped ? Buffer.from(writer.finish()) : bytes;
|
||||
}
|
||||
|
||||
/** Names of the layers inside a vector tile, in order. */
|
||||
export function listTileLayers(bytes) {
|
||||
const names = [];
|
||||
if (!bytes?.length) return names;
|
||||
new PbfReader(bytes).readFields((tag, _result, pbf) => {
|
||||
if (tag !== TILE_LAYER_FIELD) return;
|
||||
const end = pbf.readVarint() + pbf.pos;
|
||||
names.push(layerName(bytes.subarray(pbf.pos, end)));
|
||||
pbf.pos = end;
|
||||
}, null);
|
||||
return names;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
|
||||
const normalized = String(token || '').trim();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'User-Agent':
|
||||
'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
terrainPointKey,
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
|
||||
@@ -20,11 +22,31 @@ import {
|
||||
* Only missing/stale points go upstream; the response is rebuilt in exact
|
||||
* request order. Larger requests are chunked sequentially, and one failing
|
||||
* chunk does not discard the chunks that resolved.
|
||||
*
|
||||
* Bounds, because every distinct 5dp point is its own key: coordinates must be
|
||||
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling
|
||||
* with oldest-first eviction, an over-large cache file is not read back, and
|
||||
* the route carries an always-on per-client limiter. The route also refuses
|
||||
* cross-site browser requests, so another web page cannot use the visitor's
|
||||
* browser to fill the cache or spend the visitor's own rate-limit quota.
|
||||
* Reverse-proxied same-origin requests are admitted: terrain is keyless and
|
||||
* needed for rendering, and a 403 would send every chunk to the geoid fallback.
|
||||
*/
|
||||
export function terrainHeightsProxy() {
|
||||
export function terrainHeightsProxy(options = {}) {
|
||||
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
|
||||
// extracts these proxies by slicing to the first closing brace in column
|
||||
// zero, so a multi-line parameter list would cut the function in half.
|
||||
// Overridable so the ceilings can be exercised as behaviour rather than
|
||||
// asserted as constants; production constructs this with no arguments.
|
||||
const {
|
||||
cacheMaxEntries = 50_000,
|
||||
cacheMaxDiskBytes = 24 * 1024 * 1024,
|
||||
cachePath,
|
||||
} = options;
|
||||
const TTL_MS = 30 * 24 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json');
|
||||
const CACHE_PATH =
|
||||
cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json');
|
||||
const CACHE_DIR = path.dirname(CACHE_PATH);
|
||||
// Sized against measured upstream latency, not the documented page cap.
|
||||
// Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12,
|
||||
// a 2x swing within one hour). 256 points therefore costs 22-48 s and blows
|
||||
@@ -34,9 +56,50 @@ export function terrainHeightsProxy() {
|
||||
// that range.
|
||||
const UPSTREAM_CHUNK = 64;
|
||||
const MAX_POINTS = 2000;
|
||||
/**
|
||||
* Entry ceiling for the point cache, and with it the file written from it.
|
||||
*
|
||||
* Every distinct 5dp point is a separate key, so without a ceiling the map
|
||||
* — and the JSON dumped from it verbatim — grow for as long as a caller
|
||||
* supplies new coordinates. Measured against real use rather than guessed:
|
||||
* this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in
|
||||
* 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is
|
||||
* eight times that working set and ~8 MB on disk, comfortably inside the
|
||||
* 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file.
|
||||
*/
|
||||
const CACHE_MAX_ENTRIES = cacheMaxEntries;
|
||||
/** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */
|
||||
const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes;
|
||||
/**
|
||||
* Requests/min/client. Terrain is keyless and free, so this guards the point
|
||||
* cache and the upstream community service rather than a bill. Sized from
|
||||
* measurement: across a full `npm run test:track` run — the heaviest
|
||||
* exercise this app has — the route served 10 requests, peaking at 8/min.
|
||||
* 90/min is an order of magnitude above that, and the band `/api/overpass`
|
||||
* and `/api/military-installations` already use.
|
||||
*/
|
||||
const RATE_PER_MIN = 90;
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: RATE_PER_MIN,
|
||||
globalMax: RATE_PER_MIN * 3,
|
||||
});
|
||||
|
||||
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
|
||||
const mem = new Map();
|
||||
|
||||
/**
|
||||
* Drop the oldest entries until the map is back inside its ceiling.
|
||||
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
|
||||
* Terrain does not move, so evicting an old point costs one refetch, never
|
||||
* a wrong answer.
|
||||
*/
|
||||
function evictOverflow() {
|
||||
if (mem.size <= CACHE_MAX_ENTRIES) return;
|
||||
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
|
||||
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
|
||||
mem.delete(byAge[i][0]);
|
||||
}
|
||||
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
|
||||
const inflight = new Map();
|
||||
let diskLoaded = false;
|
||||
@@ -47,6 +110,21 @@ export function terrainHeightsProxy() {
|
||||
if (diskLoaded) return;
|
||||
diskLoaded = true;
|
||||
try {
|
||||
// A cache file past the ceiling is not read back into memory: loading it
|
||||
// would reinstate exactly the unbounded map the ceiling exists to stop.
|
||||
// The next flush rewrites a bounded one.
|
||||
//
|
||||
// "Cannot measure it" is not "too large": if stat is unavailable, fall
|
||||
// through and let readFile decide, so an absent file behaves exactly as
|
||||
// it did before this guard existed.
|
||||
let sizeBytes = null;
|
||||
try {
|
||||
sizeBytes = (await fsp.stat(CACHE_PATH)).size;
|
||||
} catch {
|
||||
sizeBytes = null;
|
||||
}
|
||||
if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES)
|
||||
throw new Error('cache file too large');
|
||||
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
|
||||
const pointEntries =
|
||||
parsed?.version === 2 &&
|
||||
@@ -87,6 +165,7 @@ export function terrainHeightsProxy() {
|
||||
}
|
||||
diskDirty = mem.size > 0;
|
||||
}
|
||||
evictOverflow();
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
@@ -157,12 +236,25 @@ export function terrainHeightsProxy() {
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/terrain/heights', async (req, res) => {
|
||||
// Refuse cross-site browser requests (an <img> or fetch from another
|
||||
// site) before the limiter, cache or upstream see them. Same-origin app
|
||||
// requests, non-browser loopback callers (MCP panel_request) and the app
|
||||
// served behind a reverse proxy pass.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const send = (status, bodyObj) => {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(bodyObj));
|
||||
};
|
||||
try {
|
||||
if (!allow(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
await loadDiskOnce();
|
||||
const parsedUrl = new URL(req.url || '', 'http://internal');
|
||||
const rawPoints = parsedUrl.searchParams.get('points');
|
||||
@@ -187,7 +279,10 @@ export function terrainHeightsProxy() {
|
||||
fetchMissing: fetchMissingSingleFlight,
|
||||
ttlMs: TTL_MS,
|
||||
});
|
||||
if (outcome.cacheChanged) diskDirty = true;
|
||||
if (outcome.cacheChanged) {
|
||||
evictOverflow();
|
||||
diskDirty = true;
|
||||
}
|
||||
if (outcome.upstreamError) {
|
||||
console.warn(
|
||||
'[terrain-heights-proxy] refresh incomplete' +
|
||||
|
||||
@@ -7,7 +7,6 @@ import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
import { standaloneVoiceTools } from './voiceTools.js';
|
||||
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
||||
import { vcamPlugin } from '../vcam/plugin.js';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
@@ -17,11 +16,11 @@ export default defineConfig(({ command, mode }) => {
|
||||
for (const [key, value] of Object.entries(loaded)) {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
const tools = standaloneVoiceTools();
|
||||
return createBrowserViteConfig({
|
||||
plugins: [
|
||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
||||
...localProviderPlugins({ realtime: { tools }, gemini: { tools } }),
|
||||
localMcpPlugin(),
|
||||
vcamPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
unwatchedRuntimeDirsPlugin(),
|
||||
],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0a0a0f"/><circle cx="32" cy="32" r="17" fill="none" stroke="#00d4ff" stroke-width="3"/><circle cx="32" cy="32" r="7" fill="#00d4ff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8" stroke="#00d4ff" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
Before Width: | Height: | Size: 343 B |
@@ -1,154 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#0a0a0f" />
|
||||
<title>GEV Virtual Camera</title>
|
||||
<link rel="icon" href="icon.svg" />
|
||||
<link rel="stylesheet" href="phone.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="pair" class="screen">
|
||||
<img class="logo" src="icon.svg" alt="" />
|
||||
<h1>VIRTUAL CAMERA</h1>
|
||||
<p class="lede">
|
||||
Enter the code shown in God's Eye View under SCENES › VCAM.
|
||||
</p>
|
||||
<form id="pair-form" autocomplete="off">
|
||||
<input
|
||||
id="code"
|
||||
inputmode="text"
|
||||
autocapitalize="characters"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
maxlength="6"
|
||||
placeholder="CODE"
|
||||
aria-label="Pairing code"
|
||||
/>
|
||||
<button id="join" type="submit" class="primary">PAIR</button>
|
||||
</form>
|
||||
<p id="pair-status" class="status" role="status"></p>
|
||||
<details class="help">
|
||||
<summary>Connection drops on iPhone?</summary>
|
||||
<p>
|
||||
Safari can load this page past a certificate warning but may still
|
||||
refuse its live connection, so the camera falls back to slower
|
||||
requests. To fix that once:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<a id="ca-link" href="/ca.crt">Download the local certificate</a>
|
||||
and allow the profile.
|
||||
</li>
|
||||
<li>Settings › General › VPN & Device Management: install it.</li>
|
||||
<li>
|
||||
Settings › General › About › Certificate Trust Settings: turn on
|
||||
full trust for "God's Eye View Local VCam CA".
|
||||
</li>
|
||||
</ol>
|
||||
<p>The certificate only lets your phone trust this computer.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="start" class="screen" hidden>
|
||||
<img class="logo" src="icon.svg" alt="" />
|
||||
<h1>PAIRED</h1>
|
||||
<p id="support" class="lede"></p>
|
||||
<button id="start-xr" class="primary" hidden>START AR CAMERA</button>
|
||||
<button id="start-gyro" class="primary">START GYRO CAMERA</button>
|
||||
<p class="hint">
|
||||
AR tracks position and rotation (6DoF). Gyro tracks rotation only;
|
||||
move with the on-screen sticks.
|
||||
</p>
|
||||
<p id="start-status" class="status" role="status"></p>
|
||||
</section>
|
||||
|
||||
<section id="camera" class="screen camera" hidden>
|
||||
<img id="monitor" alt="" draggable="false" />
|
||||
<div id="focus-ring" hidden></div>
|
||||
<div class="topbar">
|
||||
<span id="link-dot" class="dot" title="Connection"></span>
|
||||
<span id="mode-readout" class="chip">—</span>
|
||||
<span id="rec-readout" class="chip rec" hidden>● 00:00</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="scale-readout" class="chip">1:1</span>
|
||||
<span id="focal-readout" class="chip">31mm</span>
|
||||
</div>
|
||||
<p id="camera-status" class="toast" role="status" hidden></p>
|
||||
|
||||
<div class="stick left" id="stick-left" aria-label="Move">
|
||||
<div class="knob"></div>
|
||||
<span>MOVE</span>
|
||||
</div>
|
||||
<div class="stick right" id="stick-right" aria-label="Crane and pan">
|
||||
<div class="knob"></div>
|
||||
<span>CRANE · PAN</span>
|
||||
</div>
|
||||
|
||||
<div class="bottombar">
|
||||
<button id="hold-btn" class="tool" aria-pressed="false">HOLD</button>
|
||||
<button id="recenter-btn" class="tool">RECENTER</button>
|
||||
<button id="record-btn" class="record" aria-pressed="false">
|
||||
<span class="record-dot"></span><span class="sr">RECORD</span>
|
||||
</button>
|
||||
<button id="scale-btn" class="tool" aria-expanded="false">
|
||||
SCALE
|
||||
</button>
|
||||
<button id="lens-btn" class="tool" aria-expanded="false">LENS</button>
|
||||
</div>
|
||||
|
||||
<div id="scale-drawer" class="drawer" hidden>
|
||||
<div class="presets" id="scale-presets">
|
||||
<button data-scale="1">1:1<small>WALK</small></button>
|
||||
<button data-scale="10">1:10<small>CRANE</small></button>
|
||||
<button data-scale="100">1:100<small>DRONE</small></button>
|
||||
<button data-scale="1000">1:1K<small>FLYOVER</small></button>
|
||||
<button data-scale="10000">1:10K<small>ORBIT</small></button>
|
||||
</div>
|
||||
<label
|
||||
>MOTION SCALE <output id="scale-value">1:1</output>
|
||||
<input id="scale-range" type="range" min="0" max="5" step="0.01" value="0"
|
||||
/></label>
|
||||
<label
|
||||
>SMOOTHING <output id="smooth-value">30%</output>
|
||||
<input id="smooth-range" type="range" min="0" max="100" step="1" value="30"
|
||||
/></label>
|
||||
<div class="toggles">
|
||||
<label><input id="height-lock" type="checkbox" /> HEIGHT LOCK</label>
|
||||
<label><input id="horizon-lock" type="checkbox" /> HORIZON LOCK</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lens-drawer" class="drawer" hidden>
|
||||
<label
|
||||
>FOCAL LENGTH <output id="focal-value">31mm</output>
|
||||
<input id="focal-range" type="range" min="0" max="1" step="0.001" value="0.3"
|
||||
/></label>
|
||||
<div class="presets" id="focal-presets">
|
||||
<button data-focal="14">14</button>
|
||||
<button data-focal="24">24</button>
|
||||
<button data-focal="35">35</button>
|
||||
<button data-focal="50">50</button>
|
||||
<button data-focal="85">85</button>
|
||||
<button data-focal="200">200</button>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<label><input id="dof-toggle" type="checkbox" /> DEPTH OF FIELD</label>
|
||||
</div>
|
||||
<label
|
||||
>APERTURE <output id="fstop-value">f/2.8</output>
|
||||
<input id="fstop-range" type="range" min="0" max="1" step="0.001" value="0.35"
|
||||
/></label>
|
||||
<p class="hint">Tap the picture to focus there.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="phone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Device orientation to the virtual camera's Y-up pose frame (+x right,
|
||||
* +y up, -z forward), shared by the phone page and its tests.
|
||||
*/
|
||||
export const quat = {
|
||||
multiply(a, b) {
|
||||
const [ax, ay, az, aw] = a;
|
||||
const [bx, by, bz, bw] = b;
|
||||
return [
|
||||
aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
];
|
||||
},
|
||||
axisAngle(axis, angle) {
|
||||
const s = Math.sin(angle / 2);
|
||||
return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(angle / 2)];
|
||||
},
|
||||
};
|
||||
const DEG = Math.PI / 180;
|
||||
// Device orientation is device→earth (x east, y north, z up). Rotating -90°
|
||||
// about x maps that earth frame to the Y-up frame (north becomes -z).
|
||||
const EARTH_TO_Y_UP = quat.axisAngle([1, 0, 0], -Math.PI / 2);
|
||||
|
||||
/** W3C DeviceOrientation (Z-X'-Y'') to a Y-up quaternion for the current screen orientation. */
|
||||
export function orientationQuaternion(alpha, beta, gamma, screenAngle) {
|
||||
const x = (beta || 0) * DEG;
|
||||
const y = (gamma || 0) * DEG;
|
||||
const z = (alpha || 0) * DEG;
|
||||
const cX = Math.cos(x / 2);
|
||||
const cY = Math.cos(y / 2);
|
||||
const cZ = Math.cos(z / 2);
|
||||
const sX = Math.sin(x / 2);
|
||||
const sY = Math.sin(y / 2);
|
||||
const sZ = Math.sin(z / 2);
|
||||
const device = [
|
||||
sX * cY * cZ - cX * sY * sZ,
|
||||
cX * sY * cZ + sX * cY * sZ,
|
||||
cX * cY * sZ + sX * sY * cZ,
|
||||
cX * cY * cZ - sX * sY * sZ,
|
||||
];
|
||||
const screen = quat.axisAngle([0, 0, 1], -(screenAngle || 0) * DEG);
|
||||
return quat.multiply(EARTH_TO_Y_UP, quat.multiply(device, screen));
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { orientationQuaternion } from './orientation.js';
|
||||
import { rotateByQuaternion } from '../../../src/vcam/rig.js';
|
||||
|
||||
const close = (actual, expected, message) =>
|
||||
actual.forEach((value, i) =>
|
||||
assert.ok(Math.abs(value - expected[i]) < 1e-9, `${message}: ${actual} vs ${expected}`),
|
||||
);
|
||||
const axes = (q) => ({
|
||||
forward: rotateByQuaternion(q, [0, 0, -1]),
|
||||
up: rotateByQuaternion(q, [0, 1, 0]),
|
||||
});
|
||||
|
||||
test('an upright portrait phone looks out of its back along the Y-up forward axis', () => {
|
||||
// beta 90: stood up from flat, screen toward the user, back toward north.
|
||||
const { forward, up } = axes(orientationQuaternion(0, 90, 0, 0));
|
||||
close(forward, [0, 0, -1], 'faces north (-z)');
|
||||
close(up, [0, 1, 0], 'top edge up');
|
||||
});
|
||||
|
||||
test('alpha turns the phone counterclockwise seen from above', () => {
|
||||
const { forward } = axes(orientationQuaternion(90, 90, 0, 0));
|
||||
close(forward, [-1, 0, 0], 'faces west');
|
||||
});
|
||||
|
||||
test('a landscape screen reports the picture\'s up, not the device\'s top edge', () => {
|
||||
// Rotated a quarter turn counterclockwise: the top edge points west and the
|
||||
// right edge up, the back still faces north, and the screen angle is 90.
|
||||
const { forward, up } = axes(orientationQuaternion(90, 0, -90, 90));
|
||||
close(forward, [0, 0, -1], 'still faces north');
|
||||
close(up, [0, 1, 0], 'picture up is world up');
|
||||
});
|
||||
|
||||
test('a phone lying flat, screen up, looks straight down', () => {
|
||||
const { forward, up } = axes(orientationQuaternion(0, 0, 0, 0));
|
||||
close(forward, [0, -1, 0], 'down');
|
||||
close(up, [0, 0, -1], 'top edge toward north');
|
||||
});
|
||||
@@ -1,371 +0,0 @@
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--glass: rgba(12, 12, 20, 0.72);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--accent: #00d4ff;
|
||||
--accent-dim: rgba(0, 212, 255, 0.18);
|
||||
--text: #e8eaed;
|
||||
--muted: rgba(232, 234, 237, 0.6);
|
||||
--rec: #ff3b4e;
|
||||
--mono: 'SF Mono', ui-monospace, 'JetBrains Mono', Menlo, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.screen {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: max(24px, env(safe-area-inset-top)) 24px
|
||||
max(24px, env(safe-area-inset-bottom));
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.lede,
|
||||
.hint,
|
||||
.status {
|
||||
margin: 0;
|
||||
max-width: 34ch;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
.status.error {
|
||||
color: #ff8a95;
|
||||
}
|
||||
#pair-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
#code {
|
||||
width: 9.5ch;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
color: var(--text);
|
||||
font: 600 22px var(--mono);
|
||||
letter-spacing: 0.2em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.primary {
|
||||
min-width: 220px;
|
||||
padding: 14px 20px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
background: var(--accent-dim);
|
||||
font: 600 15px var(--mono);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
#pair-form .primary {
|
||||
min-width: 0;
|
||||
}
|
||||
.help {
|
||||
max-width: 36ch;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.help summary {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.help a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Camera monitor: the desktop's render fills the screen. */
|
||||
.camera {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: #000;
|
||||
}
|
||||
#monitor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.topbar,
|
||||
.bottombar {
|
||||
position: absolute;
|
||||
left: max(10px, env(safe-area-inset-left));
|
||||
right: max(10px, env(safe-area-inset-right));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.topbar {
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
}
|
||||
.bottombar {
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.chip {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--glass);
|
||||
font: 600 12px var(--mono);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.chip.rec {
|
||||
border-color: var(--rec);
|
||||
color: var(--rec);
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #777;
|
||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.dot.live {
|
||||
background: #3ddc84;
|
||||
}
|
||||
.dot.slow {
|
||||
background: #ffb020;
|
||||
}
|
||||
.toast {
|
||||
position: absolute;
|
||||
top: calc(max(10px, env(safe-area-inset-top)) + 40px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
padding: 7px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool {
|
||||
min-width: 64px;
|
||||
padding: 10px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
font: 600 11px var(--mono);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.tool[aria-pressed='true'],
|
||||
.tool[aria-expanded='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.record {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.record-dot {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--rec);
|
||||
transition:
|
||||
border-radius 150ms,
|
||||
width 150ms,
|
||||
height 150ms;
|
||||
}
|
||||
.record[aria-pressed='true'] .record-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
/* Virtual sticks sit low on each side, under the thumbs. */
|
||||
.stick {
|
||||
position: absolute;
|
||||
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 84px);
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: rgba(12, 12, 20, 0.38);
|
||||
touch-action: none;
|
||||
}
|
||||
.stick.left {
|
||||
left: max(18px, env(safe-area-inset-left));
|
||||
}
|
||||
.stick.right {
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
}
|
||||
.stick span {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
color: var(--muted);
|
||||
font: 600 10px var(--mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-align: center;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: -26px 0 0 -26px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 84px);
|
||||
transform: translateX(-50%);
|
||||
width: min(360px, calc(100% - 24px));
|
||||
max-height: 60%;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: rgba(12, 12, 20, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font: 600 11px var(--mono);
|
||||
letter-spacing: 0.08em;
|
||||
z-index: 2;
|
||||
}
|
||||
.drawer label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
.drawer input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.drawer output {
|
||||
color: var(--accent);
|
||||
}
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.presets button {
|
||||
flex: 1;
|
||||
padding: 8px 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: 600 12px var(--mono);
|
||||
}
|
||||
.presets button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.presets small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
.toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
.toggles label {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.drawer .hint {
|
||||
font: 12px var(--sans);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
#focus-ring {
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: -32px 0 0 -32px;
|
||||
border: 2px solid #ffd60a;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
animation: focus 700ms ease-out forwards;
|
||||
}
|
||||
@keyframes focus {
|
||||
from {
|
||||
transform: scale(1.5);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.stick {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 24px);
|
||||
}
|
||||
.bottombar {
|
||||
left: 160px;
|
||||
right: 160px;
|
||||
}
|
||||
}
|
||||
@@ -1,770 +0,0 @@
|
||||
/**
|
||||
* God's Eye View virtual camera — phone side.
|
||||
*
|
||||
* Streams this phone's pose to the desktop app and shows the app's render as
|
||||
* a monitor. Tracking uses WebXR `immersive-ar` (6DoF, Android Chrome with
|
||||
* ARCore) when available, otherwise device orientation (3DoF, iPhone Safari)
|
||||
* with on-screen sticks for movement. Poses are sent in one Y-up frame for
|
||||
* both: +x right, +y up, -z forward (WebXR's convention); yaw is arbitrary
|
||||
* and the desktop aligns it on recenter.
|
||||
*
|
||||
* Transport: a WebSocket when the connection allows it, otherwise HTTPS
|
||||
* requests (iOS refuses WebSockets under a tapped-through certificate). The
|
||||
* monitor always pulls the newest frame, so a slow network drops frames
|
||||
* instead of building a backlog.
|
||||
*/
|
||||
|
||||
import { orientationQuaternion } from './orientation.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const KEY_STORAGE = 'gevVcam.phoneKey';
|
||||
const CODE_STORAGE = 'gevVcam.code';
|
||||
const SCALE_PRESETS = [1, 10, 100, 1000, 10000];
|
||||
const FOCAL_RANGE = [12, 400];
|
||||
const FSTOP_RANGE = [1.2, 22];
|
||||
const STICK_DEADZONE = 0.12;
|
||||
|
||||
const store = {
|
||||
get(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
try {
|
||||
if (value === null) sessionStorage.removeItem(key);
|
||||
else sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Private browsing: pairing still works for this page load.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const logMap = ([low, high], v) => low * (high / low) ** v;
|
||||
const logUnmap = ([low, high], value) =>
|
||||
Math.log(value / low) / Math.log(high / low);
|
||||
const round = (value, places = 5) => {
|
||||
const k = 10 ** places;
|
||||
return Math.round(value * k) / k;
|
||||
};
|
||||
const formatScale = (scale) =>
|
||||
scale >= 1000
|
||||
? `1:${+(scale / 1000).toFixed(scale >= 10000 ? 0 : 1)}K`
|
||||
: `1:${+scale.toFixed(scale < 10 ? 1 : 0)}`;
|
||||
const formatTime = (sec) => {
|
||||
const s = Math.max(0, Math.floor(sec));
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const screenAngle = () =>
|
||||
screen.orientation?.angle ?? (Number(window.orientation) || 0);
|
||||
|
||||
// ------------------------------------------------------------------ transport
|
||||
class Link {
|
||||
constructor(key, { onMessage, onStatus, onUnpaired }) {
|
||||
this.key = key;
|
||||
this.onMessage = onMessage;
|
||||
this.onStatus = onStatus;
|
||||
this.onUnpaired = onUnpaired;
|
||||
this.ws = null;
|
||||
this.events = null;
|
||||
this.mode = 'connecting';
|
||||
this.queue = [];
|
||||
this.pendingPose = null;
|
||||
this.inFlight = false;
|
||||
this.closed = false;
|
||||
this.webSocketFailed = false;
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.closed) return;
|
||||
this.onStatus('connecting');
|
||||
if (!this.webSocketFailed && (await this.openWebSocket())) return;
|
||||
this.webSocketFailed = true;
|
||||
this.openHttp();
|
||||
}
|
||||
|
||||
openWebSocket() {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(
|
||||
`wss://${location.host}/ws?key=${encodeURIComponent(this.key)}`,
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const done = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
done(false);
|
||||
}, 3500);
|
||||
ws.onopen = () => {
|
||||
this.ws = ws;
|
||||
this.mode = 'ws';
|
||||
this.onStatus('live');
|
||||
done(true);
|
||||
};
|
||||
ws.onerror = () => done(false);
|
||||
ws.onmessage = (event) => this.receive(event.data);
|
||||
ws.onclose = (event) => {
|
||||
done(false);
|
||||
if (this.ws !== ws) return;
|
||||
this.ws = null;
|
||||
this.mode = 'connecting';
|
||||
this.handleClose(event.code);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
openHttp() {
|
||||
if (this.closed) return;
|
||||
this.mode = 'http';
|
||||
const events = new EventSource(
|
||||
`/api/events?key=${encodeURIComponent(this.key)}`,
|
||||
);
|
||||
this.events = events;
|
||||
events.onopen = () => this.onStatus('slow');
|
||||
events.onmessage = (event) => this.receive(event.data);
|
||||
events.onerror = async () => {
|
||||
if (events.readyState !== EventSource.CLOSED || this.closed) return;
|
||||
this.events = null;
|
||||
// A closed stream is either a lost network or an ended session.
|
||||
try {
|
||||
const probe = await fetch('/api/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Vcam-Key': this.key,
|
||||
},
|
||||
body: '[]',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (probe.status === 403) return this.handleClose(4003);
|
||||
} catch {
|
||||
// Offline: retry below.
|
||||
}
|
||||
this.handleClose(1006);
|
||||
};
|
||||
this.flush();
|
||||
}
|
||||
|
||||
handleClose(code) {
|
||||
if (this.closed) return;
|
||||
if (code === 4003 || code === 4000) {
|
||||
this.onUnpaired(code === 4000 ? 'ended' : 'unpaired');
|
||||
return;
|
||||
}
|
||||
if (code === 4001) {
|
||||
this.onStatus('replaced');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.onStatus('connecting');
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = setTimeout(() => this.start(), 1000);
|
||||
}
|
||||
|
||||
receive(text) {
|
||||
try {
|
||||
this.onMessage(JSON.parse(text));
|
||||
} catch {
|
||||
// Ignore malformed relay input.
|
||||
}
|
||||
}
|
||||
|
||||
/** Poses are latest-wins; every other message is delivered in order. */
|
||||
send(message) {
|
||||
if (this.closed) return;
|
||||
// 1 is WebSocket.OPEN.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
if (message.type === 'pose') this.pendingPose = message;
|
||||
else this.queue.push(message);
|
||||
if (this.mode === 'http') this.flush();
|
||||
}
|
||||
|
||||
async flush() {
|
||||
if (this.inFlight || this.mode !== 'http' || this.closed) return;
|
||||
const batch = this.queue.splice(0, 32);
|
||||
if (this.pendingPose) batch.push(this.pendingPose);
|
||||
this.pendingPose = null;
|
||||
if (!batch.length) return;
|
||||
this.inFlight = true;
|
||||
try {
|
||||
const response = await fetch('/api/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Vcam-Key': this.key,
|
||||
},
|
||||
body: JSON.stringify(batch),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.status === 403) this.handleClose(4003);
|
||||
} catch {
|
||||
// The event stream's error handling owns reconnection.
|
||||
} finally {
|
||||
this.inFlight = false;
|
||||
}
|
||||
if (this.queue.length || this.pendingPose) this.flush();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
clearTimeout(this.retryTimer);
|
||||
this.ws?.close();
|
||||
this.events?.close();
|
||||
this.ws = null;
|
||||
this.events = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- state
|
||||
const app = {
|
||||
key: store.get(KEY_STORAGE),
|
||||
link: null,
|
||||
tracking: null, // { mode: '6dof'|'3dof', stop() }
|
||||
orientation: null,
|
||||
sticks: { left: [0, 0], right: [0, 0] },
|
||||
host: {
|
||||
connected: false,
|
||||
recording: false,
|
||||
elapsedSec: 0,
|
||||
scale: 1,
|
||||
focalMm: 31.18,
|
||||
fStop: 2.8,
|
||||
dof: false,
|
||||
held: false,
|
||||
smoothing: 0.3,
|
||||
heightLock: false,
|
||||
horizonLock: false,
|
||||
},
|
||||
recordingSince: 0,
|
||||
monitorRunning: false,
|
||||
wakeLock: null,
|
||||
};
|
||||
|
||||
function show(screenId) {
|
||||
for (const id of ['pair', 'start', 'camera']) $(id).hidden = id !== screenId;
|
||||
}
|
||||
|
||||
function setStatus(element, text, error = false) {
|
||||
element.textContent = text || '';
|
||||
element.classList.toggle('error', !!error);
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(text) {
|
||||
const element = $('camera-status');
|
||||
element.textContent = text;
|
||||
element.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (element.hidden = true), 2600);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- pairing
|
||||
async function join(code) {
|
||||
setStatus($('pair-status'), 'Pairing…');
|
||||
try {
|
||||
const response = await fetch('/api/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.status === 403) {
|
||||
setStatus(
|
||||
$('pair-status'),
|
||||
'That code is not active. Check the code on the desktop.',
|
||||
true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const { phoneKey } = await response.json();
|
||||
app.key = phoneKey;
|
||||
store.set(KEY_STORAGE, phoneKey);
|
||||
store.set(CODE_STORAGE, code);
|
||||
setStatus($('pair-status'), '');
|
||||
connect();
|
||||
showStart();
|
||||
return true;
|
||||
} catch {
|
||||
setStatus(
|
||||
$('pair-status'),
|
||||
'Could not reach the desktop. Is it on the same network?',
|
||||
true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
app.link?.close();
|
||||
app.link = new Link(app.key, {
|
||||
onMessage: handleMessage,
|
||||
onStatus: presentLink,
|
||||
onUnpaired: async (reason) => {
|
||||
app.link?.close();
|
||||
app.link = null;
|
||||
store.set(KEY_STORAGE, null);
|
||||
stopTracking();
|
||||
const code = store.get(CODE_STORAGE);
|
||||
if (reason === 'unpaired' && code && (await join(code))) return;
|
||||
show('pair');
|
||||
setStatus(
|
||||
$('pair-status'),
|
||||
reason === 'ended'
|
||||
? 'The desktop ended this session. Enter a new code.'
|
||||
: 'Pairing expired. Enter the code shown on the desktop.',
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
app.link.start();
|
||||
}
|
||||
|
||||
function presentLink(status) {
|
||||
const dot = $('link-dot');
|
||||
dot.classList.toggle('live', status === 'live');
|
||||
dot.classList.toggle('slow', status === 'slow');
|
||||
dot.title =
|
||||
status === 'live'
|
||||
? 'Live connection'
|
||||
: status === 'slow'
|
||||
? 'Fallback connection (install the certificate for lower latency)'
|
||||
: 'Connecting…';
|
||||
if (status === 'replaced') toast('Another phone took over this session');
|
||||
}
|
||||
|
||||
async function showStart() {
|
||||
show('start');
|
||||
const xr = await xrSupported();
|
||||
$('start-xr').hidden = !xr;
|
||||
$('support').textContent = xr
|
||||
? 'This phone supports AR tracking. Point it like a camera.'
|
||||
: 'AR tracking is not available in this browser, so the gyro camera tracks rotation and the sticks move you.';
|
||||
}
|
||||
|
||||
async function xrSupported() {
|
||||
try {
|
||||
return !!(await navigator.xr?.isSessionSupported('immersive-ar'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tracking
|
||||
function sendPose(q, p) {
|
||||
const { left, right } = app.sticks;
|
||||
app.link?.send({
|
||||
type: 'pose',
|
||||
t: Math.round(performance.now()),
|
||||
mode: app.tracking?.mode,
|
||||
q: q.map((v) => round(v)),
|
||||
p: p ? p.map((v) => round(v, 4)) : null,
|
||||
j: [left[0], left[1], right[0], right[1]].map((v) => round(v, 3)),
|
||||
});
|
||||
}
|
||||
|
||||
async function startXr() {
|
||||
setStatus($('start-status'), 'Starting AR…');
|
||||
try {
|
||||
const root = $('camera');
|
||||
const session = await navigator.xr.requestSession('immersive-ar', {
|
||||
requiredFeatures: ['local'],
|
||||
optionalFeatures: ['dom-overlay'],
|
||||
domOverlay: { root },
|
||||
});
|
||||
// Screen taps on controls must not become XR select input.
|
||||
root.addEventListener('beforexrselect', (event) => event.preventDefault());
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl', { xrCompatible: true, alpha: true });
|
||||
await gl.makeXRCompatible?.();
|
||||
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
|
||||
const space = await session.requestReferenceSpace('local');
|
||||
let stopped = false;
|
||||
const onFrame = (_time, frame) => {
|
||||
if (stopped) return;
|
||||
session.requestAnimationFrame(onFrame);
|
||||
const pose = frame.getViewerPose(space);
|
||||
if (pose) {
|
||||
const { position, orientation } = pose.transform;
|
||||
sendPose(
|
||||
[orientation.x, orientation.y, orientation.z, orientation.w],
|
||||
pose.emulatedPosition ? null : [position.x, position.y, position.z],
|
||||
);
|
||||
}
|
||||
const layer = session.renderState.baseLayer;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
};
|
||||
session.requestAnimationFrame(onFrame);
|
||||
session.addEventListener('end', () => {
|
||||
stopped = true;
|
||||
if (app.tracking?.session === session) {
|
||||
app.tracking = null;
|
||||
showStart();
|
||||
}
|
||||
});
|
||||
app.tracking = {
|
||||
mode: '6dof',
|
||||
session,
|
||||
stop: () => session.end().catch(() => {}),
|
||||
};
|
||||
enterCamera();
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
$('start-status'),
|
||||
`AR could not start (${error?.message || error}). Try the gyro camera.`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function startGyro() {
|
||||
// iOS asks for motion access only from a tap, before anything else awaits.
|
||||
try {
|
||||
if (typeof DeviceOrientationEvent?.requestPermission === 'function') {
|
||||
const answer = await DeviceOrientationEvent.requestPermission();
|
||||
if (answer !== 'granted') {
|
||||
setStatus(
|
||||
$('start-status'),
|
||||
'Motion access was denied. Allow it in Safari settings for this site, then reload.',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setStatus($('start-status'), 'Motion access is unavailable.', true);
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
const onOrientation = (event) => {
|
||||
if (event.alpha === null && event.beta === null) return;
|
||||
app.orientation = orientationQuaternion(
|
||||
event.alpha,
|
||||
event.beta,
|
||||
event.gamma,
|
||||
screenAngle(),
|
||||
);
|
||||
};
|
||||
window.addEventListener('deviceorientation', onOrientation);
|
||||
const tick = () => {
|
||||
if (stopped) return;
|
||||
requestAnimationFrame(tick);
|
||||
if (app.orientation) sendPose(app.orientation, null);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
app.tracking = {
|
||||
mode: '3dof',
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
window.removeEventListener('deviceorientation', onOrientation);
|
||||
},
|
||||
};
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
enterCamera();
|
||||
setTimeout(() => {
|
||||
if (!app.orientation && !stopped)
|
||||
toast('No motion data yet — this browser may block the gyroscope');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopTracking() {
|
||||
app.tracking?.stop();
|
||||
app.tracking = null;
|
||||
app.monitorRunning = false;
|
||||
}
|
||||
|
||||
async function enterCamera() {
|
||||
show('camera');
|
||||
$('mode-readout').textContent =
|
||||
app.tracking?.mode === '6dof' ? 'AR 6DOF' : 'GYRO 3DOF';
|
||||
app.link?.send({
|
||||
type: 'hello',
|
||||
mode: app.tracking?.mode,
|
||||
screen: [window.innerWidth, window.innerHeight],
|
||||
});
|
||||
try {
|
||||
app.wakeLock = await navigator.wakeLock?.request('screen');
|
||||
} catch {
|
||||
// Optional: the screen may dim during long takes.
|
||||
}
|
||||
runMonitor();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- monitor
|
||||
async function runMonitor() {
|
||||
if (app.monitorRunning) return;
|
||||
app.monitorRunning = true;
|
||||
const monitor = $('monitor');
|
||||
let seq = 0;
|
||||
let previousUrl = null;
|
||||
while (app.monitorRunning && app.key) {
|
||||
if (document.hidden) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/frame?key=${encodeURIComponent(app.key)}&after=${seq}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (response.status === 403) break;
|
||||
if (response.status !== 200) continue;
|
||||
seq = Number(response.headers.get('X-Frame-Seq')) || seq + 1;
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
await new Promise((resolve) => {
|
||||
monitor.onload = resolve;
|
||||
monitor.onerror = resolve;
|
||||
monitor.src = url;
|
||||
});
|
||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
||||
previousUrl = url;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
app.monitorRunning = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ controls
|
||||
function stick(element, target) {
|
||||
const knob = element.querySelector('.knob');
|
||||
let pointer = null;
|
||||
const set = (x, y) => {
|
||||
target[0] = x;
|
||||
target[1] = y;
|
||||
const radius = element.clientWidth / 2 - 26;
|
||||
knob.style.transform = `translate(${x * radius}px, ${-y * radius}px)`;
|
||||
};
|
||||
const move = (event) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const radius = rect.width / 2;
|
||||
let x = (event.clientX - rect.left - radius) / radius;
|
||||
let y = -(event.clientY - rect.top - radius) / radius;
|
||||
const length = Math.hypot(x, y);
|
||||
if (length > 1) {
|
||||
x /= length;
|
||||
y /= length;
|
||||
}
|
||||
// Rescale past the dead zone so small nudges stay precise.
|
||||
const live = Math.min(1, Math.hypot(x, y));
|
||||
const scale =
|
||||
live < STICK_DEADZONE
|
||||
? 0
|
||||
: (live - STICK_DEADZONE) / (1 - STICK_DEADZONE) / live;
|
||||
set(x * scale, y * scale);
|
||||
};
|
||||
element.addEventListener('pointerdown', (event) => {
|
||||
if (pointer !== null) return;
|
||||
pointer = event.pointerId;
|
||||
element.setPointerCapture(pointer);
|
||||
move(event);
|
||||
});
|
||||
element.addEventListener('pointermove', (event) => {
|
||||
if (event.pointerId === pointer) move(event);
|
||||
});
|
||||
const release = (event) => {
|
||||
if (event.pointerId !== pointer) return;
|
||||
pointer = null;
|
||||
set(0, 0);
|
||||
};
|
||||
element.addEventListener('pointerup', release);
|
||||
element.addEventListener('pointercancel', release);
|
||||
}
|
||||
|
||||
const sendSet = (key, value) => app.link?.send({ type: 'set', key, value });
|
||||
const sendCommand = (command) => app.link?.send({ type: 'cmd', cmd: command });
|
||||
|
||||
function presentHost() {
|
||||
const h = app.host;
|
||||
$('record-btn').setAttribute('aria-pressed', String(h.recording));
|
||||
$('rec-readout').hidden = !h.recording;
|
||||
$('hold-btn').setAttribute('aria-pressed', String(h.held));
|
||||
$('scale-readout').textContent = formatScale(h.scale);
|
||||
$('scale-value').textContent = formatScale(h.scale);
|
||||
$('focal-readout').textContent = `${Math.round(h.focalMm)}mm`;
|
||||
$('focal-value').textContent = `${Math.round(h.focalMm)}mm`;
|
||||
$('fstop-value').textContent = `f/${h.fStop.toFixed(1)}`;
|
||||
$('smooth-value').textContent = `${Math.round(h.smoothing * 100)}%`;
|
||||
const active = (element) =>
|
||||
element === document.activeElement && element.matches(':active');
|
||||
if (!active($('scale-range')))
|
||||
$('scale-range').value = String(Math.log10(h.scale));
|
||||
if (!active($('focal-range')))
|
||||
$('focal-range').value = String(logUnmap(FOCAL_RANGE, h.focalMm));
|
||||
if (!active($('fstop-range')))
|
||||
$('fstop-range').value = String(logUnmap(FSTOP_RANGE, h.fStop));
|
||||
if (!active($('smooth-range')))
|
||||
$('smooth-range').value = String(Math.round(h.smoothing * 100));
|
||||
$('dof-toggle').checked = h.dof;
|
||||
$('height-lock').checked = h.heightLock;
|
||||
$('horizon-lock').checked = h.horizonLock;
|
||||
for (const button of $('scale-presets').querySelectorAll('button'))
|
||||
button.classList.toggle(
|
||||
'active',
|
||||
Math.abs(Number(button.dataset.scale) - h.scale) < 1e-6,
|
||||
);
|
||||
for (const button of $('focal-presets').querySelectorAll('button'))
|
||||
button.classList.toggle(
|
||||
'active',
|
||||
Math.abs(Number(button.dataset.focal) - h.focalMm) < 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
function handleMessage(message) {
|
||||
if (message.type === 'state') {
|
||||
const wasRecording = app.host.recording;
|
||||
Object.assign(app.host, message.state);
|
||||
if (app.host.recording && !wasRecording)
|
||||
app.recordingSince = performance.now() - app.host.elapsedSec * 1000;
|
||||
presentHost();
|
||||
} else if (message.type === 'peer' && message.role === 'host') {
|
||||
app.host.connected = message.connected;
|
||||
if (!message.connected) toast('Desktop disconnected — waiting…');
|
||||
} else if (message.type === 'toast' && typeof message.text === 'string') {
|
||||
toast(message.text.slice(0, 140));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer(name) {
|
||||
for (const other of ['scale', 'lens']) {
|
||||
const open = other === name && $(`${other}-drawer`).hidden;
|
||||
$(`${other}-drawer`).hidden = !open;
|
||||
$(`${other}-btn`).setAttribute('aria-expanded', String(open));
|
||||
}
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
$('pair-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const code = $('code').value.trim().toUpperCase();
|
||||
if (code) join(code);
|
||||
});
|
||||
$('start-xr').addEventListener('click', startXr);
|
||||
$('start-gyro').addEventListener('click', startGyro);
|
||||
stick($('stick-left'), app.sticks.left);
|
||||
stick($('stick-right'), app.sticks.right);
|
||||
$('record-btn').addEventListener('click', () =>
|
||||
sendCommand(app.host.recording ? 'stop' : 'record'),
|
||||
);
|
||||
$('hold-btn').addEventListener('click', () =>
|
||||
sendCommand(app.host.held ? 'release' : 'hold'),
|
||||
);
|
||||
$('recenter-btn').addEventListener('click', () => sendCommand('recenter'));
|
||||
$('scale-btn').addEventListener('click', () => toggleDrawer('scale'));
|
||||
$('lens-btn').addEventListener('click', () => toggleDrawer('lens'));
|
||||
$('scale-presets').addEventListener('click', (event) => {
|
||||
const value = Number(event.target.closest('button')?.dataset.scale);
|
||||
if (SCALE_PRESETS.includes(value)) sendSet('scale', value);
|
||||
});
|
||||
$('scale-range').addEventListener('input', (event) => {
|
||||
const scale = 10 ** Number(event.target.value);
|
||||
$('scale-value').textContent = formatScale(scale);
|
||||
sendSet('scale', round(scale, 3));
|
||||
});
|
||||
$('smooth-range').addEventListener('input', (event) =>
|
||||
sendSet('smoothing', Number(event.target.value) / 100),
|
||||
);
|
||||
$('height-lock').addEventListener('change', (event) =>
|
||||
sendSet('heightLock', event.target.checked),
|
||||
);
|
||||
$('horizon-lock').addEventListener('change', (event) =>
|
||||
sendSet('horizonLock', event.target.checked),
|
||||
);
|
||||
$('focal-range').addEventListener('input', (event) => {
|
||||
const focal = logMap(FOCAL_RANGE, Number(event.target.value));
|
||||
$('focal-value').textContent = `${Math.round(focal)}mm`;
|
||||
sendSet('focalMm', round(focal, 2));
|
||||
});
|
||||
$('focal-presets').addEventListener('click', (event) => {
|
||||
const value = Number(event.target.closest('button')?.dataset.focal);
|
||||
if (value) sendSet('focalMm', value);
|
||||
});
|
||||
$('dof-toggle').addEventListener('change', (event) =>
|
||||
sendSet('dof', event.target.checked),
|
||||
);
|
||||
$('fstop-range').addEventListener('input', (event) => {
|
||||
const fStop = logMap(FSTOP_RANGE, Number(event.target.value));
|
||||
$('fstop-value').textContent = `f/${fStop.toFixed(1)}`;
|
||||
sendSet('fStop', round(fStop, 2));
|
||||
});
|
||||
// Tap the monitor to focus: coordinates are within the shown picture.
|
||||
$('monitor').addEventListener('click', (event) => {
|
||||
const monitor = $('monitor');
|
||||
if (!monitor.naturalWidth) return;
|
||||
const rect = monitor.getBoundingClientRect();
|
||||
const fit = Math.min(
|
||||
rect.width / monitor.naturalWidth,
|
||||
rect.height / monitor.naturalHeight,
|
||||
);
|
||||
const width = monitor.naturalWidth * fit;
|
||||
const height = monitor.naturalHeight * fit;
|
||||
const x = (event.clientX - rect.left - (rect.width - width) / 2) / width;
|
||||
const y = (event.clientY - rect.top - (rect.height - height) / 2) / height;
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return;
|
||||
app.link?.send({ type: 'focus', x: round(x, 4), y: round(y, 4) });
|
||||
const ring = $('focus-ring');
|
||||
ring.hidden = true;
|
||||
ring.style.left = `${event.clientX}px`;
|
||||
ring.style.top = `${event.clientY}px`;
|
||||
void ring.offsetWidth;
|
||||
ring.hidden = false;
|
||||
});
|
||||
setInterval(() => {
|
||||
if (app.host.recording)
|
||||
$('rec-readout').textContent =
|
||||
`● ${formatTime((performance.now() - app.recordingSince) / 1000)}`;
|
||||
}, 250);
|
||||
// The system releases a wake lock whenever the page is hidden.
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.hidden || !app.tracking) return;
|
||||
if (app.wakeLock && !app.wakeLock.released) return;
|
||||
try {
|
||||
app.wakeLock = await navigator.wakeLock?.request('screen');
|
||||
} catch {
|
||||
// Optional.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bindControls();
|
||||
presentHost();
|
||||
const fromLink = decodeURIComponent(location.hash.slice(1))
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, '');
|
||||
if (fromLink) {
|
||||
$('code').value = fromLink;
|
||||
store.set(CODE_STORAGE, fromLink);
|
||||
// Keep the code out of the address bar once read.
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
if (app.key) {
|
||||
connect();
|
||||
showStart();
|
||||
} else if (fromLink) {
|
||||
join(fromLink);
|
||||
} else {
|
||||
show('pair');
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -1,522 +0,0 @@
|
||||
/**
|
||||
* Virtual camera: lets a phone on the same network act as the app's camera.
|
||||
*
|
||||
* Two surfaces, deliberately separate:
|
||||
*
|
||||
* - On the app's own server (loopback only, behind the same gate as /mcp):
|
||||
* `POST /api/vcam/session` creates a pairing session, `DELETE` ends it,
|
||||
* `GET /api/vcam/ca.crt` downloads the local CA, and the desktop page joins
|
||||
* its session over the `/api/vcam/host` WebSocket.
|
||||
* - A separate HTTPS listener on the LAN (VCAM_PORT, default 4174) that starts
|
||||
* only when the desktop creates a session and stops after the last one
|
||||
* ends. It serves the static phone page and the phone side of the relay
|
||||
* and nothing else, so pairing a phone never exposes the app's provider
|
||||
* proxies or API keys to the network.
|
||||
*
|
||||
* The phone transport prefers a WebSocket and falls back to plain HTTPS
|
||||
* requests (POST for poses, server-sent events for state), which work under
|
||||
* a tapped-through certificate warning where iOS refuses WebSockets.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { isAllowedHost } from '../../build/allowedHosts.js';
|
||||
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
|
||||
import { isLocalMcpRequest } from '../mcp/plugin.js';
|
||||
import { MAX_FRAME_BYTES, MAX_TEXT_BYTES, createVcamRelay } from './relay.js';
|
||||
import { loadLocalCertificates } from './tls.js';
|
||||
|
||||
const PHONE_DIR = fileURLToPath(new URL('./phone/', import.meta.url));
|
||||
const PHONE_FILES = Object.freeze({
|
||||
'/': ['index.html', 'text/html; charset=utf-8'],
|
||||
'/index.html': ['index.html', 'text/html; charset=utf-8'],
|
||||
'/phone.js': ['phone.js', 'text/javascript; charset=utf-8'],
|
||||
'/orientation.js': ['orientation.js', 'text/javascript; charset=utf-8'],
|
||||
'/phone.css': ['phone.css', 'text/css; charset=utf-8'],
|
||||
'/icon.svg': ['icon.svg', 'image/svg+xml'],
|
||||
});
|
||||
const DEFAULT_PORT = 4174;
|
||||
const MAX_BATCH_BYTES = 64 * 1024;
|
||||
const BODY_TIMEOUT_MS = 10000;
|
||||
const SSE_KEEPALIVE_MS = 15000;
|
||||
// The listener lingers after the last session in case the desktop reloads.
|
||||
const IDLE_SHUTDOWN_MS = 120000;
|
||||
const CA_CONTENT_TYPE = 'application/x-x509-ca-cert';
|
||||
|
||||
/** Headers for every phone-listener response; the page is static. */
|
||||
export const PHONE_HEADERS = Object.freeze({
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' blob: data:",
|
||||
"connect-src 'self' wss:",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; '),
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Cache-Control': 'no-store',
|
||||
'Permissions-Policy': 'xr-spatial-tracking=(self), camera=(self)',
|
||||
});
|
||||
|
||||
/**
|
||||
* IPv4 addresses a phone on the same network could reach, private ranges
|
||||
* first (home and office Wi-Fi), then others. Loopback and link-local are
|
||||
* left out.
|
||||
*/
|
||||
export function lanAddresses(interfaces = os.networkInterfaces()) {
|
||||
const rank = (address) =>
|
||||
address.startsWith('192.168.')
|
||||
? 0
|
||||
: address.startsWith('10.')
|
||||
? 1
|
||||
: /^172\.(1[6-9]|2\d|3[01])\./.test(address)
|
||||
? 2
|
||||
: 3;
|
||||
const found = [];
|
||||
for (const entries of Object.values(interfaces || {}))
|
||||
for (const entry of entries || []) {
|
||||
const family = entry.family === 4 ? 'IPv4' : entry.family;
|
||||
if (family !== 'IPv4' || entry.internal) continue;
|
||||
if (entry.address.startsWith('169.254.')) continue;
|
||||
if (!found.includes(entry.address)) found.push(entry.address);
|
||||
}
|
||||
return found.sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
const json = (res, status, body) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
function readBody(req, limit) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const timer = setTimeout(() => {
|
||||
reject(Object.assign(new Error('timeout'), { status: 408 }));
|
||||
req.destroy();
|
||||
}, BODY_TIMEOUT_MS);
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(new Error('too large'), { status: 413 }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a desktop request may control pairing (loopback, same-origin). */
|
||||
function isDesktopRequest(req, env, { requireOrigin = true } = {}) {
|
||||
if (requireOrigin && !req.headers.origin) return false;
|
||||
return isLocalMcpRequest({
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
localPort: req.socket?.localPort,
|
||||
host: req.headers.host || '',
|
||||
origin: req.headers.origin,
|
||||
headers: req.headers,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the virtual-camera service: the relay, the lazily started phone
|
||||
* listener and the handlers both servers mount.
|
||||
*/
|
||||
export function createVcamService({
|
||||
env = process.env,
|
||||
cacheDir = path.join(process.cwd(), '.gev-cache', 'vcam'),
|
||||
port = Number.parseInt(env.VCAM_PORT, 10) || DEFAULT_PORT,
|
||||
host = env.VCAM_HOST || '0.0.0.0',
|
||||
addresses = () => lanAddresses(),
|
||||
allowedHosts = [],
|
||||
logger = console,
|
||||
} = {}) {
|
||||
let listener = null;
|
||||
let listenerNames = '';
|
||||
let starting = null;
|
||||
let idleTimer = null;
|
||||
let certificates = null;
|
||||
const relay = createVcamRelay({
|
||||
onSessionClosed: () => {
|
||||
if (relay.size === 0) scheduleIdleShutdown();
|
||||
},
|
||||
});
|
||||
const sockets = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_FRAME_BYTES,
|
||||
});
|
||||
|
||||
function scheduleIdleShutdown() {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (relay.size === 0) stopListener();
|
||||
}, IDLE_SHUTDOWN_MS);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
function stopListener() {
|
||||
const current = listener;
|
||||
listener = null;
|
||||
listenerNames = '';
|
||||
current?.closeAllConnections?.();
|
||||
current?.close();
|
||||
}
|
||||
|
||||
/** Start (or restart for a changed address) the HTTPS phone listener. */
|
||||
async function ensureListener(requested) {
|
||||
clearTimeout(idleTimer);
|
||||
const names = [...new Set(requested)];
|
||||
const key = names.join(',');
|
||||
if (listener && listenerNames === key) return;
|
||||
if (starting) await starting.catch(() => {});
|
||||
if (listener && listenerNames === key) return;
|
||||
stopListener();
|
||||
certificates = loadLocalCertificates(cacheDir, names);
|
||||
const server = https.createServer(
|
||||
{ cert: certificates.cert, key: certificates.key },
|
||||
(req, res) => handlePhoneRequest(req, res),
|
||||
);
|
||||
server.on('upgrade', (req, socket, head) =>
|
||||
handlePhoneUpgrade(req, socket, head),
|
||||
);
|
||||
server.on('clientError', (_error, socket) => socket.destroy());
|
||||
starting = new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
await starting;
|
||||
} finally {
|
||||
starting = null;
|
||||
}
|
||||
listener = server;
|
||||
listenerNames = key;
|
||||
logger.info?.(
|
||||
`[vcam] phone listener on https://${names[0]}:${port}/ (pairing session only)`,
|
||||
);
|
||||
}
|
||||
|
||||
const phoneHostAllowed = (req) =>
|
||||
isAllowedHost(req.headers.host, allowedHosts);
|
||||
|
||||
function servePhoneFile(req, res, pathname) {
|
||||
const entry = PHONE_FILES[pathname];
|
||||
if (!entry) return false;
|
||||
const [file, type] = entry;
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(path.join(PHONE_DIR, file));
|
||||
} catch {
|
||||
json(res, 500, { error: 'phone page unavailable' });
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', type);
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendCa(res) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', CA_CONTENT_TYPE);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="gods-eye-view-vcam-ca.crt"',
|
||||
);
|
||||
res.end(certificates?.ca || '');
|
||||
}
|
||||
|
||||
function phoneSession(req, url) {
|
||||
const key = req.headers['x-vcam-key'] || url.searchParams.get('key');
|
||||
return relay.sessionForPhone(key);
|
||||
}
|
||||
|
||||
async function handlePhoneRequest(req, res) {
|
||||
for (const [name, value] of Object.entries(PHONE_HEADERS))
|
||||
res.setHeader(name, value);
|
||||
if (!phoneHostAllowed(req)) return json(res, 421, { error: 'bad host' });
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'https://phone.invalid');
|
||||
} catch {
|
||||
return json(res, 400, { error: 'bad request' });
|
||||
}
|
||||
const { pathname } = url;
|
||||
try {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||
if (servePhoneFile(req, res, pathname)) return;
|
||||
if (pathname === '/ca.crt') return sendCa(res);
|
||||
}
|
||||
if (pathname === '/api/join' && req.method === 'POST') {
|
||||
let code = '';
|
||||
try {
|
||||
code = JSON.parse(await readBody(req, 1024))?.code;
|
||||
} catch (error) {
|
||||
if (error?.status) throw error;
|
||||
}
|
||||
const joined = relay.joinPhone(code);
|
||||
if (!joined.ok) return json(res, 403, { error: joined.reason });
|
||||
return json(res, 200, {
|
||||
phoneKey: joined.phoneKey,
|
||||
sessionId: joined.sessionId,
|
||||
limits: { text: MAX_TEXT_BYTES },
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/send' && req.method === 'POST') {
|
||||
const session = phoneSession(req, url);
|
||||
if (!session) return json(res, 403, { error: 'unpaired' });
|
||||
let messages;
|
||||
try {
|
||||
messages = JSON.parse(await readBody(req, MAX_BATCH_BYTES));
|
||||
} catch (error) {
|
||||
if (error?.status) throw error;
|
||||
return json(res, 400, { error: 'expected a JSON array' });
|
||||
}
|
||||
if (!Array.isArray(messages) || messages.length > 64)
|
||||
return json(res, 400, { error: 'expected a JSON array' });
|
||||
let accepted = 0;
|
||||
for (const message of messages)
|
||||
if (relay.relay(session, 'phone', JSON.stringify(message)))
|
||||
accepted += 1;
|
||||
return json(res, 200, { accepted });
|
||||
}
|
||||
if (pathname === '/api/events' && req.method === 'GET')
|
||||
return openEventStream(req, res, phoneSession(req, url));
|
||||
if (pathname === '/api/frame' && req.method === 'GET') {
|
||||
const session = phoneSession(req, url);
|
||||
if (!session) return json(res, 403, { error: 'unpaired' });
|
||||
const after = Number.parseInt(url.searchParams.get('after'), 10) || 0;
|
||||
const frame = await relay.nextFrame(session, after, 2000);
|
||||
if (!frame) {
|
||||
res.statusCode = 204;
|
||||
return res.end();
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('X-Frame-Seq', String(frame.seq));
|
||||
return res.end(frame.bytes);
|
||||
}
|
||||
json(res, 404, { error: 'not found' });
|
||||
} catch (error) {
|
||||
if (!res.headersSent)
|
||||
json(res, error?.status || 500, { error: 'request failed' });
|
||||
else res.end();
|
||||
}
|
||||
}
|
||||
|
||||
function openEventStream(req, res, session) {
|
||||
if (!session) return json(res, 403, { error: 'unpaired' });
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
const keepAlive = setInterval(
|
||||
() => res.write(': keep-alive\n\n'),
|
||||
SSE_KEEPALIVE_MS,
|
||||
);
|
||||
const peer = {
|
||||
send: (text) => res.write(`data: ${text}\n\n`),
|
||||
close: () => res.end(),
|
||||
};
|
||||
const attached = relay.attachPhone(
|
||||
req.headers['x-vcam-key'] ||
|
||||
new URL(req.url, 'https://phone.invalid').searchParams.get('key'),
|
||||
peer,
|
||||
);
|
||||
if (!attached) {
|
||||
clearInterval(keepAlive);
|
||||
return res.end();
|
||||
}
|
||||
req.on('close', () => {
|
||||
clearInterval(keepAlive);
|
||||
relay.detach(attached, 'phone', peer);
|
||||
});
|
||||
}
|
||||
|
||||
function refuseUpgrade(socket, status) {
|
||||
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
|
||||
}
|
||||
|
||||
function handlePhoneUpgrade(req, socket, head) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'https://phone.invalid');
|
||||
} catch {
|
||||
return refuseUpgrade(socket, '400 Bad Request');
|
||||
}
|
||||
if (url.pathname !== '/ws' || !phoneHostAllowed(req))
|
||||
return refuseUpgrade(socket, '404 Not Found');
|
||||
// A WebSocket is not subject to CORS: only this page's own origin may open one.
|
||||
if (req.headers.origin !== `https://${req.headers.host}`)
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
const key = url.searchParams.get('key');
|
||||
if (!relay.sessionForPhone(key))
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
sockets.handleUpgrade(req, socket, head, (ws) => {
|
||||
const peer = {
|
||||
send: (text) => ws.readyState === ws.OPEN && ws.send(text),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
};
|
||||
const session = relay.attachPhone(key, peer);
|
||||
if (!session) return ws.close(4003, 'unpaired');
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
relay.relay(session, 'phone', data.toString('utf8'));
|
||||
});
|
||||
ws.on('close', () => relay.detach(session, 'phone', peer));
|
||||
ws.on('error', () => ws.terminate());
|
||||
});
|
||||
}
|
||||
|
||||
/** Mount the desktop routes on the app's own (dev or preview) server. */
|
||||
function install(server) {
|
||||
server.middlewares.use('/api/vcam', async (req, res, next) => {
|
||||
const pathname = (req.url || '/').split('?')[0];
|
||||
try {
|
||||
if (pathname === '/ca.crt' && req.method === 'GET') {
|
||||
if (!isDesktopRequest(req, env, { requireOrigin: false }))
|
||||
return json(res, 403, { error: 'local only' });
|
||||
if (!certificates)
|
||||
certificates = loadLocalCertificates(cacheDir, [
|
||||
...addresses(),
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
]);
|
||||
return sendCa(res);
|
||||
}
|
||||
if (pathname !== '/session') return next();
|
||||
if (!isDesktopRequest(req, env))
|
||||
return json(res, 403, { error: 'local only' });
|
||||
if (isSharingEnabled(env))
|
||||
return json(res, 403, { error: 'sharing is enabled' });
|
||||
if (req.method === 'POST') {
|
||||
const lan = addresses();
|
||||
if (!lan.length)
|
||||
return json(res, 503, {
|
||||
error: 'no network address',
|
||||
code: 'no-network',
|
||||
});
|
||||
try {
|
||||
await ensureListener([...lan, 'localhost', '127.0.0.1']);
|
||||
} catch (error) {
|
||||
return json(res, 503, {
|
||||
error: 'phone listener unavailable',
|
||||
code: error?.code === 'EADDRINUSE' ? 'port-in-use' : 'listen',
|
||||
port,
|
||||
});
|
||||
}
|
||||
const session = relay.createSession();
|
||||
return json(res, 200, {
|
||||
sessionId: session.id,
|
||||
hostKey: session.hostKey,
|
||||
code: session.code,
|
||||
port: listener.address()?.port ?? port,
|
||||
addresses: lan,
|
||||
caFingerprint: certificates.caFingerprint,
|
||||
});
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
relay.endSession(req.headers['x-vcam-host-key']);
|
||||
return json(res, 200, { ended: true });
|
||||
}
|
||||
return json(res, 405, { error: 'method not allowed' });
|
||||
} catch {
|
||||
if (!res.headersSent) json(res, 500, { error: 'request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
server.httpServer?.on('upgrade', (req, socket, head) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'http://local.invalid');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== '/api/vcam/host') return;
|
||||
// Browsers always send Origin on a WebSocket; it must be this app.
|
||||
if (!isDesktopRequest(req, env))
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
const key = url.searchParams.get('key');
|
||||
if (!relay.sessionForHost(key))
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
sockets.handleUpgrade(req, socket, head, (ws) => {
|
||||
const peer = {
|
||||
send: (text) => ws.readyState === ws.OPEN && ws.send(text),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
};
|
||||
const session = relay.attachHost(key, peer);
|
||||
if (!session) return ws.close(4003, 'unknown session');
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (isBinary) relay.publishFrame(session, Buffer.from(data));
|
||||
else relay.relay(session, 'host', data.toString('utf8'));
|
||||
});
|
||||
ws.on('close', () => relay.detach(session, 'host', peer));
|
||||
ws.on('error', () => ws.terminate());
|
||||
});
|
||||
});
|
||||
server.httpServer?.once('close', () => destroy());
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(idleTimer);
|
||||
relay.closeAll();
|
||||
stopListener();
|
||||
sockets.close();
|
||||
}
|
||||
|
||||
return {
|
||||
install,
|
||||
destroy,
|
||||
relay,
|
||||
ensureListener,
|
||||
get listening() {
|
||||
return !!listener;
|
||||
},
|
||||
get port() {
|
||||
return listener?.address()?.port ?? port;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Vite plugin mounting the virtual-camera desktop routes and phone listener. */
|
||||
export function vcamPlugin(options = {}) {
|
||||
let service = null;
|
||||
const install = (server) => {
|
||||
service ??= createVcamService({
|
||||
allowedHosts: server.config?.server?.allowedHosts || [],
|
||||
logger: server.config?.logger || console,
|
||||
...options,
|
||||
});
|
||||
service.install(server);
|
||||
};
|
||||
return {
|
||||
name: 'gev-vcam',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import WebSocket from 'ws';
|
||||
import { createVcamService, lanAddresses, PHONE_HEADERS } from './plugin.js';
|
||||
|
||||
/** The slice of a Vite server the service mounts on. */
|
||||
function appServer() {
|
||||
const stack = [];
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
const layer = stack.find(({ prefix }) => req.url.startsWith(prefix));
|
||||
if (!layer) {
|
||||
res.statusCode = 404;
|
||||
return res.end();
|
||||
}
|
||||
req.url = req.url.slice(layer.prefix.length) || '/';
|
||||
layer.handle(req, res, () => {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
return {
|
||||
httpServer,
|
||||
middlewares: { use: (prefix, handle) => stack.push({ prefix, handle }) },
|
||||
};
|
||||
}
|
||||
|
||||
const listen = (server) =>
|
||||
new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
|
||||
function request(url, { method = 'GET', headers = {}, body, ca, checkServerIdentity } = {}) {
|
||||
const target = new URL(url);
|
||||
const client = target.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(
|
||||
target,
|
||||
{ method, headers, ca, ...(checkServerIdentity ? { checkServerIdentity } : {}) },
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
/** Buffer a socket's messages from creation so back-to-back sends are never missed. */
|
||||
const inboxes = new WeakMap();
|
||||
function connect(url, options) {
|
||||
const ws = new WebSocket(url, options);
|
||||
const box = { queued: [], waiting: [] };
|
||||
inboxes.set(ws, box);
|
||||
ws.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
const waiter = box.waiting.shift();
|
||||
if (waiter) waiter(message);
|
||||
else box.queued.push(message);
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
function nextMessage(ws) {
|
||||
const box = inboxes.get(ws);
|
||||
if (box.queued.length) return Promise.resolve(box.queued.shift());
|
||||
return new Promise((resolve) => box.waiting.push(resolve));
|
||||
}
|
||||
const opened = (ws) =>
|
||||
new Promise((resolve, reject) => {
|
||||
ws.once('open', resolve);
|
||||
ws.once('error', reject);
|
||||
ws.once('unexpected-response', (_req, res) => reject(new Error(`HTTP ${res.statusCode}`)));
|
||||
});
|
||||
|
||||
async function harness(t) {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-vcam-'));
|
||||
const service = createVcamService({
|
||||
env: {},
|
||||
cacheDir,
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
// The phone reaches the listener on loopback in this test.
|
||||
addresses: () => ['127.0.0.1'],
|
||||
logger: {},
|
||||
});
|
||||
const app = appServer();
|
||||
service.install(app);
|
||||
await listen(app.httpServer);
|
||||
const appOrigin = `http://127.0.0.1:${app.httpServer.address().port}`;
|
||||
t.after(() => {
|
||||
service.destroy();
|
||||
app.httpServer.close();
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
const session = async () => {
|
||||
const response = await request(`${appOrigin}/api/vcam/session`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: appOrigin },
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return JSON.parse(response.body);
|
||||
};
|
||||
const ca = () => fs.readFileSync(path.join(cacheDir, 'ca.crt'));
|
||||
return { service, appOrigin, session, ca };
|
||||
}
|
||||
|
||||
test('the desktop pairs a phone over a trusted local CA and both legs relay', async (t) => {
|
||||
const { appOrigin, session, ca } = await harness(t);
|
||||
const created = await session();
|
||||
assert.equal(created.code.length, 6);
|
||||
assert.deepEqual(created.addresses, ['127.0.0.1']);
|
||||
const phoneOrigin = `https://127.0.0.1:${created.port}`;
|
||||
|
||||
const page = await request(`${phoneOrigin}/`, { ca: ca() });
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.body.toString(), /VIRTUAL CAMERA/);
|
||||
for (const [name, value] of Object.entries(PHONE_HEADERS))
|
||||
assert.equal(page.headers[name.toLowerCase()], value);
|
||||
assert.equal((await request(`${phoneOrigin}/api/vcam/session`, { method: 'POST', ca: ca() })).status, 404,
|
||||
'the phone listener exposes none of the desktop routes');
|
||||
const certificate = await request(`${phoneOrigin}/ca.crt`, { ca: ca() });
|
||||
assert.equal(certificate.headers['content-type'], 'application/x-x509-ca-cert');
|
||||
assert.equal(certificate.body.toString(), ca().toString());
|
||||
|
||||
const host = connect(
|
||||
`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=${created.hostKey}`,
|
||||
{ origin: appOrigin },
|
||||
);
|
||||
const hostCode = nextMessage(host);
|
||||
await opened(host);
|
||||
assert.deepEqual(await hostCode, { type: 'code', code: created.code });
|
||||
await nextMessage(host); // phone not yet connected
|
||||
|
||||
const join = await request(`${phoneOrigin}/api/join`, {
|
||||
method: 'POST', ca: ca(), body: JSON.stringify({ code: created.code }),
|
||||
});
|
||||
assert.equal(join.status, 200);
|
||||
const { phoneKey } = JSON.parse(join.body);
|
||||
const phone = connect(`wss://127.0.0.1:${created.port}/ws?key=${phoneKey}`, {
|
||||
ca: ca(), origin: phoneOrigin,
|
||||
});
|
||||
const hostSeesPhone = nextMessage(host);
|
||||
await opened(phone);
|
||||
assert.deepEqual(await hostSeesPhone, { type: 'peer', role: 'phone', connected: true });
|
||||
|
||||
const pose = { type: 'pose', t: 1, q: [0, 0, 0, 1], p: [0, 1.5, 0] };
|
||||
const received = nextMessage(host);
|
||||
phone.send(JSON.stringify(pose));
|
||||
assert.deepEqual(await received, pose);
|
||||
await nextMessage(phone); // host presence
|
||||
const state = nextMessage(phone);
|
||||
host.send(JSON.stringify({ type: 'state', state: { recording: true } }));
|
||||
assert.deepEqual(await state, { type: 'state', state: { recording: true } });
|
||||
|
||||
// The host's binary messages are preview frames the phone pulls over HTTPS.
|
||||
host.send(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 7]));
|
||||
const frame = await request(`${phoneOrigin}/api/frame?after=0`, { ca: ca(), headers: { 'X-Vcam-Key': phoneKey } });
|
||||
assert.equal(frame.status, 200);
|
||||
assert.equal(frame.headers['content-type'], 'image/jpeg');
|
||||
assert.equal(frame.headers['x-frame-seq'], '1');
|
||||
assert.equal(frame.body.at(-1), 7);
|
||||
|
||||
phone.close();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test('the HTTPS fallback relays poses by POST and state by server-sent events', async (t) => {
|
||||
const { appOrigin, session, ca } = await harness(t);
|
||||
const created = await session();
|
||||
const phoneOrigin = `https://127.0.0.1:${created.port}`;
|
||||
const host = connect(
|
||||
`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=${created.hostKey}`,
|
||||
{ origin: appOrigin },
|
||||
);
|
||||
await opened(host);
|
||||
const { phoneKey } = JSON.parse(
|
||||
(await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: JSON.stringify({ code: created.code }) })).body,
|
||||
);
|
||||
const events = await new Promise((resolve) => {
|
||||
https.get(`${phoneOrigin}/api/events?key=${phoneKey}`, { ca: ca() }, resolve);
|
||||
});
|
||||
assert.equal(events.headers['content-type'], 'text/event-stream; charset=utf-8');
|
||||
const firstEvent = new Promise((resolve) => events.once('data', (chunk) => resolve(chunk.toString())));
|
||||
assert.match(await firstEvent, /^data: \{"type":"peer","role":"host","connected":true\}/);
|
||||
|
||||
const relayed = [];
|
||||
const collect = async () => {
|
||||
for (;;) relayed.push(await nextMessage(host));
|
||||
};
|
||||
collect();
|
||||
const sent = await request(`${phoneOrigin}/api/send`, {
|
||||
method: 'POST', ca: ca(), headers: { 'X-Vcam-Key': phoneKey },
|
||||
body: JSON.stringify([{ type: 'cmd', cmd: 'record' }, { type: 'pose', t: 2 }, 'not an object']),
|
||||
});
|
||||
assert.deepEqual(JSON.parse(sent.body), { accepted: 2 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.deepEqual(relayed.filter(({ type }) => !['peer', 'code'].includes(type)), [{ type: 'cmd', cmd: 'record' }, { type: 'pose', t: 2 }]);
|
||||
|
||||
const downstream = new Promise((resolve) => events.once('data', (chunk) => resolve(chunk.toString())));
|
||||
host.send(JSON.stringify({ type: 'toast', text: 'hi' }));
|
||||
assert.equal(await downstream, 'data: {"type":"toast","text":"hi"}\n\n');
|
||||
|
||||
const ended = new Promise((resolve) => events.once('end', resolve));
|
||||
await request(`${appOrigin}/api/vcam/session`, { method: 'DELETE', headers: { Origin: appOrigin, 'X-Vcam-Host-Key': created.hostKey } });
|
||||
await ended;
|
||||
const after = await request(`${phoneOrigin}/api/send`, {
|
||||
method: 'POST', ca: ca(), headers: { 'X-Vcam-Key': phoneKey }, body: '[]',
|
||||
});
|
||||
assert.equal(after.status, 403, 'an ended session revokes the phone');
|
||||
host.close();
|
||||
});
|
||||
|
||||
test('pairing refuses foreign origins, bad codes, bad keys and cross-site sockets', async (t) => {
|
||||
const { appOrigin, session, ca } = await harness(t);
|
||||
const noOrigin = await request(`${appOrigin}/api/vcam/session`, { method: 'POST' });
|
||||
assert.equal(noOrigin.status, 403, 'a session needs the page origin');
|
||||
const foreign = await request(`${appOrigin}/api/vcam/session`, { method: 'POST', headers: { Origin: 'https://evil.example' } });
|
||||
assert.equal(foreign.status, 403);
|
||||
const proxied = await request(`${appOrigin}/api/vcam/session`, { method: 'POST', headers: { Origin: appOrigin, 'X-Forwarded-For': '1.2.3.4' } });
|
||||
assert.equal(proxied.status, 403);
|
||||
|
||||
const created = await session();
|
||||
const phoneOrigin = `https://127.0.0.1:${created.port}`;
|
||||
const badJoin = await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: '{"code":"222222"}' });
|
||||
assert.equal(badJoin.status, 403);
|
||||
const hugeJoin = await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: 'x'.repeat(5000) }).catch(() => ({ status: 413 }));
|
||||
assert.equal(hugeJoin.status, 413);
|
||||
const badHost = await request(`${phoneOrigin}/`, {
|
||||
ca: ca(), headers: { Host: 'rebind.example' }, checkServerIdentity: () => undefined,
|
||||
});
|
||||
assert.equal(badHost.status, 421, 'the phone listener answers IP addresses and allowed hosts only');
|
||||
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=${created.hostKey}`, { origin: 'https://evil.example' })),
|
||||
/403/,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=wrong`, { origin: appOrigin })),
|
||||
/403/,
|
||||
);
|
||||
const { phoneKey } = JSON.parse(
|
||||
(await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: JSON.stringify({ code: created.code }) })).body,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`wss://127.0.0.1:${created.port}/ws?key=${phoneKey}`, { ca: ca(), origin: 'https://evil.example' })),
|
||||
/403/,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`wss://127.0.0.1:${created.port}/ws?key=wrong`, { ca: ca(), origin: phoneOrigin })),
|
||||
/403/,
|
||||
);
|
||||
});
|
||||
|
||||
test('LAN addresses prefer private ranges and skip loopback and link-local', () => {
|
||||
assert.deepEqual(
|
||||
lanAddresses({
|
||||
lo: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
|
||||
docker0: [{ family: 'IPv4', address: '172.17.0.1', internal: false }],
|
||||
en0: [
|
||||
{ family: 'IPv6', address: 'fe80::1', internal: false },
|
||||
{ family: 4, address: '192.168.1.23', internal: false },
|
||||
],
|
||||
tun0: [{ family: 'IPv4', address: '100.64.0.2', internal: false }],
|
||||
bad: [{ family: 'IPv4', address: '169.254.3.3', internal: false }],
|
||||
}),
|
||||
['192.168.1.23', '172.17.0.1', '100.64.0.2'],
|
||||
);
|
||||
});
|
||||
@@ -1,319 +0,0 @@
|
||||
/**
|
||||
* Pairing and message relay between the desktop app (host) and one phone.
|
||||
*
|
||||
* Transport-agnostic: a peer is `{ send(text), close(code, reason) }`, so the
|
||||
* WebSocket and HTTP-fallback transports share every rule here. The desktop
|
||||
* creates a session from a loopback request and receives a host key. The
|
||||
* phone proves possession of the short pairing code shown on the desktop
|
||||
* (carried in the QR code's URL fragment) and receives its own key. Too many
|
||||
* wrong codes rotate the code, so guessing cannot outlast the session.
|
||||
*
|
||||
* Messages are small JSON texts the relay forwards without interpreting,
|
||||
* bounded in size and rate. The newest preview frame (a JPEG the host sends)
|
||||
* is held for the phone to pull, so a slow phone drops frames instead of
|
||||
* queueing them.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// No 0/O, 1/I/L: the code is read off a screen.
|
||||
const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
||||
export const PAIRING_CODE_LENGTH = 6;
|
||||
export const MAX_TEXT_BYTES = 4096;
|
||||
export const MAX_FRAME_BYTES = 768 * 1024;
|
||||
const MAX_CODE_FAILURES = 8;
|
||||
const MESSAGE_BURST = 240;
|
||||
const MESSAGES_PER_SECOND = 120;
|
||||
// A host that disappears (a reload, a closed tab) may come back this soon.
|
||||
const HOST_GRACE_MS = 60000;
|
||||
const MAX_SESSIONS = 4;
|
||||
|
||||
const randomKey = () => crypto.randomBytes(24).toString('base64url');
|
||||
function pairingCode(randomBytes = crypto.randomBytes) {
|
||||
const bytes = randomBytes(PAIRING_CODE_LENGTH);
|
||||
let code = '';
|
||||
for (const byte of bytes) code += CODE_ALPHABET[byte % CODE_ALPHABET.length];
|
||||
return code;
|
||||
}
|
||||
const sameSecret = (a, b) => {
|
||||
const left = Buffer.from(String(a));
|
||||
const right = Buffer.from(String(b));
|
||||
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
||||
};
|
||||
const normalizeCode = (code) =>
|
||||
String(code ?? '')
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, '')
|
||||
.slice(0, 32);
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {() => number} [options.now]
|
||||
* @param {(session: object) => void} [options.onSessionClosed]
|
||||
*/
|
||||
export function createVcamRelay({
|
||||
now = () => Date.now(),
|
||||
onSessionClosed = () => {},
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
} = {}) {
|
||||
/** @type {Map<string, object>} */
|
||||
const sessions = new Map();
|
||||
|
||||
const bucket = () => ({ tokens: MESSAGE_BURST, at: now() });
|
||||
function admit(rate) {
|
||||
const at = now();
|
||||
rate.tokens = Math.min(
|
||||
MESSAGE_BURST,
|
||||
rate.tokens + ((at - rate.at) / 1000) * MESSAGES_PER_SECOND,
|
||||
);
|
||||
rate.at = at;
|
||||
if (rate.tokens < 1) return false;
|
||||
rate.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function notify(peer, message) {
|
||||
try {
|
||||
peer?.send(JSON.stringify(message));
|
||||
} catch {
|
||||
// A failed send belongs to that transport's own close handling.
|
||||
}
|
||||
}
|
||||
|
||||
function closeSession(session, reason = 'ended') {
|
||||
if (!sessions.has(session.id)) return;
|
||||
sessions.delete(session.id);
|
||||
clearTimer(session.hostTimer);
|
||||
for (const role of ['host', 'phone']) {
|
||||
const peer = session[role];
|
||||
session[role] = null;
|
||||
try {
|
||||
peer?.close(4000, reason);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
for (const waiter of session.frameWaiters.splice(0)) waiter(null);
|
||||
onSessionClosed(session);
|
||||
}
|
||||
|
||||
function scheduleHostExpiry(session) {
|
||||
clearTimer(session.hostTimer);
|
||||
session.hostTimer = setTimer(() => {
|
||||
if (!session.host) closeSession(session, 'host-gone');
|
||||
}, HOST_GRACE_MS);
|
||||
session.hostTimer?.unref?.();
|
||||
}
|
||||
|
||||
/** Create a session for the desktop; older sessions beyond the cap close. */
|
||||
function createSession() {
|
||||
while (sessions.size >= MAX_SESSIONS)
|
||||
closeSession(sessions.values().next().value, 'replaced');
|
||||
const session = {
|
||||
id: crypto.randomUUID(),
|
||||
hostKey: randomKey(),
|
||||
code: pairingCode(),
|
||||
phoneKey: null,
|
||||
failures: 0,
|
||||
host: null,
|
||||
phone: null,
|
||||
hostRate: bucket(),
|
||||
phoneRate: bucket(),
|
||||
frame: null,
|
||||
frameSeq: 0,
|
||||
frameWaiters: [],
|
||||
createdAt: now(),
|
||||
};
|
||||
sessions.set(session.id, session);
|
||||
scheduleHostExpiry(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
const sessionForHost = (hostKey) =>
|
||||
[...sessions.values()].find((s) => sameSecret(s.hostKey, hostKey)) || null;
|
||||
const sessionForPhone = (phoneKey) =>
|
||||
(phoneKey &&
|
||||
[...sessions.values()].find(
|
||||
(s) => s.phoneKey && sameSecret(s.phoneKey, phoneKey),
|
||||
)) ||
|
||||
null;
|
||||
|
||||
/**
|
||||
* Exchange a pairing code for a phone key. A new join replaces the previous
|
||||
* phone. Wrong codes count against every session; enough of them rotate
|
||||
* the codes and tell the desktop to show the new one.
|
||||
*
|
||||
* @returns {{ ok: true, phoneKey: string, sessionId: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
function joinPhone(code) {
|
||||
const wanted = normalizeCode(code);
|
||||
const session =
|
||||
wanted.length === PAIRING_CODE_LENGTH
|
||||
? [...sessions.values()].find((s) => sameSecret(s.code, wanted))
|
||||
: null;
|
||||
if (!session) {
|
||||
for (const candidate of sessions.values()) {
|
||||
candidate.failures += 1;
|
||||
if (candidate.failures >= MAX_CODE_FAILURES) {
|
||||
candidate.failures = 0;
|
||||
candidate.code = pairingCode();
|
||||
notify(candidate.host, { type: 'code', code: candidate.code });
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: 'invalid-code' };
|
||||
}
|
||||
session.failures = 0;
|
||||
session.phoneKey = randomKey();
|
||||
if (session.phone) {
|
||||
const previous = session.phone;
|
||||
session.phone = null;
|
||||
try {
|
||||
previous.close(4001, 'replaced');
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
return { ok: true, phoneKey: session.phoneKey, sessionId: session.id };
|
||||
}
|
||||
|
||||
/** Attach a desktop transport; returns the session or null for a bad key. */
|
||||
function attachHost(hostKey, peer) {
|
||||
const session = sessionForHost(hostKey);
|
||||
if (!session) return null;
|
||||
const previous = session.host;
|
||||
session.host = peer;
|
||||
clearTimer(session.hostTimer);
|
||||
if (previous && previous !== peer) {
|
||||
try {
|
||||
previous.close(4001, 'replaced');
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
notify(peer, { type: 'code', code: session.code });
|
||||
notify(peer, { type: 'peer', role: 'phone', connected: !!session.phone });
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Attach a phone transport; returns the session or null for a bad key. */
|
||||
function attachPhone(phoneKey, peer) {
|
||||
const session = sessionForPhone(phoneKey);
|
||||
if (!session) return null;
|
||||
const previous = session.phone;
|
||||
session.phone = peer;
|
||||
if (previous && previous !== peer) {
|
||||
try {
|
||||
previous.close(4001, 'replaced');
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
notify(session.host, { type: 'peer', role: 'phone', connected: true });
|
||||
notify(peer, { type: 'peer', role: 'host', connected: !!session.host });
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Detach a transport if it is still the session's current one. */
|
||||
function detach(session, role, peer) {
|
||||
if (!session || session[role] !== peer) return;
|
||||
session[role] = null;
|
||||
const other = role === 'host' ? session.phone : session.host;
|
||||
notify(other, { type: 'peer', role, connected: false });
|
||||
if (role === 'host') scheduleHostExpiry(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one text message to the other peer. Refuses oversize, non-JSON
|
||||
* and over-rate input; the caller may close a peer that keeps sending it.
|
||||
*
|
||||
* @returns {boolean} Whether the message was admitted.
|
||||
*/
|
||||
function relay(session, role, text) {
|
||||
if (!session || !sessions.has(session.id)) return false;
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_TEXT_BYTES)
|
||||
return false;
|
||||
if (!admit(role === 'host' ? session.hostRate : session.phoneRate))
|
||||
return false;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const target = role === 'host' ? session.phone : session.host;
|
||||
if (target) {
|
||||
try {
|
||||
target.send(text);
|
||||
} catch {
|
||||
// The target transport owns its own close handling.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hold the host's newest JPEG preview frame and wake waiting phones. */
|
||||
function publishFrame(session, bytes) {
|
||||
if (!session || !sessions.has(session.id)) return false;
|
||||
if (!Buffer.isBuffer(bytes) || !bytes.length) return false;
|
||||
if (bytes.length > MAX_FRAME_BYTES) return false;
|
||||
// JPEG start-of-image marker: the phone page only ever shows JPEG.
|
||||
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
||||
session.frame = bytes;
|
||||
session.frameSeq += 1;
|
||||
for (const waiter of session.frameWaiters.splice(0)) waiter(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve with the newest frame after `afterSeq`, waiting up to `waitMs`.
|
||||
* @returns {Promise<{ seq: number, bytes: Buffer } | null>}
|
||||
*/
|
||||
function nextFrame(session, afterSeq, waitMs = 2000) {
|
||||
const current = () =>
|
||||
session.frame && session.frameSeq > afterSeq
|
||||
? { seq: session.frameSeq, bytes: session.frame }
|
||||
: null;
|
||||
const ready = current();
|
||||
if (ready || waitMs <= 0) return Promise.resolve(ready);
|
||||
return new Promise((resolve) => {
|
||||
let timer = null;
|
||||
const waiter = (live) => {
|
||||
clearTimer(timer);
|
||||
resolve(live ? current() : null);
|
||||
};
|
||||
session.frameWaiters.push(waiter);
|
||||
timer = setTimer(() => {
|
||||
const index = session.frameWaiters.indexOf(waiter);
|
||||
if (index >= 0) session.frameWaiters.splice(index, 1);
|
||||
resolve(current());
|
||||
}, waitMs);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createSession,
|
||||
joinPhone,
|
||||
attachHost,
|
||||
attachPhone,
|
||||
detach,
|
||||
relay,
|
||||
publishFrame,
|
||||
nextFrame,
|
||||
sessionForHost,
|
||||
sessionForPhone,
|
||||
closeSession,
|
||||
endSession(hostKey) {
|
||||
const session = sessionForHost(hostKey);
|
||||
if (session) closeSession(session, 'ended');
|
||||
return !!session;
|
||||
},
|
||||
get size() {
|
||||
return sessions.size;
|
||||
},
|
||||
closeAll() {
|
||||
for (const session of [...sessions.values()])
|
||||
closeSession(session, 'shutdown');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_TEXT_BYTES,
|
||||
PAIRING_CODE_LENGTH,
|
||||
createVcamRelay,
|
||||
} from './relay.js';
|
||||
|
||||
function peer() {
|
||||
const sent = [];
|
||||
const closed = [];
|
||||
return {
|
||||
sent,
|
||||
closed,
|
||||
messages: () => sent.map((text) => JSON.parse(text)),
|
||||
send: (text) => sent.push(text),
|
||||
close: (code, reason) => closed.push([code, reason]),
|
||||
};
|
||||
}
|
||||
|
||||
function clock() {
|
||||
let at = 0;
|
||||
const timers = new Set();
|
||||
return {
|
||||
now: () => at,
|
||||
advance(ms) {
|
||||
at += ms;
|
||||
for (const timer of [...timers])
|
||||
if (timer.due <= at) {
|
||||
timers.delete(timer);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
setTimer: (fn, ms) => {
|
||||
const timer = { fn, due: at + ms };
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimer: (timer) => timers.delete(timer),
|
||||
};
|
||||
}
|
||||
|
||||
test('a phone pairs with the displayed code and relays both ways', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
assert.equal(session.code.length, PAIRING_CODE_LENGTH);
|
||||
assert.match(session.code, /^[2-9A-HJKMNP-Z]+$/, 'no ambiguous characters');
|
||||
const host = peer();
|
||||
assert.equal(relay.attachHost(session.hostKey, host), session);
|
||||
assert.deepEqual(host.messages(), [
|
||||
{ type: 'code', code: session.code },
|
||||
{ type: 'peer', role: 'phone', connected: false },
|
||||
]);
|
||||
const joined = relay.joinPhone(` ${session.code.toLowerCase().slice(0, 3)}-${session.code.slice(3)} `);
|
||||
assert.equal(joined.ok, true, 'codes are case- and separator-insensitive');
|
||||
const phone = peer();
|
||||
assert.equal(relay.attachPhone(joined.phoneKey, phone), session);
|
||||
assert.deepEqual(host.messages().at(-1), { type: 'peer', role: 'phone', connected: true });
|
||||
assert.ok(relay.relay(session, 'phone', '{"type":"pose","q":[0,0,0,1]}'));
|
||||
assert.equal(host.sent.at(-1), '{"type":"pose","q":[0,0,0,1]}');
|
||||
assert.ok(relay.relay(session, 'host', '{"type":"state"}'));
|
||||
assert.equal(phone.sent.at(-1), '{"type":"state"}');
|
||||
relay.detach(session, 'phone', phone);
|
||||
assert.deepEqual(host.messages().at(-1), { type: 'peer', role: 'phone', connected: false });
|
||||
});
|
||||
|
||||
test('keys are per role and a new phone join replaces the previous phone', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
assert.equal(relay.attachPhone(session.hostKey, peer()), null);
|
||||
assert.equal(relay.attachHost('nope', peer()), null);
|
||||
const first = relay.joinPhone(session.code);
|
||||
const phone = peer();
|
||||
relay.attachPhone(first.phoneKey, phone);
|
||||
const second = relay.joinPhone(session.code);
|
||||
assert.notEqual(first.phoneKey, second.phoneKey);
|
||||
assert.deepEqual(phone.closed, [[4001, 'replaced']]);
|
||||
assert.equal(relay.sessionForPhone(first.phoneKey), null, 'the old key is revoked');
|
||||
});
|
||||
|
||||
test('wrong codes rotate the pairing code and tell the desktop', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
const host = peer();
|
||||
relay.attachHost(session.hostKey, host);
|
||||
const original = session.code;
|
||||
for (let i = 0; i < 7; i++) assert.equal(relay.joinPhone('ZZZZZZ').ok, false);
|
||||
assert.equal(session.code, original);
|
||||
relay.joinPhone('ZZZZZZ');
|
||||
assert.notEqual(session.code, original);
|
||||
assert.deepEqual(host.messages().at(-1), { type: 'code', code: session.code });
|
||||
assert.equal(relay.joinPhone(original).ok, false, 'the old code no longer pairs');
|
||||
assert.equal(relay.joinPhone(session.code).ok, true);
|
||||
});
|
||||
|
||||
test('oversize, non-object and over-rate messages are refused', () => {
|
||||
const time = clock();
|
||||
const relay = createVcamRelay(time);
|
||||
const session = relay.createSession();
|
||||
const host = peer();
|
||||
relay.attachHost(session.hostKey, host);
|
||||
const big = JSON.stringify({ type: 'x', pad: 'a'.repeat(MAX_TEXT_BYTES) });
|
||||
assert.equal(relay.relay(session, 'phone', big), false);
|
||||
assert.equal(relay.relay(session, 'phone', 'not json'), false);
|
||||
assert.equal(relay.relay(session, 'phone', '[1,2]'), false);
|
||||
let admitted = 0;
|
||||
for (let i = 0; i < 400; i++) if (relay.relay(session, 'phone', '{}')) admitted++;
|
||||
// The two malformed messages above already spent their tokens.
|
||||
assert.equal(admitted, 238, 'a burst is capped, malformed input included');
|
||||
time.advance(500);
|
||||
assert.equal(relay.relay(session, 'phone', '{}'), true, 'tokens refill over time');
|
||||
});
|
||||
|
||||
test('the newest preview frame is pulled, not queued', async () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
const jpeg = (n) => Buffer.from([0xff, 0xd8, n]);
|
||||
assert.equal(relay.publishFrame(session, Buffer.from('GIF89a')), false, 'only JPEG');
|
||||
assert.equal(relay.publishFrame(session, Buffer.alloc(MAX_FRAME_BYTES + 1, 0xff)), false);
|
||||
assert.equal(await relay.nextFrame(session, 0, 0), null);
|
||||
const waiting = relay.nextFrame(session, 0, 5000);
|
||||
relay.publishFrame(session, jpeg(1));
|
||||
relay.publishFrame(session, jpeg(2));
|
||||
const first = await waiting;
|
||||
assert.equal(first.seq, 1);
|
||||
const latest = await relay.nextFrame(session, first.seq, 0);
|
||||
assert.equal(latest.seq, 2, 'a slow phone skips straight to the newest frame');
|
||||
assert.equal(latest.bytes[2], 2);
|
||||
relay.closeSession(session);
|
||||
assert.equal(relay.publishFrame(session, jpeg(3)), false);
|
||||
});
|
||||
|
||||
test('a session outlives a desktop reload but not a departed desktop', () => {
|
||||
const time = clock();
|
||||
const closed = [];
|
||||
const relay = createVcamRelay({ ...time, onSessionClosed: (s) => closed.push(s.id) });
|
||||
const session = relay.createSession();
|
||||
const host = peer();
|
||||
relay.attachHost(session.hostKey, host);
|
||||
const phone = peer();
|
||||
relay.attachPhone(relay.joinPhone(session.code).phoneKey, phone);
|
||||
relay.detach(session, 'host', host);
|
||||
assert.deepEqual(phone.messages().at(-1), { type: 'peer', role: 'host', connected: false });
|
||||
time.advance(30000);
|
||||
relay.attachHost(session.hostKey, peer());
|
||||
time.advance(60000);
|
||||
assert.equal(relay.size, 1, 'a reattached host keeps the session');
|
||||
relay.detach(session, 'host', relay.sessionForHost(session.hostKey).host);
|
||||
time.advance(60001);
|
||||
assert.equal(relay.size, 0);
|
||||
assert.deepEqual(closed, [session.id]);
|
||||
assert.deepEqual(phone.closed, [[4000, 'host-gone']]);
|
||||
});
|
||||
|
||||
test('ending a session closes both peers and only the host key can end it', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
const phone = peer();
|
||||
relay.attachPhone(relay.joinPhone(session.code).phoneKey, phone);
|
||||
assert.equal(relay.endSession('wrong'), false);
|
||||
assert.equal(relay.endSession(session.hostKey), true);
|
||||
assert.deepEqual(phone.closed, [[4000, 'ended']]);
|
||||
for (let i = 0; i < 6; i++) relay.createSession();
|
||||
assert.equal(relay.size, 4, 'sessions are capped');
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* Local certificate authority for the virtual-camera phone listener.
|
||||
*
|
||||
* Phones only expose camera tracking (WebXR, DeviceOrientation) to secure
|
||||
* contexts, so the LAN listener must speak HTTPS. A tapped-through
|
||||
* self-signed certificate is not enough on iOS: Safari loads the page but
|
||||
* refuses its WebSocket. Instead this module keeps one long-lived local CA,
|
||||
* which a phone can install and trust once, and issues the listener a short
|
||||
* server certificate for this machine's current LAN addresses.
|
||||
*
|
||||
* Certificates are encoded here with node:crypto (EC P-256, ECDSA-SHA256), so
|
||||
* no OpenSSL binary or dependency is needed. Keys never leave the cache
|
||||
* directory, which is git-ignored and outside the dev server's watcher.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
// Apple rejects user-trusted TLS server certificates valid for over 825 days.
|
||||
const SERVER_VALIDITY_DAYS = 397;
|
||||
const CA_VALIDITY_DAYS = 3650;
|
||||
// Reissue the server certificate this long before it expires.
|
||||
const RENEW_MARGIN_DAYS = 30;
|
||||
|
||||
const length = (n) =>
|
||||
n < 0x80
|
||||
? Buffer.from([n])
|
||||
: n < 0x100
|
||||
? Buffer.from([0x81, n])
|
||||
: Buffer.from([0x82, n >> 8, n & 0xff]);
|
||||
const tlv = (tag, ...parts) => {
|
||||
const body = Buffer.concat(parts);
|
||||
return Buffer.concat([Buffer.from([tag]), length(body.length), body]);
|
||||
};
|
||||
const sequence = (...parts) => tlv(0x30, ...parts);
|
||||
const set = (...parts) => tlv(0x31, ...parts);
|
||||
const explicit = (n, ...parts) => tlv(0xa0 + n, ...parts);
|
||||
const octets = (buffer) => tlv(0x04, buffer);
|
||||
const bits = (buffer, unused = 0) => tlv(0x03, Buffer.from([unused]), buffer);
|
||||
const boolean = (value) => tlv(0x01, Buffer.from([value ? 0xff : 0]));
|
||||
const utf8 = (text) => tlv(0x0c, Buffer.from(text, 'utf8'));
|
||||
function integer(buffer) {
|
||||
let bytes = Buffer.from(buffer);
|
||||
while (bytes.length > 1 && bytes[0] === 0 && !(bytes[1] & 0x80))
|
||||
bytes = bytes.subarray(1);
|
||||
if (bytes[0] & 0x80) bytes = Buffer.concat([Buffer.from([0]), bytes]);
|
||||
return tlv(0x02, bytes);
|
||||
}
|
||||
function oid(dotted) {
|
||||
const [first, second, ...rest] = dotted.split('.').map(Number);
|
||||
const bytes = [40 * first + second];
|
||||
for (const part of rest) {
|
||||
const group = [part & 0x7f];
|
||||
for (let v = part >>> 7; v; v >>>= 7) group.unshift((v & 0x7f) | 0x80);
|
||||
bytes.push(...group);
|
||||
}
|
||||
return tlv(0x06, Buffer.from(bytes));
|
||||
}
|
||||
function time(date) {
|
||||
const iso = date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
// RFC 5280: UTCTime through 2049, GeneralizedTime from 2050.
|
||||
return date.getUTCFullYear() < 2050
|
||||
? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`))
|
||||
: tlv(0x18, Buffer.from(`${iso}Z`));
|
||||
}
|
||||
const name = (commonName) =>
|
||||
sequence(
|
||||
set(sequence(oid('2.5.4.10'), utf8("God's Eye View"))),
|
||||
set(sequence(oid('2.5.4.3'), utf8(commonName))),
|
||||
);
|
||||
const extension = (id, critical, value) =>
|
||||
sequence(oid(id), ...(critical ? [boolean(true)] : []), octets(value));
|
||||
const ECDSA_SHA256 = sequence(oid('1.2.840.10045.4.3.2'));
|
||||
const spkiOf = (publicKey) => publicKey.export({ type: 'spki', format: 'der' });
|
||||
// RFC 5280 method 1: SHA-1 of the subjectPublicKey bit string's contents.
|
||||
const keyIdentifier = (spki) =>
|
||||
crypto.createHash('sha1').update(spki.subarray(-65)).digest();
|
||||
|
||||
function subjectAltNames(names) {
|
||||
return sequence(
|
||||
...names.map((entry) =>
|
||||
isIP(entry) === 4
|
||||
? tlv(0x87, Buffer.from(entry.split('.').map(Number)))
|
||||
: tlv(0x82, Buffer.from(entry, 'ascii')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sign({ subject, issuer, publicKey, signingKey, days, extensions }) {
|
||||
const now = Date.now();
|
||||
const tbs = sequence(
|
||||
explicit(0, integer(Buffer.from([2]))),
|
||||
integer(crypto.randomBytes(16)),
|
||||
ECDSA_SHA256,
|
||||
issuer,
|
||||
sequence(time(new Date(now - DAY_MS)), time(new Date(now + days * DAY_MS))),
|
||||
subject,
|
||||
spkiOf(publicKey),
|
||||
explicit(3, sequence(...extensions)),
|
||||
);
|
||||
const signature = crypto.sign('sha256', tbs, signingKey);
|
||||
return sequence(tbs, ECDSA_SHA256, bits(signature));
|
||||
}
|
||||
|
||||
const pem = (label, der) =>
|
||||
`-----BEGIN ${label}-----\n${der
|
||||
.toString('base64')
|
||||
.match(/.{1,64}/g)
|
||||
.join('\n')}\n-----END ${label}-----\n`;
|
||||
const keyPem = (key) => key.export({ type: 'pkcs8', format: 'pem' });
|
||||
|
||||
const CA_COMMON_NAME = "God's Eye View Local VCam CA";
|
||||
|
||||
/** Create a CA that may only sign server certificates (path length 0). */
|
||||
export function createCertificateAuthority(commonName = CA_COMMON_NAME) {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
});
|
||||
const subject = name(commonName);
|
||||
const der = sign({
|
||||
subject,
|
||||
issuer: subject,
|
||||
publicKey,
|
||||
signingKey: privateKey,
|
||||
days: CA_VALIDITY_DAYS,
|
||||
extensions: [
|
||||
extension(
|
||||
'2.5.29.19',
|
||||
true,
|
||||
sequence(boolean(true), integer(Buffer.from([0]))),
|
||||
),
|
||||
// keyCertSign and cRLSign.
|
||||
extension('2.5.29.15', true, bits(Buffer.from([0x06]), 1)),
|
||||
extension('2.5.29.14', false, octets(keyIdentifier(spkiOf(publicKey)))),
|
||||
],
|
||||
});
|
||||
return { cert: pem('CERTIFICATE', der), key: keyPem(privateKey) };
|
||||
}
|
||||
|
||||
/** Issue a TLS server certificate for the given IP addresses and DNS names. */
|
||||
export function issueServerCertificate(authority, names) {
|
||||
const caKey = crypto.createPrivateKey(authority.key);
|
||||
const caCert = new crypto.X509Certificate(authority.cert);
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
});
|
||||
const caSpki = spkiOf(caCert.publicKey);
|
||||
const der = sign({
|
||||
subject: name(names[0]),
|
||||
issuer: name(caCert.subject.match(/CN=([^\n]+)/)?.[1] ?? CA_COMMON_NAME),
|
||||
publicKey,
|
||||
signingKey: caKey,
|
||||
days: SERVER_VALIDITY_DAYS,
|
||||
extensions: [
|
||||
extension('2.5.29.19', true, sequence()),
|
||||
// digitalSignature.
|
||||
extension('2.5.29.15', true, bits(Buffer.from([0x80]), 7)),
|
||||
extension('2.5.29.37', false, sequence(oid('1.3.6.1.5.5.7.3.1'))),
|
||||
extension('2.5.29.17', false, subjectAltNames(names)),
|
||||
extension('2.5.29.35', false, sequence(tlv(0x80, keyIdentifier(caSpki)))),
|
||||
],
|
||||
});
|
||||
return { cert: pem('CERTIFICATE', der), key: keyPem(privateKey) };
|
||||
}
|
||||
|
||||
function readPair(directory, base) {
|
||||
try {
|
||||
return {
|
||||
cert: fs.readFileSync(path.join(directory, `${base}.crt`), 'utf8'),
|
||||
key: fs.readFileSync(path.join(directory, `${base}.key`), 'utf8'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePair(directory, base, pair) {
|
||||
fs.writeFileSync(path.join(directory, `${base}.key`), pair.key, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(path.join(directory, `${base}.crt`), pair.cert);
|
||||
}
|
||||
|
||||
function serverCertificateCovers(pair, authority, names) {
|
||||
try {
|
||||
const cert = new crypto.X509Certificate(pair.cert);
|
||||
if (!cert.verify(new crypto.X509Certificate(authority.cert).publicKey))
|
||||
return false;
|
||||
if (Date.parse(cert.validTo) - Date.now() < RENEW_MARGIN_DAYS * DAY_MS)
|
||||
return false;
|
||||
const listed = new Set(
|
||||
String(cert.subjectAltName || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().replace(/^(DNS|IP Address):/, '')),
|
||||
);
|
||||
return names.every((entry) => listed.has(entry));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create the cached CA and a server certificate covering `names`.
|
||||
* The CA survives restarts so a phone's one-time trust keeps working; the
|
||||
* server certificate is reissued when an address changes or it nears expiry.
|
||||
*
|
||||
* @param {string} directory - Cache directory, created with owner-only access.
|
||||
* @param {string[]} names - IPv4 addresses and DNS names; the first is the subject.
|
||||
* @returns {{ ca: string, cert: string, key: string, caFingerprint: string }}
|
||||
*/
|
||||
export function loadLocalCertificates(directory, names) {
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
let authority = readPair(directory, 'ca');
|
||||
try {
|
||||
if (authority && !new crypto.X509Certificate(authority.cert).ca)
|
||||
authority = null;
|
||||
} catch {
|
||||
authority = null;
|
||||
}
|
||||
if (!authority) {
|
||||
authority = createCertificateAuthority();
|
||||
writePair(directory, 'ca', authority);
|
||||
}
|
||||
let server = readPair(directory, 'server');
|
||||
if (!server || !serverCertificateCovers(server, authority, names)) {
|
||||
server = issueServerCertificate(authority, names);
|
||||
writePair(directory, 'server', server);
|
||||
}
|
||||
return {
|
||||
ca: authority.cert,
|
||||
cert: server.cert,
|
||||
key: server.key,
|
||||
caFingerprint: new crypto.X509Certificate(authority.cert).fingerprint256,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
} from '../data/layerSnapshot.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
|
||||
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||
{ layerId: 'flights', kind: 'aircraft' },
|
||||
{ layerId: 'military', kind: 'aircraft' },
|
||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||
{ layerId: 'satellites', kind: 'satellite' },
|
||||
]);
|
||||
|
||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||
{ field: 'mode', emptyAs: 'off' },
|
||||
{ field: 'entering', emptyAs: null },
|
||||
{ field: 'priorMode', emptyAs: null },
|
||||
]);
|
||||
|
||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||
'context',
|
||||
'contextRollback',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate context-mode payloads into the public tool vocabulary while
|
||||
* retaining the internal id beside each translated field.
|
||||
*/
|
||||
export function withContextModeVocabulary(state) {
|
||||
if (!state || typeof state !== 'object') return state;
|
||||
let out = state;
|
||||
const mutable = () => {
|
||||
if (out === state) out = { ...state };
|
||||
return out;
|
||||
};
|
||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
||||
if (!(field in state)) continue;
|
||||
const internal = state[field] ?? null;
|
||||
const target = mutable();
|
||||
target[field] = contextModeWord(internal, { emptyAs });
|
||||
target[`${field}Internal`] = internal;
|
||||
}
|
||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
||||
const nested = state[field];
|
||||
if (!nested || typeof nested !== 'object') continue;
|
||||
const translated = withContextModeVocabulary(nested);
|
||||
if (translated !== nested) mutable()[field] = translated;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read tracked entities from their canonical layer owners. */
|
||||
export function collectTrackedEntities(dataManager) {
|
||||
const tracked = [];
|
||||
for (const family of TRACKABLE_FAMILIES) {
|
||||
const module = dataManager?.layers?.get(family.layerId)?.module;
|
||||
if (!module) continue;
|
||||
try {
|
||||
const info =
|
||||
family.kind === 'vessel'
|
||||
? module.getSelectedInfo?.()
|
||||
: module.getTrackedInfo?.();
|
||||
if (info)
|
||||
tracked.push({
|
||||
kind: family.kind,
|
||||
layerId: family.layerId,
|
||||
...info,
|
||||
});
|
||||
} catch {
|
||||
// A not-yet-ready layer contributes no tracked observation.
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/** Read the active Contacts window from its owning awareness layer. */
|
||||
export function activeContactsWindow(dataManager) {
|
||||
try {
|
||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||
return (
|
||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model-free fast World Packet from existing application owners.
|
||||
*
|
||||
* This performs no network requests and intentionally mirrors the historical
|
||||
* get_current_view_state payload so voice can delegate without behavior drift.
|
||||
*/
|
||||
export function getWorldPacket(options = {}) {
|
||||
const {
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager,
|
||||
sceneDirector = null,
|
||||
now: requestedNow,
|
||||
} = options ?? {};
|
||||
// A default parameter only covers undefined; null or NaN would age every
|
||||
// feed against the epoch and report stale data as "just now".
|
||||
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
|
||||
if (!viewer?.camera?.positionWC)
|
||||
throw new TypeError('World Packet requires a viewer camera');
|
||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||
throw new TypeError('World Packet requires a data manager');
|
||||
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||
// current enabled-only view keeps the model's context budget bounded.
|
||||
const rows = dataManager.getAll();
|
||||
const enabledRows = rows.filter((layer) => layer.enabled);
|
||||
const contactsWindow = activeContactsWindow(dataManager);
|
||||
const contextState =
|
||||
typeof styleManager?.getContextModeState === 'function'
|
||||
? withContextModeVocabulary(styleManager.getContextModeState())
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
},
|
||||
style: styleManager?.activeStyle || 'normal',
|
||||
context: contextState
|
||||
? {
|
||||
...contextState,
|
||||
...(contactsWindow ? { contactsWindow } : {}),
|
||||
}
|
||||
: null,
|
||||
cockpit:
|
||||
typeof styleManager?.getCockpitState === 'function'
|
||||
? styleManager.getCockpitState()
|
||||
: null,
|
||||
controls:
|
||||
typeof styleManager?.getControlState === 'function'
|
||||
? styleManager.getControlState()
|
||||
: null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
disabledLayerCount: rows.length - enabledRows.length,
|
||||
// Preserve get_current_view_state's existing compact enabled-only shape.
|
||||
layers: enabledRows.map((layer) => {
|
||||
const snapshot = layerSnapshot(layer, { now });
|
||||
return {
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
enabled: layer.enabled,
|
||||
count: layer.stats?.count || 0,
|
||||
error: layer.stats?.error || null,
|
||||
feedState: snapshot.feedState,
|
||||
source: snapshot.source,
|
||||
lastUpdate: snapshot.lastUpdate,
|
||||
};
|
||||
}),
|
||||
feedProvenance: feedProvenanceEnvelope(
|
||||
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
|
||||
{ now },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
import { getWorldPacket } from './worldPacket.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
function withoutVoicePresentation(result) {
|
||||
const packet = { ...result };
|
||||
delete packet.say;
|
||||
delete packet.display;
|
||||
delete packet.schedule;
|
||||
return packet;
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||
const viewer = {
|
||||
trackedEntity: undefined,
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: {
|
||||
canvas: {
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
globe: { getHeight: () => 0 },
|
||||
tweens: [],
|
||||
},
|
||||
camera: {
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-45),
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
cancelFlight() {},
|
||||
lookAtTransform() {},
|
||||
},
|
||||
};
|
||||
const rows = [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 3,
|
||||
source: 'fixture',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
const dataManager = {
|
||||
layers: new Map([['traffic', { module: null }]]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'normal',
|
||||
getContextModeState: () => ({ mode: 'flights' }),
|
||||
getCockpitState: () => null,
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager };
|
||||
}
|
||||
|
||||
test('World Packet preserves get_current_view_state parity', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
assert.equal(packet.context.mode, 'contacts');
|
||||
assert.equal(packet.context.modeInternal, 'flights');
|
||||
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
dataManager.getAll = () => [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(packet.layers[0].count, 0);
|
||||
assert.notEqual(packet.layers[0].feedState, 'nominal');
|
||||
assert.notEqual(packet.feedProvenance.overall, 'nominal');
|
||||
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||
});
|
||||
|
||||
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
||||
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const rows = dataManager.getAll();
|
||||
rows.push({
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
});
|
||||
let reads = 0;
|
||||
dataManager.getAll = () => {
|
||||
reads += 1;
|
||||
return rows;
|
||||
};
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
|
||||
assert.equal(packet.disabledLayerCount, 1);
|
||||
assert.deepEqual(
|
||||
packet.layers.map((layer) => layer.id),
|
||||
['traffic'],
|
||||
);
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
});
|
||||
|
||||
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
|
||||
|
||||
// Exercises every packet field: context vocabulary and Contacts window,
|
||||
// cockpit, controls, scene playback, tracked families (one not ready),
|
||||
// enabled and disabled layers, and an aged and an unavailable feed.
|
||||
function pinnedHarness() {
|
||||
const { viewer } = harness();
|
||||
const rows = [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 12,
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: PINNED_NOW - 90_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
},
|
||||
];
|
||||
const module = (methods) => ({ module: methods });
|
||||
const dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'flights',
|
||||
module({
|
||||
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
|
||||
}),
|
||||
],
|
||||
[
|
||||
'ais-live-vessels',
|
||||
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
|
||||
],
|
||||
[
|
||||
'satellites',
|
||||
module({
|
||||
getTrackedInfo: () => {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'military-awareness',
|
||||
module({
|
||||
getContextSnapshot: () => ({ snapshot: true }),
|
||||
contactsWindowFromSnapshot: (snapshot) =>
|
||||
snapshot ? { windowMin: 30, count: 4 } : null,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'night-vision',
|
||||
getContextModeState: () => ({
|
||||
mode: 'flights',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
}),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
const sceneDirector = {
|
||||
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager, sceneDirector };
|
||||
}
|
||||
|
||||
// Captured from main's get_current_view_state before the extraction
|
||||
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
|
||||
// minus the speech envelope. Key order is part of the contract.
|
||||
const PRE_EXTRACTION_PAYLOAD = {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: 30.259999999999994,
|
||||
longitude: -97.74,
|
||||
heightM: 499.99999999901877,
|
||||
},
|
||||
style: 'night-vision',
|
||||
context: {
|
||||
mode: 'contacts',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
modeInternal: 'flights',
|
||||
enteringInternal: null,
|
||||
priorModeInternal: null,
|
||||
contactsWindow: { windowMin: 30, count: 4 },
|
||||
},
|
||||
cockpit: { active: false },
|
||||
controls: { hud: { visible: 'auto' } },
|
||||
scenePlayback: { playing: false, sceneId: 'fixture' },
|
||||
tracked: [
|
||||
{
|
||||
kind: 'aircraft',
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
callsign: 'TEST1',
|
||||
},
|
||||
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
|
||||
],
|
||||
disabledLayerCount: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
count: 12,
|
||||
error: null,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: 1792238310000,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
error: 'fixture unavailable',
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
lastUpdate: 0,
|
||||
},
|
||||
],
|
||||
feedProvenance: {
|
||||
overall: 'unavailable',
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
count: 12,
|
||||
lastUpdate: 1792238310000,
|
||||
ageSec: 90,
|
||||
ageLabel: '1m ago',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
count: 0,
|
||||
lastUpdate: 0,
|
||||
ageSec: null,
|
||||
ageLabel: null,
|
||||
error: 'fixture unavailable',
|
||||
},
|
||||
],
|
||||
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
|
||||
},
|
||||
};
|
||||
|
||||
function assertPinnedPayload(actual) {
|
||||
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
|
||||
// deepEqual ignores key order; the serialized form does not.
|
||||
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
|
||||
}
|
||||
|
||||
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
|
||||
assertPinnedPayload(packet);
|
||||
});
|
||||
|
||||
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
const runner = createGevActionRunner(pinnedHarness());
|
||||
const voice = await runner('get_current_view_state');
|
||||
assertPinnedPayload(withoutVoicePresentation(voice));
|
||||
});
|
||||
|
||||
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
for (const now of [null, Number.NaN, '2026-10-17']) {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now });
|
||||
const flights = packet.feedProvenance.layers.find(
|
||||
(layer) => layer.id === 'flights',
|
||||
);
|
||||
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
|
||||
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('World Packet rejects null options with the camera requirement', () => {
|
||||
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||
});
|
||||
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
services: { sprites, picking, input, scenePick, render },
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { createCameraLens } from '../scenes/cameraLens.js';
|
||||
import { createVcamController } from '../vcam/controller.js';
|
||||
import { bindVcamPanel } from '../ui/vcamPanel.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
@@ -35,36 +32,16 @@ export function createApplicationTools({
|
||||
const { viewer, tileset, mapStackController, operations } = scene;
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
// Focal length and depth of field, shared by scene playback and the VCAM.
|
||||
const lens = createCameraLens(viewer, {
|
||||
requestRender: (reason) => governorRequestRender(reason),
|
||||
});
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
dataPacks: sceneDataPacks,
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
lens,
|
||||
});
|
||||
dataManager.layers
|
||||
.get('bhote-koshi-2026')
|
||||
?.module.attachSceneController(sceneDirector);
|
||||
defer(() => sceneDirector.destroy());
|
||||
onSceneDirector?.(sceneDirector);
|
||||
// SCENES ▸ VCAM: a phone on the same network becomes the camera; its
|
||||
// takes are recorded into the selected scene. Torn down before the lens
|
||||
// and director it drives.
|
||||
const vcam = createVcamController({
|
||||
viewer,
|
||||
navigation: styleManager,
|
||||
director: sceneDirector,
|
||||
lens,
|
||||
});
|
||||
const unbindVcamPanel = bindVcamPanel(vcam);
|
||||
defer(() => lens.destroy());
|
||||
defer(() => {
|
||||
unbindVcamPanel();
|
||||
vcam.destroy();
|
||||
});
|
||||
const annotations = initAnnotations({
|
||||
viewer,
|
||||
tileset,
|
||||
@@ -189,8 +166,6 @@ export function createApplicationTools({
|
||||
getRenderGovernorDiagnostics,
|
||||
surfaceServices: operations.surface,
|
||||
requestRender: governorRequestRender,
|
||||
vcam,
|
||||
lens,
|
||||
};
|
||||
const debug = window.__godsEyeView;
|
||||
defer(() => {
|
||||
|
||||
@@ -693,7 +693,10 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
}
|
||||
|
||||
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
|
||||
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
||||
assert.match(
|
||||
ui,
|
||||
/enterPanels: \(\) => \{[^}]*this\._panelChrome\.enterCockpit\(\);\s*\}/,
|
||||
);
|
||||
const callback = { 1: shellMethod('enterCockpit').toString() };
|
||||
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
||||
assert.match(
|
||||
|
||||
@@ -77,6 +77,11 @@ import {
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
activateCctvCameraFromWorldClick,
|
||||
} from '../cctvFocusRequest.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
@@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
|
||||
}
|
||||
});
|
||||
|
||||
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
|
||||
const record = (id, feedType) => ({
|
||||
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
|
||||
billboard: {},
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
});
|
||||
const idleVideo = record('idle-hls', 'hls');
|
||||
const idleStill = record('idle-image', 'image');
|
||||
const activeVideo = record('active-hls', 'hls');
|
||||
const viewer = { entities: { add: (entity) => entity } };
|
||||
|
||||
try {
|
||||
// Disabled keeps the active video camera from opening a real projection;
|
||||
// icon colors are assigned either way.
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [idleVideo, idleStill, activeVideo],
|
||||
activeCameraId: activeVideo.camera.id,
|
||||
enabled: false,
|
||||
coverageMode: 'off',
|
||||
});
|
||||
refreshCoverageStyles();
|
||||
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
|
||||
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
|
||||
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
||||
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
floridaCameraId,
|
||||
floridaCameraToSource,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyFloridaCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
|
||||
|
||||
/** A response with a live body, plus a flag that flips when it is cancelled. */
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One FL511 feature, shaped like the live FeatureServer payload. */
|
||||
const feature = (overrides = {}) => ({
|
||||
attributes: {
|
||||
OBJECTID_1: 6,
|
||||
ID: '1220',
|
||||
DESCRIPT: 'I-75 @ MM 346.5 SB',
|
||||
COUNTY: 'Marion',
|
||||
HIGHWAY: 'I-75',
|
||||
DIRECTION: 'S',
|
||||
LATITUDE: 29.107363,
|
||||
LONGITUDE: -82.188334,
|
||||
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
/** One catalog page of `count` distinct cameras, channels from `start`. */
|
||||
const page = (start, count, extra = {}) => ({
|
||||
features: Array.from({ length: count }, (_, i) =>
|
||||
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
|
||||
),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const offsetOf = (url) =>
|
||||
Number(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
|
||||
/** Lift the pack cap for one test so every loaded camera comes back. */
|
||||
const uncapped = (t) => {
|
||||
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
|
||||
});
|
||||
};
|
||||
|
||||
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
|
||||
const source = floridaCameraToSource(feature());
|
||||
assert.equal(source.id, 'fl-9431');
|
||||
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
|
||||
assert.equal(source.city, 'Marion County');
|
||||
assert.equal(source.cityId, 'florida');
|
||||
assert.equal(source.provider, 'FL511');
|
||||
assert.equal(source.lat, 29.107363);
|
||||
assert.equal(source.lon, -82.188334);
|
||||
assert.equal(source.headingDeg, 180);
|
||||
assert.equal(
|
||||
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
|
||||
90,
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.equal(source.sourceKind, 'fl511-open-data');
|
||||
assert.match(source.license, /personal use only/);
|
||||
assert.match(source.license, /written consent/);
|
||||
});
|
||||
|
||||
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
|
||||
const source = floridaCameraToSource(
|
||||
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
|
||||
});
|
||||
|
||||
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
|
||||
const dropped = [
|
||||
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
|
||||
feature({ LATITUDE: 0, LONGITUDE: 0 }),
|
||||
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
|
||||
feature({ LATITUDE: '', LONGITUDE: '' }),
|
||||
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
|
||||
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
|
||||
feature({
|
||||
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
|
||||
}),
|
||||
feature({ IMAGE: 'not a url' }),
|
||||
{ attributes: null },
|
||||
null,
|
||||
];
|
||||
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
|
||||
});
|
||||
|
||||
test('camera ids come from the image channel, not the repeating ID column', () => {
|
||||
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
|
||||
const marion = floridaCameraToSource(feature());
|
||||
const broward = floridaCameraToSource(
|
||||
feature({
|
||||
DESCRIPT: 'I-95 SB before E Cypress Rd',
|
||||
COUNTY: 'Broward',
|
||||
LATITUDE: 26.22006,
|
||||
LONGITUDE: -80.13676,
|
||||
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(marion.id, 'fl-9431');
|
||||
assert.equal(broward.id, 'fl-6351');
|
||||
// Any resolution suffix keys the same way.
|
||||
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
|
||||
// A filename without a channel falls back to a stable path slug.
|
||||
assert.equal(
|
||||
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
|
||||
'fl-us-1-20at-20sr-5',
|
||||
);
|
||||
assert.equal(floridaCameraId('not a url'), null);
|
||||
});
|
||||
|
||||
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
|
||||
const inside = [
|
||||
[24.5551, -81.78], // Key West
|
||||
[30.4213, -87.2169], // Pensacola
|
||||
[30.99, -84.0], // Georgia line
|
||||
[25.7617, -80.1918], // Miami
|
||||
];
|
||||
const outside = [
|
||||
[33.749, -84.388], // Atlanta
|
||||
[25.0443, -77.3504], // Nassau
|
||||
[0, 0],
|
||||
[-81.78, 24.5551], // swapped
|
||||
[NaN, -81],
|
||||
];
|
||||
for (const [lat, lon] of inside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
|
||||
for (const [lat, lon] of outside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
|
||||
});
|
||||
|
||||
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
|
||||
calls.push([offsetOf(url), init.redirect]);
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
// Last page (no exceededTransferLimit): two new cameras and a repeat.
|
||||
return Response.json(page(1999, 3));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(calls, [
|
||||
[0, 'manual'],
|
||||
[2000, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 2002);
|
||||
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
|
||||
});
|
||||
|
||||
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const offsets = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const params = new URL(String(url)).searchParams;
|
||||
// Offset paging is only stable over a fixed order on the object id.
|
||||
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
|
||||
const offset = offsetOf(url);
|
||||
offsets.push(offset);
|
||||
// A layer whose max record count was lowered to 1,000.
|
||||
return Response.json(
|
||||
page(offset, offset < 2000 ? 1000 : 500, {
|
||||
exceededTransferLimit: offset < 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(offsets, [0, 1000, 2000]);
|
||||
assert.equal(cameras.length, 2500);
|
||||
});
|
||||
|
||||
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
uncapped(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
|
||||
assert.equal(cameras.length, 2000);
|
||||
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
|
||||
});
|
||||
|
||||
test('a redirect on any page drops the whole pack and releases it', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: redirected.response,
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed later page keeps the pages already read', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
uncapped(t);
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: failed.response,
|
||||
);
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
// A network error on a later page keeps them too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
});
|
||||
|
||||
test('an oversized catalog page is refused', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(JSON.stringify(page(0, 1)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only turns out too long while streaming.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve a two-camera FL511 page to the Florida endpoint and an empty payload
|
||||
* to every other pack, so one catalog refresh exercises the registration
|
||||
* without reaching the network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
|
||||
return Response.json([]);
|
||||
});
|
||||
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Put process.env back exactly as it was. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key in saved) continue;
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_FLORIDA_ENABLED = '1';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
'the catalog refresh invokes the Florida loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources
|
||||
.filter((s) => s.cityId === 'florida')
|
||||
.map((s) => s.id)
|
||||
.sort(),
|
||||
['fl-0', 'fl-1'],
|
||||
'Florida cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_FLORIDA_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'florida'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -263,6 +263,14 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'florida-cctv',
|
||||
html:
|
||||
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
|
||||
'Individual, personal use only under the ' +
|
||||
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
|
||||
"any other use needs FDOT's express written consent",
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
@@ -300,6 +308,22 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||
},
|
||||
{
|
||||
key: 'mapillary',
|
||||
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'google-street-view',
|
||||
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
|
||||
},
|
||||
{
|
||||
key: 'kartaview',
|
||||
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
|
||||
test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
for (const key of [
|
||||
'mapillary',
|
||||
'google-street-view',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
])
|
||||
assert.ok(
|
||||
DATA_CREDITS.some((entry) => entry.key === key),
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
|
||||
|
||||
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
|
||||
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
};
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
...headers,
|
||||
Authorization: 'Token secret',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
const start = new Date(end.getTime() - 30 * 86400000);
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
|
||||
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
|
||||
url.searchParams.set('net__gte', start.toISOString());
|
||||
url.searchParams.set('net__lte', end.toISOString());
|
||||
url.searchParams.set('net__lte', roundedEnd.toISOString());
|
||||
url.searchParams.set('limit', '100');
|
||||
url.searchParams.set('mode', 'detailed');
|
||||
return url;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { normalizeVesselType } from './vesselTypes.js';
|
||||
|
||||
export { normalizeVesselType };
|
||||
|
||||
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||
@@ -43,31 +46,6 @@ const TYPE_STYLES = [
|
||||
];
|
||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||
// and tests match on them.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
@@ -104,21 +82,6 @@ export function displayVesselType(type) {
|
||||
return key ? t(key) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged.
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVesselType(type) {
|
||||
const text = String(type || '').trim();
|
||||
if (!text || !/^\d{1,2}$/.test(text)) return text;
|
||||
const code = Number(text);
|
||||
if (code <= 0) return '';
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||
}
|
||||
|
||||
/** AIS ship type → CSS hex hue for the billboard chevron. */
|
||||
export function vesselTypeCss(type) {
|
||||
return styleForType(type).css;
|
||||
|
||||
@@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => {
|
||||
assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker');
|
||||
assert.equal(normalizeVesselType('0'), '');
|
||||
assert.equal(normalizeVesselType('25'), 'OTHER');
|
||||
assert.equal(normalizeVesselType('100'), '');
|
||||
assert.equal(normalizeVesselType('255'), '');
|
||||
assert.equal(normalizeVesselType(undefined), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @module vesselTypes
|
||||
* @description AIS ship-type codes (ITU-R M.1371) to family names, with no
|
||||
* browser or i18n dependencies so the server-side query tools can use it.
|
||||
*/
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged. Code 0
|
||||
* ("not available") and the reserved codes 100 to 255 name no type and
|
||||
* resolve to "".
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVesselType(type) {
|
||||
const text = String(type || '').trim();
|
||||
if (!text || !/^\d+$/.test(text)) return text;
|
||||
const code = Number(text);
|
||||
if (code <= 0 || code >= 100) return '';
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export function editSceneDetails(
|
||||
'holdSec',
|
||||
'dataPackIds',
|
||||
'interactions',
|
||||
'lens',
|
||||
].includes(key)
|
||||
)
|
||||
throw new Error('Unsupported shot detail');
|
||||
@@ -39,7 +38,6 @@ export function editSceneDetails(
|
||||
'holdSec',
|
||||
'dataPackIds',
|
||||
'interactions',
|
||||
'lens',
|
||||
]) {
|
||||
delete shot[key];
|
||||
if (Object.hasOwn(shotDetails, key)) shot[key] = shotDetails[key];
|
||||
|
||||
@@ -17,45 +17,9 @@ export function resolveCameraPose(scene, camera) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Columns of one recorded track sample; focal length is optional. */
|
||||
export const CAMERA_TRACK_COLUMNS = Object.freeze([
|
||||
't',
|
||||
'lon',
|
||||
'lat',
|
||||
'alt',
|
||||
'heading',
|
||||
'pitch',
|
||||
'roll',
|
||||
'focalMm',
|
||||
]);
|
||||
|
||||
/** Expand one compact track sample into a pose; `t` stays in recorded seconds. */
|
||||
export function cameraTrackSample(sample) {
|
||||
const pose = {
|
||||
t: sample[0],
|
||||
lon: sample[1],
|
||||
lat: sample[2],
|
||||
alt: sample[3],
|
||||
heading: sample[4],
|
||||
pitch: sample[5],
|
||||
roll: sample[6],
|
||||
};
|
||||
if (Number.isFinite(sample[7])) pose.focalMm = sample[7];
|
||||
return pose;
|
||||
}
|
||||
|
||||
/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */
|
||||
export function resolveCameraMove(scene, shot) {
|
||||
if (!shot?.move) return null;
|
||||
if (shot.move.track) {
|
||||
const samples = shot.move.track.samples;
|
||||
return {
|
||||
from: sampleCameraTrack(samples, 0),
|
||||
to: sampleCameraTrack(samples, 1),
|
||||
track: samples,
|
||||
durationSec: shot.durationSec,
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: resolveCameraPose(scene, shot.move.from),
|
||||
to: resolveCameraPose(scene, shot.camera),
|
||||
@@ -69,57 +33,8 @@ export function normalizeCameraLongitude(longitude) {
|
||||
return ((((longitude + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a recorded track at normalized progress. The recording is stretched
|
||||
* to the shot duration, so editing `durationSec` retimes a take without
|
||||
* touching its samples. Angles and longitude take the short arc between
|
||||
* neighbouring samples.
|
||||
*/
|
||||
export function sampleCameraTrack(samples, progress) {
|
||||
const t = Math.max(0, Math.min(1, Number(progress) || 0));
|
||||
const last = samples.length - 1;
|
||||
const exact = (sample) => {
|
||||
const { t: _recorded, ...pose } = cameraTrackSample(sample);
|
||||
return pose;
|
||||
};
|
||||
if (t === 0) return exact(samples[0]);
|
||||
if (t === 1) return exact(samples[last]);
|
||||
const target = t * samples[last][0];
|
||||
let low = 0;
|
||||
let high = last;
|
||||
while (high - low > 1) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (samples[middle][0] <= target) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
const a = cameraTrackSample(samples[low]);
|
||||
const b = cameraTrackSample(samples[high]);
|
||||
const span = b.t - a.t;
|
||||
const k = span > 0 ? Math.max(0, Math.min(1, (target - a.t) / span)) : 1;
|
||||
// A time that lands on a recorded sample returns it unchanged.
|
||||
if (k === 0) return exact(samples[low]);
|
||||
if (k === 1) return exact(samples[high]);
|
||||
const lerp = (from, to) => from + (to - from) * k;
|
||||
const angle = (from, to) =>
|
||||
from + (((((to - from + 540) % 360) + 360) % 360) - 180) * k;
|
||||
const pose = {
|
||||
lat: lerp(a.lat, b.lat),
|
||||
lon: normalizeCameraLongitude(angle(a.lon, b.lon)),
|
||||
alt: lerp(a.alt, b.alt),
|
||||
heading: angle(a.heading, b.heading),
|
||||
pitch: lerp(a.pitch, b.pitch),
|
||||
roll: angle(a.roll, b.roll),
|
||||
};
|
||||
if (Number.isFinite(a.focalMm) && Number.isFinite(b.focalMm))
|
||||
pose.focalMm = lerp(a.focalMm, b.focalMm);
|
||||
else if (Number.isFinite(a.focalMm ?? b.focalMm))
|
||||
pose.focalMm = a.focalMm ?? b.focalMm;
|
||||
return pose;
|
||||
}
|
||||
|
||||
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
|
||||
export function sampleCameraMove(move, progress) {
|
||||
if (move.track) return sampleCameraTrack(move.track, progress);
|
||||
const t = Math.max(0, Math.min(1, Number(progress) || 0));
|
||||
if (t === 0) return { ...move.from };
|
||||
if (t === 1) return { ...move.to };
|
||||
|
||||
@@ -73,7 +73,7 @@ test('v3 unversioned bloom already uses scale 2 and is not migrated again in lat
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(project.version, 7);
|
||||
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', () => {
|
||||
|
||||
@@ -50,59 +50,6 @@ function pose(value, path, version, anchorIds, explicit = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Recorded focal lengths are 35 mm-equivalent; the range spans fisheye to supertele.
|
||||
export const FOCAL_LENGTH_MM = Object.freeze([4, 2000]);
|
||||
const TRACK_COLUMNS = [
|
||||
[-1, 86400],
|
||||
POSITION.lon,
|
||||
POSITION.lat,
|
||||
POSITION.alt,
|
||||
ORIENTATION.heading,
|
||||
ORIENTATION.pitch,
|
||||
ORIENTATION.roll,
|
||||
FOCAL_LENGTH_MM,
|
||||
];
|
||||
function track(value, path) {
|
||||
fields(value, path, ['altitudeReference', 'samples']);
|
||||
reference(value.altitudeReference, `${path}.altitudeReference`);
|
||||
array(value.samples, `${path}.samples`);
|
||||
if (value.samples.length < 2)
|
||||
fail(`${path}.samples`, 'expected at least two samples');
|
||||
let previous = -1;
|
||||
value.samples.forEach((sample, i) => {
|
||||
const at = `${path}.samples[${i}]`;
|
||||
if (!Array.isArray(sample) || sample.length < 7 || sample.length > 8)
|
||||
fail(at, 'expected [t, lon, lat, alt, heading, pitch, roll, focalMm?]');
|
||||
sample.forEach((item, column) =>
|
||||
number(item, `${at}[${column}]`, ...TRACK_COLUMNS[column], false),
|
||||
);
|
||||
if (i === 0 ? sample[0] !== 0 : sample[0] <= previous)
|
||||
fail(
|
||||
`${at}[0]`,
|
||||
i === 0
|
||||
? 'expected the first sample at 0 seconds'
|
||||
: 'expected increasing sample times',
|
||||
);
|
||||
previous = sample[0];
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate a shot lens: focal length and an optional depth-of-field focus. */
|
||||
export function validateShotLens(lens, path) {
|
||||
fields(lens, path, ['focalMm', 'dof']);
|
||||
optional(lens, 'focalMm', path, (v, p) =>
|
||||
number(v, p, ...FOCAL_LENGTH_MM, false),
|
||||
);
|
||||
optional(lens, 'dof', path, (dof, field) => {
|
||||
fields(dof, field, ['enabled', 'focusM', 'fStop', 'scale']);
|
||||
if (typeof dof.enabled !== 'boolean')
|
||||
fail(`${field}.enabled`, 'expected a boolean');
|
||||
optional(dof, 'focusM', field, (v, p) => number(v, p, 0.1, 1e8, false));
|
||||
optional(dof, 'fStop', field, (v, p) => number(v, p, 0.5, 64, false));
|
||||
optional(dof, 'scale', field, (v, p) => number(v, p, 1e-3, 1e7, false));
|
||||
});
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -126,14 +73,6 @@ export function validateSceneCameras(scene, path, version) {
|
||||
scene.shots.forEach((shot, index) => {
|
||||
const at = `${path}.shots[${index}]`;
|
||||
optional(shot, 'move', at, (move, field) => {
|
||||
if (version >= 7 && Object.hasOwn(move || {}, 'track')) {
|
||||
fields(move, field, ['track']);
|
||||
track(move.track, `${field}.track`);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
fields(move, field, ['from', 'easing']);
|
||||
if (!['linear', 'cubic-in-out'].includes(move.easing))
|
||||
fail(`${field}.easing`, 'expected linear or cubic-in-out');
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
jsonTree,
|
||||
} from './documentFields.js';
|
||||
import { validateSceneDataPacks } from './packs/manifest.js';
|
||||
import { validateSceneCameras, validateShotLens } from './cameraDocument.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 = 7;
|
||||
export const SCENE_DOCUMENT_VERSION = 6;
|
||||
|
||||
function visual(value, path, legacy) {
|
||||
fields(value, path, [
|
||||
@@ -67,7 +67,7 @@ export function validateSceneDocument(project) {
|
||||
'scenes',
|
||||
]);
|
||||
const version = Object.hasOwn(project, 'version') ? project.version : 1;
|
||||
if (![1, 2, 3, 4, 5, 6, 7].includes(version))
|
||||
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'])
|
||||
@@ -128,7 +128,6 @@ export function validateSceneDocument(project) {
|
||||
...(version >= 4 ? ['move'] : []),
|
||||
...(version >= 5 ? ['dataPackIds'] : []),
|
||||
...(version >= 6 ? ['interactions'] : []),
|
||||
...(version >= 7 ? ['lens'] : []),
|
||||
]);
|
||||
uniqueId(shot, at, shotIds);
|
||||
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
|
||||
@@ -141,7 +140,6 @@ export function validateSceneDocument(project) {
|
||||
);
|
||||
optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy));
|
||||
optional(shot, 'visual', at, (v, p) => visual(v, p, legacy));
|
||||
optional(shot, 'lens', at, validateShotLens);
|
||||
optional(shot, 'layers', at, (layers, field) => {
|
||||
object(layers, field);
|
||||
for (const [id, entry] of Object.entries(layers)) {
|
||||
|
||||
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
|
||||
const migrated = normalizeProject(
|
||||
parseSceneDocument(JSON.stringify(project)),
|
||||
);
|
||||
assert.equal(migrated.version, 7);
|
||||
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(
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Renderer-independent lens math. Focal lengths are 35 mm full-frame
|
||||
* equivalents measured across the frame's longer side (36 mm), which is the
|
||||
* dimension Cesium's `PerspectiveFrustum.fov` describes.
|
||||
*/
|
||||
export const SENSOR_LONG_SIDE_MM = 36;
|
||||
|
||||
/** Cesium's default 60-degree field of view expressed as a focal length. */
|
||||
export const DEFAULT_FOCAL_MM = SENSOR_LONG_SIDE_MM / 2 / Math.tan(Math.PI / 6);
|
||||
|
||||
/** Field of view in radians across the longer side for a focal length. */
|
||||
export function focalLengthToFov(focalMm) {
|
||||
return 2 * Math.atan(SENSOR_LONG_SIDE_MM / (2 * focalMm));
|
||||
}
|
||||
|
||||
/** Focal length in millimetres for a field of view across the longer side. */
|
||||
export function fovToFocalLength(fovRad) {
|
||||
return SENSOR_LONG_SIDE_MM / (2 * Math.tan(fovRad / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin-lens circle-of-confusion radius in pixels. `sceneScale` divides world
|
||||
* distances first, so a 1:1000 rig focuses on the city as if it were a
|
||||
* tabletop model and produces the matching shallow depth of field. The
|
||||
* depth-of-field shader evaluates the same expression per pixel.
|
||||
*/
|
||||
export function circleOfConfusionPx({
|
||||
focalMm,
|
||||
fStop,
|
||||
focusM,
|
||||
distanceM,
|
||||
sceneScale = 1,
|
||||
viewportPx,
|
||||
}) {
|
||||
const f = focalMm / 1000;
|
||||
const focus = Math.max(focusM / sceneScale, f * 1.01);
|
||||
const distance = Math.max(distanceM / sceneScale, 1e-4);
|
||||
const diameterM =
|
||||
((f * f) / (fStop * (focus - f))) * (Math.abs(distance - focus) / distance);
|
||||
return (0.5 * diameterM * 1000 * viewportPx) / SENSOR_LONG_SIDE_MM;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ test('v5 packs and shot references round trip through project migration without
|
||||
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
|
||||
),
|
||||
);
|
||||
assert.equal(old.version, 7);
|
||||
assert.equal(old.version, 6);
|
||||
assert.equal(old.scenes[0].dataPacks, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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,
|
||||
sampleCameraTrack,
|
||||
} from './camera.js';
|
||||
import { sceneSeekState } from './timeline.js';
|
||||
import {
|
||||
DEFAULT_FOCAL_MM,
|
||||
circleOfConfusionPx,
|
||||
focalLengthToFov,
|
||||
fovToFocalLength,
|
||||
} from './lens.js';
|
||||
|
||||
const samples = () => [
|
||||
[0, 179.9, 10, 500, 350, -30, 0, 24],
|
||||
[1, -179.9, 10.5, 600, 10, -20, 2, 50],
|
||||
[3, -179.7, 11, 400, 30, -10, 4, 50],
|
||||
];
|
||||
const fixture = (move = { track: { altitudeReference: 'ellipsoid', samples: samples() } }) => ({
|
||||
version: 7,
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene',
|
||||
shots: [
|
||||
{
|
||||
id: 'take',
|
||||
title: 'Take 1',
|
||||
durationSec: 3,
|
||||
holdSec: 1,
|
||||
camera: { lat: 11, lon: -179.7, alt: 400, heading: 30, pitch: -10, roll: 4, altitudeReference: 'ellipsoid' },
|
||||
move,
|
||||
lens: { focalMm: 50, dof: { enabled: true, focusM: 250, fStop: 1.8, scale: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test('a recorded track round trips through the document and project migration', () => {
|
||||
const project = normalizeProject(parseSceneDocument(JSON.stringify(fixture())));
|
||||
const shot = project.scenes[0].shots[0];
|
||||
assert.deepEqual(shot.move.track.samples, samples());
|
||||
assert.deepEqual(shot.lens, fixture().scenes[0].shots[0].lens);
|
||||
assert.deepEqual(parseSceneDocument(stringifySceneDocument(project)).scenes[0].shots[0].move, fixture().scenes[0].shots[0].move);
|
||||
});
|
||||
|
||||
test('track samples interpolate by recorded time, take short arcs and keep exact endpoints', () => {
|
||||
const rows = samples();
|
||||
assert.deepEqual(sampleCameraTrack(rows, 0), { lon: 179.9, lat: 10, alt: 500, heading: 350, pitch: -30, roll: 0, focalMm: 24 });
|
||||
assert.deepEqual(sampleCameraTrack(rows, 1), { lon: -179.7, lat: 11, alt: 400, heading: 30, pitch: -10, roll: 4, focalMm: 50 });
|
||||
const mid = sampleCameraTrack(rows, 1 / 6); // 0.5 s, halfway through the first segment
|
||||
assert.ok(Math.abs(Math.abs(mid.lon) - 180) < 1e-9, 'longitude crosses the dateline, not the globe');
|
||||
assert.ok(Math.abs(mid.heading - 360) < 1e-9 || Math.abs(mid.heading) < 1e-9);
|
||||
assert.equal(mid.alt, 550);
|
||||
assert.equal(mid.focalMm, 37);
|
||||
const late = sampleCameraTrack(rows, 2 / 3); // 2 s, halfway through the second segment
|
||||
assert.equal(late.alt, 500);
|
||||
assert.equal(late.heading, 20);
|
||||
const noFocal = sampleCameraTrack(rows.map((row) => row.slice(0, 7)), 0.5);
|
||||
assert.equal(Object.hasOwn(noFocal, 'focalMm'), false);
|
||||
});
|
||||
|
||||
test('a track stretches to its shot duration and drives scene seeking', () => {
|
||||
const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture()))).scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
const move = resolveCameraMove(scene, shot);
|
||||
assert.equal(move.from.alt, 500);
|
||||
assert.equal(move.to.alt, 400);
|
||||
assert.deepEqual(sampleCameraMove(move, 2 / 3), sampleCameraTrack(shot.move.track.samples, 2 / 3));
|
||||
// Doubling the duration plays the same path at half speed.
|
||||
shot.durationSec = 6;
|
||||
const seek = sceneSeekState(scene, 2 / 7, (_, item) => item.durationSec + item.holdSec, (_, item) => item.holdSec);
|
||||
assert.equal(seek.cameraProgress, 1 / 3);
|
||||
assert.equal(seek.camera.alt, 600);
|
||||
});
|
||||
|
||||
test('malformed tracks and lenses are rejected with a field path', () => {
|
||||
const bad = (mutate, pattern) => {
|
||||
const doc = fixture();
|
||||
mutate(doc.scenes[0].shots[0]);
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(doc)), pattern);
|
||||
};
|
||||
bad((shot) => { shot.move.track.samples = [shot.move.track.samples[0]]; }, /at least two samples/);
|
||||
bad((shot) => { shot.move.track.samples[0][0] = 0.1; }, /first sample at 0/);
|
||||
bad((shot) => { shot.move.track.samples[2][0] = 1; }, /increasing sample times/);
|
||||
bad((shot) => { shot.move.track.samples[1] = [1, 0, 0]; }, /samples\[1\]: expected \[t, lon/);
|
||||
bad((shot) => { shot.move.track.samples[1][2] = 91; }, /samples\[1\]\[2\]/);
|
||||
bad((shot) => { shot.move.track.samples[1][7] = 1; }, /samples\[1\]\[7\]/);
|
||||
bad((shot) => { delete shot.move.track.altitudeReference; }, /altitudeReference/);
|
||||
bad((shot) => { shot.move.easing = 'linear'; }, /move\.easing: unsupported field/);
|
||||
bad((shot) => { shot.lens.focalMm = 1; }, /lens\.focalMm/);
|
||||
bad((shot) => { shot.lens.dof.enabled = 'yes'; }, /lens\.dof\.enabled/);
|
||||
bad((shot) => { shot.lens.aperture = 2; }, /lens\.aperture: unsupported field/);
|
||||
const v6 = fixture();
|
||||
v6.version = 6;
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(v6)), /lens: unsupported field/);
|
||||
delete v6.scenes[0].shots[0].lens;
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(v6)), /move\.track: unsupported field/);
|
||||
});
|
||||
|
||||
test('focal lengths convert to Cesium field of view and back', () => {
|
||||
assert.ok(Math.abs(focalLengthToFov(DEFAULT_FOCAL_MM) - Math.PI / 3) < 1e-12);
|
||||
assert.ok(Math.abs(fovToFocalLength(focalLengthToFov(85)) - 85) < 1e-9);
|
||||
assert.ok(focalLengthToFov(14) > focalLengthToFov(200));
|
||||
});
|
||||
|
||||
test('the miniature scale turns invisible real-world blur into tabletop depth of field', () => {
|
||||
const lens = { focalMm: 35, fStop: 2.8, focusM: 500, distanceM: 1000, viewportPx: 1920 };
|
||||
assert.ok(circleOfConfusionPx(lens) < 0.05, 'a real 35 mm lens barely blurs a city');
|
||||
const miniature = circleOfConfusionPx({ ...lens, sceneScale: 1000 });
|
||||
assert.ok(miniature > 10 && miniature < 20, `1:1000 blurs like a model (${miniature})`);
|
||||
assert.equal(circleOfConfusionPx({ ...lens, distanceM: 500, sceneScale: 1000 }), 0);
|
||||
assert.ok(circleOfConfusionPx({ ...lens, sceneScale: 1000, fStop: 16 }) < miniature / 4);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { localProviderPlugins } from '../server/providers/local.js';
|
||||
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
|
||||
import {
|
||||
KEY_SETUP_KEYS,
|
||||
keySetupStatus,
|
||||
validateKeySetupUpdates,
|
||||
upsertDotenvValues,
|
||||
} from './keySetupCore.mjs';
|
||||
|
||||
test('local hosts opt into Gemini explicitly and retain the default plugin order', (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('construction must not fetch');
|
||||
});
|
||||
const defaults = localProviderPlugins().map((plugin) => plugin.name);
|
||||
assert.ok(!defaults.includes('gemini-live-proxy'));
|
||||
const enabled = localProviderPlugins({ gemini: {} }).map(
|
||||
(plugin) => plugin.name,
|
||||
);
|
||||
assert.equal(
|
||||
enabled.filter((name) => name === 'gemini-live-proxy').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
enabled.filter((name) => name !== 'gemini-live-proxy'),
|
||||
defaults,
|
||||
);
|
||||
});
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`opted-in ${hook} locks the standalone catalog into the Gemini token`, async () => {
|
||||
const tools = standaloneVoiceTools();
|
||||
const calls = [];
|
||||
const plugin = localProviderPlugins({
|
||||
gemini: {
|
||||
tools,
|
||||
resolveApiKey: () => 'fixture-key',
|
||||
resolveModel: () => 'gemini-3.8-live',
|
||||
resolveHost: () => '',
|
||||
resolveAllowedHosts: () => '',
|
||||
resolveRateLimit: () => '0',
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json({ name: 'auth_tokens/fixture' });
|
||||
},
|
||||
},
|
||||
}).find((entry) => entry.name === 'gemini-live-proxy');
|
||||
let handler;
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(route, value) {
|
||||
assert.equal(route, '/api/gemini/token');
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
const req = new PassThrough();
|
||||
req.method = 'POST';
|
||||
req.headers = {
|
||||
host: 'localhost:5173',
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
req.socket = { remoteAddress: '127.0.0.1' };
|
||||
const res = new EventEmitter();
|
||||
res.setHeader = () => {};
|
||||
res.end = (body) => {
|
||||
res.body = JSON.parse(body);
|
||||
res.writableEnded = true;
|
||||
};
|
||||
req.end('{}');
|
||||
await handler(req, res);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
const locked = JSON.parse(calls[0].options.body).bidiGenerateContentSetup
|
||||
.tools[0].functionDeclarations;
|
||||
assert.deepEqual(
|
||||
locked.map(({ name }) => name),
|
||||
tools.map(({ name }) => name),
|
||||
);
|
||||
assert.deepEqual(
|
||||
locked.map(({ parametersJsonSchema }) => parametersJsonSchema),
|
||||
tools.map(({ parameters }) => parameters),
|
||||
);
|
||||
assert.ok(locked.every(({ behavior }) => behavior === 'BLOCKING'));
|
||||
assert.deepEqual(res.body.config.tools[0].functionDeclarations, locked);
|
||||
assert.ok(!JSON.stringify(res.body).includes('fixture-key'));
|
||||
});
|
||||
}
|
||||
|
||||
test('Gemini key setup uses the existing server-only status and save contract', () => {
|
||||
const entry = KEY_SETUP_KEYS.find(({ id }) => id === 'gemini');
|
||||
assert.deepEqual(entry.envVars, ['GEMINI_API_KEY']);
|
||||
assert.ok(!entry.clientExposed);
|
||||
assert.equal(
|
||||
keySetupStatus({}).keys.find(({ id }) => id === 'gemini').set,
|
||||
false,
|
||||
);
|
||||
const env = { GEMINI_API_KEY: 'fixture-private-key' };
|
||||
const status = keySetupStatus(env);
|
||||
assert.equal(status.keys.find(({ id }) => id === 'gemini').set, true);
|
||||
assert.ok(!JSON.stringify(status).includes(env.GEMINI_API_KEY));
|
||||
assert.equal(validateKeySetupUpdates(env).ok, true);
|
||||
assert.match(
|
||||
upsertDotenvValues('GEMINI_API_KEY=old\n', env),
|
||||
/^GEMINI_API_KEY=fixture-private-key\n$/,
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ VITE_GEMINI_API_KEY: 'fixture-private-key' }).ok,
|
||||
false,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+61
-61
@@ -842,7 +842,8 @@
|
||||
"Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave se usa una simulación)",
|
||||
"Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial",
|
||||
"More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)",
|
||||
"Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "El acceso público a misiones espaciales no necesita clave. Déjalo en blanco para el uso estándar; los tokens de pago solo aumentan los límites de solicitudes.",
|
||||
"Optional paid token — public access works without one": "Token opcional de pago — el acceso público funciona sin él",
|
||||
"Scene action failed": "La acción de escena falló",
|
||||
"Project exported": "Proyecto exportado",
|
||||
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
|
||||
@@ -907,9 +908,7 @@
|
||||
"Red": "Rojo",
|
||||
"Clear": "Borrar",
|
||||
"Remove every mark from the board": "Quitar todas las marcas del tablero",
|
||||
"Point the camera at the globe for street-level coverage": "Apunta la cámara al globo para ver la cobertura a pie de calle",
|
||||
"No street-level imagery providers configured": "No hay proveedores de imágenes a pie de calle configurados",
|
||||
"Selected": "Seleccionada",
|
||||
"Live Vessels": "Buques en vivo",
|
||||
"Bike Share": "Bicis compartidas",
|
||||
"Cameras": "Cámaras",
|
||||
@@ -919,7 +918,6 @@
|
||||
"Active Fires": "Incendios activos",
|
||||
"Street Level could not start": "No se pudo iniciar A pie de calle",
|
||||
"Street Level toggle failed": "No se pudo activar o desactivar la capa A pie de calle",
|
||||
"Street-level image": "Imagen a pie de calle",
|
||||
"SHRINK": "REDUCIR",
|
||||
"EXPAND": "AMPLIAR",
|
||||
"Shrink": "Reducir",
|
||||
@@ -953,9 +951,6 @@
|
||||
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
|
||||
"other": "{n} calles a la vista · haz clic en una línea para su foto más cercana"
|
||||
},
|
||||
"Camera follows view: move the globe camera wherever the street-level view looks": "La cámara sigue la vista: mueve la cámara del globo hacia donde mira la vista a pie de calle",
|
||||
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Seguir con la cámara requiere el mapa Google 3D: elige Google 3D en FUENTE DEL MAPA",
|
||||
"Switch a provider on to draw its coverage.": "Activa un proveedor para dibujar su cobertura.",
|
||||
"Loading this sequence…": "Cargando esta secuencia…",
|
||||
"{n} images in this sequence · Esc clears": {
|
||||
"one": "{n} imagen en esta secuencia · Esc quita la selección",
|
||||
@@ -968,14 +963,11 @@
|
||||
"other": "{n} secuencias a la vista · haz clic en una línea para ver sus fotos"
|
||||
},
|
||||
"STREET LEVEL": "A PIE DE CALLE",
|
||||
"Expand the street-level view (Esc shrinks it)": "Ampliar la vista a pie de calle (Esc la reduce)",
|
||||
"Image fit": "Ajuste de la imagen",
|
||||
"FIT": "AJUSTAR",
|
||||
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: muestra la foto entera, con barras oscuras donde su forma no coincide con el marco",
|
||||
"FILL": "RELLENAR",
|
||||
"Fill: fill the frame edge to edge, cropping the photo": "Rellenar: llena el marco de borde a borde, recortando la foto",
|
||||
"Camera follows view": "La cámara sigue la vista",
|
||||
"FOLLOW": "SEGUIR",
|
||||
"Close the image and deselect it on the map": "Cerrar la imagen y quitar la selección en el mapa",
|
||||
"Close the street-level image": "Cerrar la imagen a pie de calle",
|
||||
"Street-level viewer": "Visor a pie de calle",
|
||||
@@ -988,7 +980,6 @@
|
||||
"ALL": "TODAS",
|
||||
"FLAT": "PLANAS",
|
||||
"SINCE": "DESDE",
|
||||
"Coverage colours": "Colores de cobertura",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
|
||||
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
|
||||
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
|
||||
@@ -1505,7 +1496,6 @@
|
||||
"DISCONNECT": "DESCONECTAR",
|
||||
"CONNECT": "CONECTAR",
|
||||
"LOCATING…": "UBICANDO…",
|
||||
"Drag to resize · double-click the header to snap back": "Arrastra para cambiar el tamaño · doble clic en el encabezado para restaurarlo",
|
||||
"CH {index} / {total}": "CANAL {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
|
||||
"Clear reading": "Borrar lectura",
|
||||
@@ -2442,7 +2432,6 @@
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
|
||||
"New scene name": "Nombre de la nueva escena",
|
||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||
@@ -2890,52 +2879,63 @@
|
||||
"SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido",
|
||||
"SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido",
|
||||
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido",
|
||||
"Held from the phone": "En pausa desde el teléfono",
|
||||
"Held: you moved the globe — press RESUME or tap HOLD on the phone": "En pausa: moviste el globo — pulsa REANUDAR o toca PAUSA en el teléfono",
|
||||
"Held": "En pausa",
|
||||
"Held: another view took the camera": "En pausa: otra vista tomó la cámara",
|
||||
"Starting…": "Iniciando…",
|
||||
"Phone camera off": "Cámara del teléfono apagada",
|
||||
"Scan the code with a phone on the same Wi-Fi": "Escanea el código con un teléfono en la misma red Wi-Fi",
|
||||
"AR tracking (6DoF)": "Seguimiento AR (6DoF)",
|
||||
"Gyro tracking (3DoF)": "Seguimiento por giroscopio (3DoF)",
|
||||
"Recording {time} · {mode}": "Grabando {time} · {mode}",
|
||||
"Phone connected · {mode} · {count} takes": {
|
||||
"one": "Teléfono conectado · {mode} · {count} toma",
|
||||
"many": "Teléfono conectado · {mode} · {count} tomas",
|
||||
"other": "Teléfono conectado · {mode} · {count} tomas"
|
||||
},
|
||||
"Phone connected · {mode}": "Teléfono conectado · {mode}",
|
||||
"HOLD": "PAUSA",
|
||||
"■ STOP": "■ DETENER",
|
||||
"● REC": "● GRABAR",
|
||||
"The phone camera service is unavailable": "El servicio de cámara del teléfono no está disponible",
|
||||
"The phone camera port is in use (set VCAM_PORT)": "El puerto de la cámara del teléfono está en uso (configura VCAM_PORT)",
|
||||
"No network connection for a phone to reach": "No hay conexión de red a la que pueda llegar un teléfono",
|
||||
"Phone camera session ended": "Sesión de cámara del teléfono finalizada",
|
||||
"Nothing to focus on there": "No hay nada que enfocar ahí",
|
||||
"Take reached its length limit and was saved": "La toma alcanzó su duración máxima y se guardó",
|
||||
"Take saved to the scene": "Toma guardada en la escena",
|
||||
"Take too short to keep": "Toma demasiado corta para guardarla",
|
||||
"Recorded: {scene} / {shot}": "Grabado: {scene} / {shot}",
|
||||
"Use a phone on this network as a virtual camera": "Usa un teléfono de esta red como cámara virtual",
|
||||
"Phone virtual camera": "Cámara virtual del teléfono",
|
||||
"Pairing QR code": "Código QR de vinculación",
|
||||
"SCAN WITH YOUR PHONE": "ESCANEA CON TU TELÉFONO",
|
||||
"Network address": "Dirección de red",
|
||||
"SCALE": "ESCALA",
|
||||
"1:1 WALK": "1:1 A PIE",
|
||||
"1:10 CRANE": "1:10 GRÚA",
|
||||
"1:100 DRONE": "1:100 DRON",
|
||||
"1:1000 FLYOVER": "1:1000 SOBREVUELO",
|
||||
"1:10000 ORBIT": "1:10000 ÓRBITA",
|
||||
"LENS": "LENTE",
|
||||
"DEPTH OF FIELD": "PROFUNDIDAD DE CAMPO",
|
||||
"APERTURE": "APERTURA",
|
||||
"RECENTER": "RECENTRAR",
|
||||
"END": "TERMINAR",
|
||||
"Takes become shots in the selected scene.": "Las tomas se convierten en planos de la escena seleccionada.",
|
||||
"Phone certificate": "Certificado del teléfono",
|
||||
"VCAM": "VCAM",
|
||||
"Exit cockpit to use the phone camera": "Sal de la cabina para usar la cámara del teléfono"
|
||||
"Zoom in to see street-level coverage": "Acércate para ver la cobertura a pie de calle",
|
||||
"Point the camera at the ground for street-level coverage": "Apunta la cámara al suelo para ver la cobertura a pie de calle",
|
||||
"Sequence images unavailable": "Las imágenes de la secuencia no están disponibles",
|
||||
"Switch Street Level on to draw its coverage.": "Activa A pie de calle para dibujar su cobertura.",
|
||||
"Every provider is switched off: light a chip to draw its coverage.": "Todos los proveedores están apagados: enciende uno para dibujar su cobertura.",
|
||||
"No sequences in view match the filter": "Ninguna secuencia a la vista coincide con el filtro",
|
||||
"No coverage in view": "No hay cobertura a la vista",
|
||||
"Show the street-level view full screen (Esc shrinks it)": "Mostrar la vista a pie de calle a pantalla completa (Esc la reduce)",
|
||||
"Gemini returned an oversized audio chunk": "Gemini devolvió un fragmento de audio demasiado grande",
|
||||
"Gemini returned an unsupported audio format": "Gemini devolvió un formato de audio no compatible",
|
||||
"Gemini returned an invalid audio sample rate": "Gemini devolvió una frecuencia de muestreo de audio no válida",
|
||||
"Gemini returned incomplete PCM audio": "Gemini devolvió audio PCM incompleto",
|
||||
"This browser does not support Gemini microphone audio": "Este navegador no admite audio del micrófono para Gemini",
|
||||
"Microphone audio processing failed": "Falló el procesamiento del audio del micrófono",
|
||||
"Gemini audio playback fell behind. Reconnect to continue.": "La reproducción de audio de Gemini se ha retrasado. Vuelve a conectar para continuar.",
|
||||
"Gemini token response did not include an ephemeral credential": "La respuesta de Gemini no incluyó una credencial temporal",
|
||||
"Invalid Gemini connection endpoint": "El destino de conexión de Gemini no es válido",
|
||||
"Voice command context is too large": "El contexto del comando de voz es demasiado grande",
|
||||
"Gemini reliable transport exceeded its queue limit. Start voice again.": "La cola de envío fiable de Gemini superó su límite. Vuelve a iniciar la voz.",
|
||||
"Gemini reliable transport did not drain. Start voice again.": "La cola de envío fiable de Gemini no se vació. Vuelve a iniciar la voz.",
|
||||
"Gemini token request failed (HTTP {status})": "Falló la solicitud de credencial de Gemini (HTTP {status})",
|
||||
"Gemini token response did not include a valid model": "La respuesta de Gemini no incluyó un modelo válido",
|
||||
"Gemini token response did not include session settings": "La respuesta de Gemini no incluyó la configuración de la sesión",
|
||||
"Gemini session settings do not match the input mode": "La configuración de la sesión de Gemini no coincide con el modo de entrada",
|
||||
"Gemini could not submit this input. Start voice again.": "Gemini no pudo enviar esta entrada. Vuelve a iniciar la voz.",
|
||||
"Connecting to Gemini": "Conectando con Gemini",
|
||||
"Gemini returned an oversized message": "Gemini devolvió un mensaje demasiado grande",
|
||||
"Gemini Live connection failed. Check network access, then start voice again.": "Falló la conexión con Gemini Live. Comprueba el acceso a la red y vuelve a iniciar la voz.",
|
||||
"Gemini Live connection ended. Start voice again for a new conversation.": "La conexión con Gemini Live terminó. Vuelve a iniciar la voz para una nueva conversación.",
|
||||
"Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live no pudo iniciarse. Vuelve a iniciar la voz para solicitar una nueva sesión.",
|
||||
"Start voice again for a fresh token and a new conversation.": "Vuelve a iniciar la voz para obtener una credencial nueva y comenzar otra conversación.",
|
||||
"Start voice again to request a fresh session credential.": "Vuelve a iniciar la voz para solicitar una nueva credencial de sesión.",
|
||||
"Microphone permission was denied. Allow microphone access and try again.": "Se denegó el permiso del micrófono. Permite el acceso al micrófono e inténtalo de nuevo.",
|
||||
"Gemini connection timed out. Try again.": "Se agotó el tiempo de conexión con Gemini. Inténtalo de nuevo.",
|
||||
"Gemini voice could not start": "No se pudo iniciar la voz de Gemini",
|
||||
"Gemini did not finish the interrupted reply. Start voice again.": "Gemini no terminó la respuesta interrumpida. Vuelve a iniciar la voz.",
|
||||
"Gemini session expiring; finishing the current reply": "La sesión de Gemini va a caducar; terminando la respuesta actual",
|
||||
"Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini rechazó la sesión de voz. Vuelve a iniciar la voz para solicitar una nueva sesión.",
|
||||
"Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Vuelve a iniciar la voz para obtener una nueva credencial de sesión. Si se repite, comprueba el modelo de Gemini y el acceso a la API.",
|
||||
"Gemini action history is full. Start voice again for a fresh session.": "El historial de acciones de Gemini está lleno. Vuelve a iniciar la voz para una nueva sesión.",
|
||||
"Gemini requested too many simultaneous actions": "Gemini solicitó demasiadas acciones simultáneas",
|
||||
"Voice text command is too long": "El comando de voz escrito es demasiado largo",
|
||||
"Gemini voice is not connected": "La voz de Gemini no está conectada",
|
||||
"Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "La entrada interrumpida de Gemini superó el límite de búfer o de espera. Vuelve a iniciar la voz.",
|
||||
"Gemini Live connection is expiring. Start voice again for a new conversation.": "La conexión con Gemini Live va a caducar. Vuelve a iniciar la voz para una nueva conversación.",
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Las sesiones de voz de Gemini tienen un límite de tiempo. Cuando caduque la sesión, vuelve a iniciar la voz para una nueva conversación.",
|
||||
"Gemini Live voice control": "Control por voz con Gemini Live",
|
||||
"Voice provider": "Proveedor de voz",
|
||||
"Changing provider reconnects voice if it is on.": "Cambiar de proveedor vuelve a conectar la voz si está activada.",
|
||||
"Gemini voice could not connect. Try again.": "No se pudo conectar la voz de Gemini. Inténtalo de nuevo.",
|
||||
"Gemini voice requires a same-origin app request": "La voz de Gemini requiere una solicitud del mismo origen que la aplicación",
|
||||
"Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en los ajustes de proveedores",
|
||||
"The Gemini Live model configuration is invalid": "La configuración del modelo de Gemini Live no es válida",
|
||||
"Gemini voice is starting too often. Try again shortly.": "La voz de Gemini se está iniciando con demasiada frecuencia. Vuelve a intentarlo en breve.",
|
||||
"Gemini quota is unavailable. Try again later.": "La cuota de Gemini no está disponible. Inténtalo más tarde.",
|
||||
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
||||
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío"
|
||||
}
|
||||
+61
-62
@@ -74,7 +74,8 @@
|
||||
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
|
||||
"More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)",
|
||||
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token).",
|
||||
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Общедоступные данные космических миссий не требуют ключа. Для обычного использования оставьте поле пустым; платные токены только повышают лимиты запросов.",
|
||||
"Optional paid token — public access works without one": "Необязательный платный токен — публичный доступ работает без него",
|
||||
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
|
||||
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
|
||||
"Live ships, worldwide": "Суда в реальном времени по всему миру",
|
||||
@@ -915,9 +916,7 @@
|
||||
"Red": "Красный",
|
||||
"Clear": "Очистить",
|
||||
"Remove every mark from the board": "Убрать все пометки с доски",
|
||||
"Point the camera at the globe for street-level coverage": "Наведите камеру на глобус, чтобы увидеть покрытие уличных снимков",
|
||||
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
|
||||
"Selected": "Выбрано",
|
||||
"Live Vessels": "Суда онлайн",
|
||||
"Bike Share": "Прокат велосипедов",
|
||||
"Cameras": "Камеры",
|
||||
@@ -927,7 +926,6 @@
|
||||
"Active Fires": "Активные пожары",
|
||||
"Street Level could not start": "Не удалось запустить «Уличные снимки»",
|
||||
"Street Level toggle failed": "Не удалось переключить «Уличные снимки»",
|
||||
"Street-level image": "Уличный снимок",
|
||||
"SHRINK": "СВЕРНУТЬ",
|
||||
"EXPAND": "РАЗВЕРНУТЬ",
|
||||
"Shrink": "Свернуть",
|
||||
@@ -963,9 +961,6 @@
|
||||
"many": "{n} улиц в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||
"other": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию"
|
||||
},
|
||||
"Camera follows view: move the globe camera wherever the street-level view looks": "Камера следует за видом: камера глобуса поворачивается туда, куда смотрит уличный снимок",
|
||||
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Слежению камеры нужна карта Google 3D: выберите Google 3D в разделе «ИСТОЧНИК КАРТЫ»",
|
||||
"Switch a provider on to draw its coverage.": "Включите источник, чтобы показать его покрытие.",
|
||||
"Loading this sequence…": "Загрузка последовательности…",
|
||||
"{n} images in this sequence · Esc clears": {
|
||||
"one": "{n} снимок в последовательности · Esc — сбросить",
|
||||
@@ -980,14 +975,11 @@
|
||||
"other": "{n} последовательности в кадре · щёлкните по линии, чтобы открыть снимки"
|
||||
},
|
||||
"STREET LEVEL": "УЛИЧНЫЕ СНИМКИ",
|
||||
"Expand the street-level view (Esc shrinks it)": "Развернуть уличный снимок (Esc сворачивает)",
|
||||
"Image fit": "Масштаб снимка",
|
||||
"FIT": "ВПИСАТЬ",
|
||||
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Вписать: показать снимок целиком, с тёмными полосами там, где его форма не совпадает с рамкой",
|
||||
"FILL": "ЗАПОЛНИТЬ",
|
||||
"Fill: fill the frame edge to edge, cropping the photo": "Заполнить: заполнить рамку целиком, обрезав края снимка",
|
||||
"Camera follows view": "Камера следует за видом",
|
||||
"FOLLOW": "СЛЕДИТЬ",
|
||||
"Close the image and deselect it on the map": "Закрыть снимок и снять выделение на карте",
|
||||
"Close the street-level image": "Закрыть уличный снимок",
|
||||
"Street-level viewer": "Просмотр уличных снимков",
|
||||
@@ -1000,7 +992,6 @@
|
||||
"ALL": "ВСЕ",
|
||||
"FLAT": "ПЛОСКИЕ",
|
||||
"SINCE": "С ДАТЫ",
|
||||
"Coverage colours": "Цвета покрытия",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
|
||||
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
|
||||
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
|
||||
@@ -1530,7 +1521,6 @@
|
||||
"DISCONNECT": "ОТКЛЮЧИТЬ",
|
||||
"CONNECT": "ПОДКЛЮЧИТЬ",
|
||||
"LOCATING…": "ОПРЕДЕЛЯЮ…",
|
||||
"Drag to resize · double-click the header to snap back": "Перетащите, чтобы изменить размер · двойной щелчок по заголовку — вернуть на место",
|
||||
"CH {index} / {total}": "КАН. {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
|
||||
"Clear reading": "Очистить показания",
|
||||
@@ -2483,7 +2473,6 @@
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
|
||||
"New scene name": "Название новой сцены",
|
||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||
@@ -2957,53 +2946,63 @@
|
||||
"SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен",
|
||||
"SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен",
|
||||
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен",
|
||||
"Held from the phone": "Пауза с телефона",
|
||||
"Held: you moved the globe — press RESUME or tap HOLD on the phone": "Пауза: вы сдвинули глобус — нажмите ПРОДОЛЖИТЬ или ПАУЗА на телефоне",
|
||||
"Held": "Пауза",
|
||||
"Held: another view took the camera": "Пауза: камеру занял другой вид",
|
||||
"Starting…": "Запуск…",
|
||||
"Phone camera off": "Камера телефона выключена",
|
||||
"Scan the code with a phone on the same Wi-Fi": "Отсканируйте код телефоном в той же сети Wi-Fi",
|
||||
"AR tracking (6DoF)": "AR-трекинг (6DoF)",
|
||||
"Gyro tracking (3DoF)": "Гироскоп (3DoF)",
|
||||
"Recording {time} · {mode}": "Запись {time} · {mode}",
|
||||
"Phone connected · {mode} · {count} takes": {
|
||||
"one": "Телефон подключён · {mode} · {count} дубль",
|
||||
"few": "Телефон подключён · {mode} · {count} дубля",
|
||||
"many": "Телефон подключён · {mode} · {count} дублей",
|
||||
"other": "Телефон подключён · {mode} · {count} дубля"
|
||||
},
|
||||
"Phone connected · {mode}": "Телефон подключён · {mode}",
|
||||
"HOLD": "ПАУЗА",
|
||||
"■ STOP": "■ СТОП",
|
||||
"● REC": "● ЗАПИСЬ",
|
||||
"The phone camera service is unavailable": "Служба камеры телефона недоступна",
|
||||
"The phone camera port is in use (set VCAM_PORT)": "Порт камеры телефона занят (задайте VCAM_PORT)",
|
||||
"No network connection for a phone to reach": "Нет сетевого подключения, доступного телефону",
|
||||
"Phone camera session ended": "Сеанс камеры телефона завершён",
|
||||
"Nothing to focus on there": "Здесь не на чем сфокусироваться",
|
||||
"Take reached its length limit and was saved": "Дубль достиг предельной длины и сохранён",
|
||||
"Take saved to the scene": "Дубль сохранён в сцену",
|
||||
"Take too short to keep": "Дубль слишком короткий",
|
||||
"Recorded: {scene} / {shot}": "Записано: {scene} / {shot}",
|
||||
"Use a phone on this network as a virtual camera": "Использовать телефон в этой сети как виртуальную камеру",
|
||||
"Phone virtual camera": "Виртуальная камера телефона",
|
||||
"Pairing QR code": "QR-код для подключения",
|
||||
"SCAN WITH YOUR PHONE": "ОТСКАНИРУЙТЕ ТЕЛЕФОНОМ",
|
||||
"Network address": "Сетевой адрес",
|
||||
"SCALE": "МАСШТАБ",
|
||||
"1:1 WALK": "1:1 ПЕШКОМ",
|
||||
"1:10 CRANE": "1:10 КРАН",
|
||||
"1:100 DRONE": "1:100 ДРОН",
|
||||
"1:1000 FLYOVER": "1:1000 ПРОЛЁТ",
|
||||
"1:10000 ORBIT": "1:10000 ОРБИТА",
|
||||
"LENS": "ОБЪЕКТИВ",
|
||||
"DEPTH OF FIELD": "ГЛУБИНА РЕЗКОСТИ",
|
||||
"APERTURE": "ДИАФРАГМА",
|
||||
"RECENTER": "ЦЕНТРИРОВАТЬ",
|
||||
"END": "ЗАВЕРШИТЬ",
|
||||
"Takes become shots in the selected scene.": "Дубли становятся кадрами выбранной сцены.",
|
||||
"Phone certificate": "Сертификат для телефона",
|
||||
"VCAM": "VCAM",
|
||||
"Exit cockpit to use the phone camera": "Выйдите из кабины, чтобы использовать камеру телефона"
|
||||
"Zoom in to see street-level coverage": "Приблизьтесь, чтобы увидеть покрытие уличных снимков",
|
||||
"Point the camera at the ground for street-level coverage": "Направьте камеру на землю, чтобы увидеть покрытие уличных снимков",
|
||||
"Sequence images unavailable": "Снимки последовательности недоступны",
|
||||
"Switch Street Level on to draw its coverage.": "Включите «Уличные снимки», чтобы показать покрытие.",
|
||||
"Every provider is switched off: light a chip to draw its coverage.": "Все источники выключены: включите один, чтобы показать его покрытие.",
|
||||
"No sequences in view match the filter": "Ни одна последовательность в кадре не подходит под фильтр",
|
||||
"No coverage in view": "В кадре нет покрытия",
|
||||
"Show the street-level view full screen (Esc shrinks it)": "Показать уличный снимок на весь экран (Esc сворачивает)",
|
||||
"Gemini returned an oversized audio chunk": "Gemini вернул слишком большой фрагмент аудио",
|
||||
"Gemini returned an unsupported audio format": "Gemini вернул неподдерживаемый формат аудио",
|
||||
"Gemini returned an invalid audio sample rate": "Gemini вернул недопустимую частоту дискретизации аудио",
|
||||
"Gemini returned incomplete PCM audio": "Gemini вернул неполные аудиоданные PCM",
|
||||
"This browser does not support Gemini microphone audio": "Этот браузер не поддерживает звук с микрофона для Gemini",
|
||||
"Microphone audio processing failed": "Не удалось обработать звук с микрофона",
|
||||
"Gemini audio playback fell behind. Reconnect to continue.": "Воспроизведение звука Gemini отстаёт. Подключитесь заново, чтобы продолжить.",
|
||||
"Gemini token response did not include an ephemeral credential": "В ответе Gemini нет временных учётных данных",
|
||||
"Invalid Gemini connection endpoint": "Недопустимый адрес подключения к Gemini",
|
||||
"Voice command context is too large": "Контекст голосовой команды слишком велик",
|
||||
"Gemini reliable transport exceeded its queue limit. Start voice again.": "Очередь надёжной передачи Gemini превысила лимит. Запустите голосовой режим заново.",
|
||||
"Gemini reliable transport did not drain. Start voice again.": "Очередь надёжной передачи Gemini не освободилась. Запустите голосовой режим заново.",
|
||||
"Gemini token request failed (HTTP {status})": "Не удалось запросить токен Gemini (HTTP {status})",
|
||||
"Gemini token response did not include a valid model": "В ответе Gemini нет допустимой модели",
|
||||
"Gemini token response did not include session settings": "В ответе Gemini нет настроек сеанса",
|
||||
"Gemini session settings do not match the input mode": "Настройки сеанса Gemini не соответствуют режиму ввода",
|
||||
"Gemini could not submit this input. Start voice again.": "Gemini не удалось отправить эти данные. Запустите голосовой режим заново.",
|
||||
"Connecting to Gemini": "Подключение к Gemini",
|
||||
"Gemini returned an oversized message": "Gemini вернул слишком большое сообщение",
|
||||
"Gemini Live connection failed. Check network access, then start voice again.": "Не удалось подключиться к Gemini Live. Проверьте доступ к сети и запустите голосовой режим заново.",
|
||||
"Gemini Live connection ended. Start voice again for a new conversation.": "Соединение с Gemini Live завершено. Запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini Live could not start. Start voice again to request a fresh session.": "Не удалось запустить Gemini Live. Запустите голосовой режим заново, чтобы запросить новый сеанс.",
|
||||
"Start voice again for a fresh token and a new conversation.": "Запустите голосовой режим заново, чтобы получить новый токен и начать новый разговор.",
|
||||
"Start voice again to request a fresh session credential.": "Запустите голосовой режим заново, чтобы запросить новые учётные данные сеанса.",
|
||||
"Microphone permission was denied. Allow microphone access and try again.": "Доступ к микрофону запрещён. Разрешите доступ и повторите попытку.",
|
||||
"Gemini connection timed out. Try again.": "Время ожидания подключения к Gemini истекло. Повторите попытку.",
|
||||
"Gemini voice could not start": "Не удалось запустить голосовой режим Gemini",
|
||||
"Gemini did not finish the interrupted reply. Start voice again.": "Gemini не завершил прерванный ответ. Запустите голосовой режим заново.",
|
||||
"Gemini session expiring; finishing the current reply": "Сеанс Gemini истекает; завершается текущий ответ",
|
||||
"Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini отклонил голосовой сеанс. Запустите голосовой режим заново, чтобы запросить новый сеанс.",
|
||||
"Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Запустите голосовой режим заново для получения новых учётных данных. Если ошибка повторится, проверьте модель Gemini и доступ к API.",
|
||||
"Gemini action history is full. Start voice again for a fresh session.": "История действий Gemini заполнена. Запустите голосовой режим заново для нового сеанса.",
|
||||
"Gemini requested too many simultaneous actions": "Gemini запросил слишком много одновременных действий",
|
||||
"Voice text command is too long": "Текст голосовой команды слишком длинный",
|
||||
"Gemini voice is not connected": "Голосовой режим Gemini не подключён",
|
||||
"Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Прерванный ввод Gemini превысил лимит буфера или ожидания. Запустите голосовой режим заново.",
|
||||
"Gemini Live connection is expiring. Start voice again for a new conversation.": "Соединение с Gemini Live истекает. Запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Время голосовых сеансов Gemini ограничено. Когда сеанс истечёт, запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini Live voice control": "Голосовое управление через Gemini Live",
|
||||
"Voice provider": "Поставщик голосового сервиса",
|
||||
"Changing provider reconnects voice if it is on.": "Смена поставщика переподключает голосовой режим, если он включён.",
|
||||
"Gemini voice could not connect. Try again.": "Не удалось подключить голосовой режим Gemini. Повторите попытку.",
|
||||
"Gemini voice requires a same-origin app request": "Голосовой режим Gemini требует запрос из того же источника, что и приложение",
|
||||
"Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в настройках поставщиков",
|
||||
"The Gemini Live model configuration is invalid": "Недопустимая конфигурация модели Gemini Live",
|
||||
"Gemini voice is starting too often. Try again shortly.": "Голосовой режим Gemini запускается слишком часто. Повторите попытку чуть позже.",
|
||||
"Gemini quota is unavailable. Try again later.": "Квота Gemini недоступна. Повторите попытку позже.",
|
||||
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
||||
"Token request is too large": "Запрос токена слишком велик",
|
||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON"
|
||||
}
|
||||
@@ -89,7 +89,6 @@
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"VCAM",
|
||||
"VISUAL",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
@@ -132,7 +131,6 @@
|
||||
"NVG",
|
||||
"R&B",
|
||||
"REC",
|
||||
"VCAM",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
]
|
||||
|
||||
+4
-2
@@ -215,7 +215,7 @@ export function stripKeylessBasemapFromHash(hash) {
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
|
||||
|
||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const mode = readStoredCloudVoiceAuthMode();
|
||||
@@ -259,7 +259,9 @@ function buildRow(documentRef, key) {
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
: key.tier === 'paid'
|
||||
? t('Optional paid token — public access works without one')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user