mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 12:23:59 +00:00
Merge pull request #1506 from inoukt/fix/taiwan-highway-cctv-fallback
feat(cctv): expand Taiwan, Québec, Ottawa, and World cameras
This commit is contained in:
66 files changed
+39134
-132
No files matched your search
+7
-1
@@ -218,6 +218,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
@@ -270,7 +271,12 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
||||
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
|
||||
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
|
||||
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
|
||||
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
|
||||
+6
-1
@@ -124,7 +124,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
||||
|
||||
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
|
||||
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
@@ -172,6 +173,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
|
||||
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
|
||||
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
|
||||
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
|
||||
@@ -223,6 +226,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
|
||||
@@ -440,7 +440,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 🌍 **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, free developer key) · 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) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · 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) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; 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 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 | 🟢 |
|
||||
|
||||
+2
-1
@@ -71,11 +71,12 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
|
||||
Québec camera locations, City of Ottawa traffic cameras (needs a free city
|
||||
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
|
||||
image-backed cameras from Oregon and Japan (InoukT, #1506).
|
||||
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
|
||||
loads only after the viewer presses Play; enforce provider-specific frame
|
||||
refresh limits, and report recognized MLIT maintenance images as degraded
|
||||
instead of healthy camera snapshots.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
||||
# CCTV integration workflow
|
||||
|
||||
Use this workflow when adding a camera provider or repairing an existing feed.
|
||||
The CCTV catalog comes through the local server; media normally does too. Keep
|
||||
provider-specific URL policy on the server, including any validated browser-only
|
||||
media URL permitted by the exception below.
|
||||
|
||||
## 1. Check the source before coding
|
||||
|
||||
- Identify the catalog endpoint and the actual frame or live-media endpoint.
|
||||
They may be different hosts or use different formats.
|
||||
- Read the provider's license, terms, attribution, rate limits, and access
|
||||
requirements. Publicly reachable data is not automatically licensed for
|
||||
reuse. Record whether the app may display it and whether any use is
|
||||
restricted.
|
||||
- Prefer a public, keyless feed. If credentials are required, keep them in a
|
||||
server-only environment variable; never put a secret in browser code or a
|
||||
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
|
||||
the variable unset it makes no request and logs once (Ontario 511 and
|
||||
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
|
||||
the provider's documented API, not a website endpoint that skips its key. If
|
||||
access terms require an agreement or remain unconfirmed, make the pack
|
||||
opt-in and ensure it makes no request by default.
|
||||
- Confirm the feed can be fetched from the app's Node server environment. A URL
|
||||
opening in a browser does not prove the server can reach it, and browser CORS
|
||||
behavior does not diagnose a server-side fetch.
|
||||
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
|
||||
as discovery aids. Trace each listing to the original agency or camera owner;
|
||||
their data and media terms govern reuse. For example, SkylineWebcams says
|
||||
only camera hosts may embed its live feed and its terms restrict copying or
|
||||
retransmitting footage, so do not proxy its stream without written
|
||||
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
|
||||
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
|
||||
- Decide whether the provider supplies still snapshots or continuous video.
|
||||
Prefer stills unless live video is necessary and supported by the existing
|
||||
proxy path.
|
||||
|
||||
## 2. Add and register the provider pack
|
||||
|
||||
Use the smallest existing provider as a template. Current owners are:
|
||||
|
||||
- `server/providers/cctv/sources.js` for standard open-data loaders.
|
||||
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
|
||||
fallback, or more involved URL policy.
|
||||
- `server/providers/cctv/catalog.js` to import the loader and add it to
|
||||
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
|
||||
allocates space across packs.
|
||||
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
|
||||
timeouts.
|
||||
|
||||
Return normalized camera records with stable unique IDs, readable names,
|
||||
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
|
||||
`feedType`. Include camera heading only when the source actually provides a
|
||||
bearing; descriptive direction text is not a numeric heading. Mark uncertain
|
||||
heading data with the existing low-confidence conventions. Set `sourceKind` so
|
||||
health and source diagnostics identify the provider. Apply a bounded per-pack
|
||||
camera limit and geographic filtering where appropriate; avoid one provider
|
||||
silently consuming the whole shared catalog cap.
|
||||
|
||||
Camera coordinates need their own verification. Prefer the original provider's
|
||||
camera coordinates, then corroborate them against its location description,
|
||||
route/mile marker, or official map. An aggregator's map dot, a town/county name,
|
||||
or a geocoded attraction name is not a precise camera position. Do not pin a
|
||||
camera to a city center or nearby landmark; if its actual location cannot be
|
||||
verified, leave it out until it can. For manually curated coordinates, record
|
||||
the evidence in the provider data or an adjacent comment. For example, TW Live
|
||||
camera pages identify the originating agency; use that agency's catalog for
|
||||
the camera point when available.
|
||||
|
||||
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
|
||||
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
|
||||
enabled-by-default flag pattern and document how to disable the pack.
|
||||
|
||||
## 3. Keep frame and media requests server-registered
|
||||
|
||||
The browser should request the local `/api/cctv/frame/:id` or
|
||||
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
|
||||
browser, and do not fetch an unvalidated URL supplied by a catalog record.
|
||||
Every provider must have a server-side rule that pins frame and media URLs to
|
||||
the expected scheme, host, and path; use HTTPS where the provider supports it.
|
||||
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
|
||||
or a stricter provider-specific validator when hosts or paths vary. Register
|
||||
any new permitted image/media origin in the existing
|
||||
`server/providers/cctv` media policy.
|
||||
|
||||
Catalog and media loaders must also:
|
||||
|
||||
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
|
||||
registered origin/path, and malformed identifiers;
|
||||
- set request deadlines and cap catalog and buffered frame bodies;
|
||||
- parse only the expected format and validate coordinates before returning a
|
||||
camera;
|
||||
- handle offline cameras and partial provider failures without failing the
|
||||
entire merged catalog; and
|
||||
- avoid persistent frame storage unless the provider's terms explicitly allow
|
||||
it and the design has been reviewed.
|
||||
|
||||
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
|
||||
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
|
||||
a source whose terms allow embedding when the browser can play it but the app
|
||||
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
|
||||
other pack, and the browser loads it only after an explicit play action, never
|
||||
on selection, because the provider sees the viewer's IP address. Build its URL
|
||||
from a validated provider ID on the server; never accept a browser-supplied
|
||||
media URL. Render it in a native DOM
|
||||
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
|
||||
or a Cesium texture. If a map preview is needed, a second video request may be
|
||||
paused as a DOM overlay at the camera location; label it as an approximate
|
||||
still and scale it with map distance. If terms disallow embedding, use a source
|
||||
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
|
||||
validation, redirects, or proxy behavior.
|
||||
|
||||
When an image provider sets a minimum refresh interval, enforce it at the
|
||||
server frame boundary as well as in the client. A bounded in-memory cache and
|
||||
single-flight request per camera prevent projection, panel, and re-selection
|
||||
paths from multiplying upstream requests; do not persist frames unless the
|
||||
provider's terms explicitly allow it.
|
||||
|
||||
## 4. Add focused regression coverage
|
||||
|
||||
Add provider tests under `src/data/` following the existing CCTV test naming
|
||||
and fixtures. Cover at least:
|
||||
|
||||
- representative valid catalog rows and their normalized output;
|
||||
- missing or invalid IDs, coordinates, and frame URLs;
|
||||
- duplicate records and the pack's cap/filter behavior;
|
||||
- unsafe host/path, credentials, and redirect cases;
|
||||
- timeout, oversized, malformed, and unavailable upstream responses; and
|
||||
- the frame or live-media content type the provider actually serves.
|
||||
|
||||
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
|
||||
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
|
||||
browser-only source, verify native playback, denied canvas pixel access, the
|
||||
paused second-player overlay, and its cleanup separately from proxy tests. Run
|
||||
the focused provider and proxy tests, then the standard checks from
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
|
||||
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
|
||||
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
|
||||
successful frame is from the upstream camera by checking its content type and
|
||||
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
|
||||
|
||||
## 5. Update source documentation and submit
|
||||
|
||||
- Add the provider, endpoint, terms/license, required attribution, key status,
|
||||
caching behavior, and any restrictions to `DATA_SOURCES.md`.
|
||||
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
|
||||
behavior, flags, caps, or fallback behavior changes.
|
||||
- Register in-app attribution through the existing source/provider credit
|
||||
fields; update `src/data/dataCredits.js` when the source needs a layer-level
|
||||
credit.
|
||||
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
|
||||
change, and include the source, license, attribution, key/runtime-fetch
|
||||
details, and verification evidence in the PR description.
|
||||
- For a new layer, also follow the camera/source requirements in the
|
||||
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
|
||||
+90
-25
@@ -1599,9 +1599,11 @@ from the payload. There is no heading in the feed, so gizmos use the shared
|
||||
id-hash fallback at low confidence.
|
||||
|
||||
Once enabled, the 250 cameras nearest the five borough cores load.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
|
||||
round-robin: no region is dropped outright, but when the packs together exceed
|
||||
the ceiling, adding a pack trims cameras from the largest packs.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is
|
||||
7,350: 4,000 shared round-robin slots, plus room above them for the 1,120
|
||||
Québec locations and 2,230 World image sources. Lower explicit
|
||||
`CCTV_MAX_SOURCES` values are honored; reservations only use the capacity
|
||||
above the 4,000 shared slots.
|
||||
|
||||
## City of Toronto RESCU CCTV source pack
|
||||
|
||||
@@ -1617,8 +1619,9 @@ cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
|
||||
|
||||
By default all ~336 cameras load, nearest downtown first.
|
||||
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
|
||||
catalog-wide ceiling round-robin with the other packs.
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default
|
||||
7,350-source catalog limit with the other packs; Québec and the World image
|
||||
pack have reserved slots and the remaining sources use round-robin allocation.
|
||||
|
||||
## Lithuania CCTV source pack
|
||||
|
||||
@@ -1638,24 +1641,8 @@ video; camera poses use the existing low-confidence heading prior.
|
||||
|
||||
## Taiwan CCTV source pack
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
|
||||
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
|
||||
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
|
||||
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
|
||||
allocation. A failed download serves the last good table (up to a day old),
|
||||
or an empty pack, and is not retried for an hour: the catalog host does not
|
||||
answer from some networks outside Taiwan, and without the backoff every refresh
|
||||
there would wait out the connect timeout. The table
|
||||
publishes MJPEG stream URLs; each is mapped to the same camera server's
|
||||
single-JPEG path for the same camera number, and only those exact HTTPS pairs
|
||||
on the four `freeway.gov.tw` camera servers are registered with the frame
|
||||
proxy. Camera keys come from the server and camera number, because published
|
||||
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
|
||||
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
|
||||
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
|
||||
`CCTV_TAIWAN_URL` overrides the table URL.
|
||||
Gate: `src/data/cctvTaiwan.test.mjs`.
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL.
|
||||
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
@@ -5448,8 +5435,10 @@ silently demoting every later lookup for the session.
|
||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
||||
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
|
||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,350: 4,000 shared
|
||||
round-robin slots plus 3,350 reserved Québec and World image slots.
|
||||
Reservations only use capacity above the 4,000 shared slots, so at 4,000 or
|
||||
less every pack, Québec and World included, shares the cap round-robin.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||
@@ -7126,3 +7115,79 @@ the container healthy. Checks time out after 10 seconds, with a 30-second
|
||||
startup grace period and three consecutive failures before reporting unhealthy.
|
||||
This reports app HTTP availability, not individual provider health, and does not
|
||||
configure automatic restarts.
|
||||
|
||||
## Québec CCTV integration
|
||||
|
||||
The regular CCTV source loader reads `public/quebec-cameras.json` by default
|
||||
when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680
|
||||
MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams.
|
||||
The default catalog limit is 7,350: it reserves all 1,120 Québec locations and
|
||||
2,230 World image feeds, and keeps 4,000 slots for the existing round-robin
|
||||
camera packs. Reservations only use capacity above those 4,000 slots: a lower
|
||||
explicit `CCTV_MAX_SOURCES` thins every pack round-robin, Québec and World
|
||||
included, instead of taking the shared slots.
|
||||
|
||||
The Québec 511 video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) because Québec
|
||||
511's terms for embedding its clips on another site are unconfirmed; the camera
|
||||
locations themselves are CC BY 4.0. With the flag unset, MTQ cameras show
|
||||
their location, credit and a link to the Québec 511 viewer page. With it set,
|
||||
selecting an MTQ camera opens a floating panel with a Play button and a notice
|
||||
that Québec 511 sees the viewer's IP address; the browser contacts Québec 511
|
||||
only after Play, never on selection alone (layer enable, auto-hop, a scene or
|
||||
voice), and a different camera starts unplayed. After Play, the panel plays
|
||||
the official MP4 in a native browser player and requests a second video for the
|
||||
paused map preview. The official feed is a five-second clip refreshed by
|
||||
Québec 511 every 2–4 minutes, not continuous live video; the browser player
|
||||
reloads every two minutes while the tab is visible. The
|
||||
map preview is a DOM overlay attached to the camera's coordinates and scales
|
||||
down with camera distance. Neither video is read into a canvas or Cesium texture:
|
||||
Québec 511 does not grant localhost pixel access, and its MP4 is not available
|
||||
to the app's server proxy. Montréal and scenic entries are regular CCTV camera
|
||||
locations with per-camera source-page links, but no direct frame URLs; those
|
||||
show a location label and open the official/operator page. MTQ positions carry
|
||||
CC BY 4.0 attribution, Montréal positions are attributed to OpenStreetMap
|
||||
contributors under ODbL, and scenic rows retain their position accuracy and
|
||||
provenance. Québec cameras load through the normal CCTV layer.
|
||||
|
||||
## City of Ottawa CCTV source pack
|
||||
|
||||
The CCTV catalog can add City of Ottawa traffic cameras, including the MTO
|
||||
cameras the city carries. Camera images come from the city's Traffic Camera
|
||||
Images API (`traffic.ottawa.ca/opendata/camera`), which needs a certificate
|
||||
from the city's registration form and lets one certificate and user id fetch a
|
||||
camera at most once a minute. Without `OTTAWA_CAMERA_CERTIFICATE` the pack is
|
||||
skipped with no request and one log line. The camera list is the documented
|
||||
keyless `traffic.ottawa.ca/map/service/camera` JSON. Catalog URLs carry no
|
||||
key: the frame proxy adds the certificate and the fixed user id `gev` at fetch
|
||||
time, only for a numeric camera number inside the pinned `/opendata/` path,
|
||||
and serves each camera from one shared server-side frame per minute. Inside
|
||||
that minute a camera with no cached frame gets the placeholder, never the
|
||||
Street View fallback. The feed publishes no bearing, so headings use the
|
||||
id-hash fallback at low confidence. `CCTV_OTTAWA_MAX_SOURCES` caps the pack
|
||||
(default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
|
||||
## World camera image feeds
|
||||
|
||||
The default `world-cameras` pack reads only the checked-in, curated
|
||||
`config/cctv_sources.world-image-cameras.json` file. It adds 1,176 Oregon
|
||||
TripCheck and 1,054 MLIT river cameras. Duplicate feeds are excluded because
|
||||
DriveBC, Ottawa, Québec and Iceland (Vegagerðin) already have app packs. NZTA's
|
||||
249 rows are excluded because they were collected from a Journey Planner web
|
||||
cache whose current terms prohibit automated scraping; an authorized API feed
|
||||
or permission is required before adding them. The wider discovery export is
|
||||
not shipped with the app.
|
||||
|
||||
The app validates and pins each still-image URL to its provider directory
|
||||
before the server proxy uses it. Oregon frames refresh no faster
|
||||
than every five minutes; MLIT river frames refresh every ten minutes to reduce
|
||||
requests. Images are not persisted; the proxy reuses frames in a bounded 32 MB
|
||||
in-memory cache until their provider cadence. Set
|
||||
`CCTV_WORLD_CAMERAS_ENABLED=0` to disable the pack.
|
||||
|
||||
Only the curated image-backed rows are bundled. Camera images are fetched from
|
||||
their registered providers at request time and are not included in this data
|
||||
file. The additive `/api/cctv/sources` metadata includes provider source links,
|
||||
browser-only media links, refresh cadence, and location provenance where
|
||||
available. When MLIT serves its recognized adjustment image, `/api/cctv/frame`
|
||||
returns a God’s Eye maintenance card and `/api/cctv/health` reports the camera
|
||||
as degraded with the provider-adjustment reason.
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
|
||||
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
|
||||
@@ -363,6 +363,9 @@
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"server/providers/cctv/taiwan.js",
|
||||
"server/providers/cctv/quebec.js",
|
||||
"server/providers/cctv/ottawa.js",
|
||||
"server/providers/cctv/worldCameras.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
@@ -806,6 +809,7 @@
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
@@ -814,6 +818,7 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
@@ -833,7 +838,7 @@
|
||||
"src/ui/stores/status.js",
|
||||
"src/ui/i18n.js"
|
||||
],
|
||||
"external": ["vue", "pinia", "vue-i18n"]
|
||||
"external": ["vue", "pinia", "vue-i18n", "cesium"]
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": ["./ui/context", "./ui/context/policy"],
|
||||
@@ -1006,6 +1011,8 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
@@ -1197,6 +1204,7 @@
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/quebecCctvOverlay.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/bhote-koshi.css",
|
||||
"src/ui/styles/recording.css",
|
||||
@@ -2808,6 +2816,7 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
|
||||
+187
-5
@@ -1,4 +1,5 @@
|
||||
import { createCctvCatalog } from './cctv/catalog.js';
|
||||
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
|
||||
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
|
||||
import {
|
||||
buildSyntheticCctvSvg,
|
||||
@@ -18,6 +19,8 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
|
||||
import { ottawaAuthorizedFrameUrl } from './cctv/ottawa.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
/**
|
||||
@@ -43,6 +46,120 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
||||
const puller = createHlsPuller();
|
||||
/**
|
||||
* Bounded transient image reuse keeps cadence-limited providers (World
|
||||
* stills, Ottawa's once-a-minute API) inside their refresh floor. The byte
|
||||
* cap is the real bound; the entry cap only limits map overhead.
|
||||
*/
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRIES = 1024;
|
||||
/** Retry a failed fetch sooner than a full refresh window, never under a minute. */
|
||||
const THROTTLED_FRAME_FAILURE_BACKOFF_MS = 60 * 1000;
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024;
|
||||
const WORLD_FRAME_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
const worldFrameCache = new Map();
|
||||
const worldFrameInflight = new Map();
|
||||
const worldFrameNextAllowedAt = new Map();
|
||||
let worldFrameCacheBytes = 0;
|
||||
|
||||
function getWorldFrameCache(cameraId) {
|
||||
const entry = worldFrameCache.get(cameraId);
|
||||
if (!entry) return null;
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function storeWorldFrame(cameraId, image) {
|
||||
if (
|
||||
!Buffer.isBuffer(image?.body) ||
|
||||
image.body.length > WORLD_FRAME_CACHE_MAX_ENTRY_BYTES
|
||||
)
|
||||
return;
|
||||
const previous = worldFrameCache.get(cameraId);
|
||||
if (previous) worldFrameCacheBytes -= previous.body.length;
|
||||
const entry = {
|
||||
body: image.body,
|
||||
contentType: image.contentType,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
worldFrameCacheBytes += entry.body.length;
|
||||
while (
|
||||
worldFrameCache.size > WORLD_FRAME_CACHE_MAX_ENTRIES ||
|
||||
worldFrameCacheBytes > WORLD_FRAME_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldestId = worldFrameCache.keys().next().value;
|
||||
const oldest = worldFrameCache.get(oldestId);
|
||||
worldFrameCache.delete(oldestId);
|
||||
worldFrameCacheBytes -= oldest.body.length;
|
||||
}
|
||||
}
|
||||
|
||||
function setNextAllowedAt(cameraId, at) {
|
||||
worldFrameNextAllowedAt.delete(cameraId);
|
||||
worldFrameNextAllowedAt.set(cameraId, at);
|
||||
while (worldFrameNextAllowedAt.size > 4096) {
|
||||
worldFrameNextAllowedAt.delete(
|
||||
worldFrameNextAllowedAt.keys().next().value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a frame from a provider with a per-camera refresh floor. Inside the
|
||||
* floor the last frame is reused; with no frame to reuse the caller gets
|
||||
* `{ throttled: true }` and serves a placeholder, so a cadence-limited camera
|
||||
* never falls through to the key-bearing Street View fallback. A failed
|
||||
* fetch backs off for a minute (capped at the floor) instead of a full
|
||||
* window.
|
||||
*/
|
||||
async function fetchThrottledFrame(source, cameraId, frameUrl) {
|
||||
const cached = getWorldFrameCache(cameraId);
|
||||
const rawRefreshMs = Number(source?.frameRefreshMs);
|
||||
const refreshMs =
|
||||
Number.isFinite(rawRefreshMs) && rawRefreshMs > 0
|
||||
? rawRefreshMs
|
||||
: 5 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < refreshMs) {
|
||||
return { ok: true, ...cached, cached: true };
|
||||
}
|
||||
|
||||
const inflight = worldFrameInflight.get(cameraId);
|
||||
if (inflight) return inflight;
|
||||
const stale = cached
|
||||
? {
|
||||
ok: true,
|
||||
body: cached.body,
|
||||
contentType: cached.contentType,
|
||||
stale: true,
|
||||
}
|
||||
: null;
|
||||
const nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
|
||||
if (now < nextAllowedAt) return stale || { ok: false, throttled: true };
|
||||
if (!frameUrl) return stale;
|
||||
setNextAllowedAt(cameraId, now + refreshMs);
|
||||
let request;
|
||||
request = fetchCctvImageFromUpstream(frameUrl)
|
||||
.then((image) => {
|
||||
if (image?.ok) {
|
||||
storeWorldFrame(cameraId, image);
|
||||
return image;
|
||||
}
|
||||
setNextAllowedAt(
|
||||
cameraId,
|
||||
Date.now() + Math.min(refreshMs, THROTTLED_FRAME_FAILURE_BACKOFF_MS),
|
||||
);
|
||||
return stale || image;
|
||||
})
|
||||
.finally(() => {
|
||||
if (worldFrameInflight.get(cameraId) === request)
|
||||
worldFrameInflight.delete(cameraId);
|
||||
});
|
||||
worldFrameInflight.set(cameraId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
@@ -153,6 +270,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
);
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const quebecVideo = quebecBrowserVideoEnabled();
|
||||
const body = {
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||
sources: sources.map((source) => ({
|
||||
@@ -164,6 +282,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
provider: source.provider,
|
||||
lat: source.lat,
|
||||
lon: source.lon,
|
||||
frameRefreshMs: source.frameRefreshMs,
|
||||
headingDeg: source.headingDeg,
|
||||
headingConfidence: source.headingConfidence || '',
|
||||
pitchDeg: source.pitchDeg,
|
||||
@@ -174,6 +293,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
feedType: normalizeFeedType(source.feedType),
|
||||
sourceKind:
|
||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
||||
sourceUrl: source.sourceUrl || '',
|
||||
browserMediaUrl:
|
||||
quebecVideo && source.sourceKind === 'quebec-511-browser'
|
||||
? quebec511VideoUrl(source.id)
|
||||
: '',
|
||||
accuracyM: source.accuracyM,
|
||||
positionSource: source.positionSource || '',
|
||||
poseSource: source.poseSource,
|
||||
model: source.model || '',
|
||||
manufacturer: source.manufacturer || '',
|
||||
@@ -526,15 +652,71 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
: '');
|
||||
|
||||
const upstreamImage =
|
||||
source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||
source?.pack === 'world-cameras'
|
||||
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
|
||||
: source?.sourceKind === 'ottawa-traffic'
|
||||
? await fetchThrottledFrame(
|
||||
source,
|
||||
cameraId,
|
||||
ottawaAuthorizedFrameUrl(upstreamCandidate),
|
||||
)
|
||||
: source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate, {
|
||||
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
|
||||
});
|
||||
if (
|
||||
source?.sourceKind === 'mlit-river' &&
|
||||
isMlitRiverAdjustmentImage(upstreamImage)
|
||||
) {
|
||||
const svg = buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'CAMERA UNDER ADJUSTMENT',
|
||||
});
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'snapshot',
|
||||
label: source.provider || 'MLIT river cameras',
|
||||
message: 'Provider camera under adjustment',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'provider-maintenance',
|
||||
});
|
||||
res.end(svg);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.throttled) {
|
||||
// Inside the provider's refresh floor with no frame to reuse: a
|
||||
// placeholder, not Street View, and no health change.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'synthetic',
|
||||
});
|
||||
res.end(
|
||||
buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'WAITING FOR NEXT FRAME',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
status: upstreamImage.stale ? 'degraded' : 'ok',
|
||||
sourceKind: 'snapshot',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: 'Upstream snapshot active',
|
||||
message: upstreamImage.stale
|
||||
? 'Serving last snapshot; refresh failed'
|
||||
: upstreamImage.cached
|
||||
? 'Cached snapshot active'
|
||||
: 'Upstream snapshot active',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': upstreamImage.contentType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from './constants.js';
|
||||
|
||||
@@ -23,16 +24,27 @@ export function resolveCatalogCap(raw) {
|
||||
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
|
||||
* each pack per turn, in each pack's own order — so a lowered
|
||||
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
|
||||
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
|
||||
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
|
||||
* that many rows only from the capacity ABOVE the shared floor, so a
|
||||
* reservation adds room and never shrinks the other packs' round-robin
|
||||
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
|
||||
* ids resolve last-pack-wins so
|
||||
* file/env overrides keep replacing live records; the record is then counted
|
||||
* against the pack that won it.
|
||||
*
|
||||
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
||||
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
||||
* @param {number} maxCount - Catalog cap.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.sharedFloor=CCTV_SHARED_SOURCE_SLOTS] - Slots kept
|
||||
* for the round-robin before reservations apply.
|
||||
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
|
||||
*/
|
||||
export function allocateSourceCap(packs, maxCount) {
|
||||
export function allocateSourceCap(
|
||||
packs,
|
||||
maxCount,
|
||||
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
|
||||
) {
|
||||
const owner = new Map();
|
||||
for (const pack of packs) {
|
||||
for (const source of pack.sources) {
|
||||
@@ -40,30 +52,53 @@ export function allocateSourceCap(packs, maxCount) {
|
||||
owner.set(source.id, { pack: pack.name, source });
|
||||
}
|
||||
}
|
||||
const lanes = packs.map((pack) => ({
|
||||
name: pack.name,
|
||||
const lanes = packs.map((pack) => {
|
||||
// Only the record that WON its id (the last occurrence anywhere, so also
|
||||
// the last within this pack) takes a lane slot; earlier duplicates never
|
||||
// count against the cap.
|
||||
queue: pack.sources.filter(
|
||||
const queue = pack.sources.filter(
|
||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
}));
|
||||
);
|
||||
return {
|
||||
name: pack.name,
|
||||
queue,
|
||||
reserved: Math.max(
|
||||
0,
|
||||
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
};
|
||||
});
|
||||
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
|
||||
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
|
||||
|
||||
const sources = [];
|
||||
const reservedSources = [];
|
||||
if (limit >= total) {
|
||||
for (const lane of lanes) {
|
||||
sources.push(...lane.queue);
|
||||
lane.kept = lane.queue.length;
|
||||
}
|
||||
} else {
|
||||
while (sources.length < limit) {
|
||||
// Reserved packs draw only on the capacity above the shared floor, then
|
||||
// every lane (reserved ones included) shares the rest round-robin.
|
||||
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
|
||||
while (reservedSources.length < reserveBudget) {
|
||||
let tookReserved = false;
|
||||
for (const lane of lanes) {
|
||||
if (sources.length >= limit) break;
|
||||
if (reservedSources.length >= reserveBudget) break;
|
||||
if (lane.kept >= lane.reserved) continue;
|
||||
reservedSources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
lane.kept += 1;
|
||||
tookReserved = true;
|
||||
}
|
||||
if (!tookReserved) break;
|
||||
}
|
||||
while (sources.length + reservedSources.length < limit) {
|
||||
for (const lane of lanes) {
|
||||
if (sources.length + reservedSources.length >= limit) break;
|
||||
if (lane.next >= lane.queue.length) continue;
|
||||
sources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
@@ -72,7 +107,9 @@ export function allocateSourceCap(packs, maxCount) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
// Keep the shared round-robin sequence first so the default active camera
|
||||
// remains in its established region; guaranteed regional rows follow it.
|
||||
sources: [...sources, ...reservedSources],
|
||||
packs: lanes.map((lane) => ({
|
||||
name: lane.name,
|
||||
offered: lane.queue.length,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import { loadLithuaniaSources } from './lithuania.js';
|
||||
import { loadOttawaSourcesFromOpenData } from './ottawa.js';
|
||||
import { loadQuebecSources, QUEBEC_CAMERA_SOURCE_COUNT } from './quebec.js';
|
||||
import { loadTaiwanSources } from './taiwan.js';
|
||||
import { loadWorldCameraSources } from './worldCameras.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -76,6 +83,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
||||
load: loadDriveBcSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'ottawa',
|
||||
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
|
||||
load: loadOttawaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'txdot',
|
||||
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
||||
@@ -178,6 +190,18 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
|
||||
load: loadHoustonSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'quebec',
|
||||
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
|
||||
load: loadQuebecSources,
|
||||
reservedCount: QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'world-cameras',
|
||||
enabled: () => envEnabled('CCTV_WORLD_CAMERAS_ENABLED'),
|
||||
load: loadWorldCameraSources,
|
||||
reservedCount: WORLD_CCTV_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
@@ -322,7 +346,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
: [];
|
||||
// Live packs first so file/env overrides win on duplicate IDs; each pack
|
||||
// keeps its own priority order and the catalog cap is shared fairly.
|
||||
const normalizePack = (name, items) => ({
|
||||
const normalizePack = (name, items, reservedCount = 0) => ({
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
@@ -332,6 +356,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
? items.available
|
||||
: items.length,
|
||||
region: items.region ?? null,
|
||||
reservedCount,
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
@@ -340,6 +365,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
liveResults[index]?.status === 'fulfilled'
|
||||
? liveResults[index].value
|
||||
: [],
|
||||
pack.reservedCount,
|
||||
),
|
||||
),
|
||||
normalizePack('file', fromFile),
|
||||
|
||||
@@ -5,15 +5,18 @@ export const DEFAULT_AUSTIN_ROWS_URL =
|
||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/**
|
||||
* Catalog-wide safety ceiling on served cameras. Each pack already caps
|
||||
* itself (nearest-to-anchor first); this bound only matters when the packs
|
||||
* together exceed it, and it is then filled round-robin across packs (see
|
||||
* cap.js) so no region is silently dropped. The default per-pack caps
|
||||
* together can exceed it, so a default install may be thinned evenly here.
|
||||
* Slots every pack shares round-robin before any reservation applies. A
|
||||
* reserved pack (Québec, World) only takes capacity above this floor, so a
|
||||
* cap at or below it thins every region evenly (see cap.js).
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
|
||||
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
|
||||
/**
|
||||
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
|
||||
* 1,120 Québec and 2,230 World image locations.
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 7350;
|
||||
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||
export const CCTV_MAX_SOURCES_CEILING = 8000;
|
||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
||||
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||
@@ -197,6 +200,39 @@ export const DRIVEBC_ANCHORS = [
|
||||
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
||||
{ lat: 48.4284, lon: -123.3656 }, // Victoria
|
||||
];
|
||||
/** City of Ottawa traffic cameras: documented JSON list (keyless). */
|
||||
export const OTTAWA_CAMERAS_URL =
|
||||
'https://traffic.ottawa.ca/map/service/camera';
|
||||
/**
|
||||
* Keyless canonical frame URL for one camera number. The city's Traffic Camera
|
||||
* Images API needs a certificate (OTTAWA_CAMERA_CERTIFICATE), which the frame
|
||||
* proxy adds at request time so it never appears in a catalog response.
|
||||
*/
|
||||
export const OTTAWA_IMAGE_URL = (cameraNumber) =>
|
||||
`https://traffic.ottawa.ca/opendata/camera?c=${encodeURIComponent(cameraNumber)}`;
|
||||
/** Pinned frame directory, for redirect containment in the media proxy. */
|
||||
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/opendata/';
|
||||
/** The API lets one certificate and user id fetch a camera once a minute. */
|
||||
export const OTTAWA_FRAME_REFRESH_MS = 60 * 1000;
|
||||
/** Alphanumeric user id this server sends with its certificate. */
|
||||
export const OTTAWA_API_USER_ID = 'gev';
|
||||
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
|
||||
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
|
||||
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
|
||||
/**
|
||||
* Taiwan Highway Bureau MJPEG servers. Each camera is one root path segment,
|
||||
* so the pin is the host root: a redirect must stay on the same server.
|
||||
*/
|
||||
export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`),
|
||||
);
|
||||
/** Direct still-image directories from the World cams add-on's eligible packs. */
|
||||
export const TRIPCHECK_IMAGE_ORIGIN =
|
||||
'https://www.tripcheck.com/roadcams/cams/';
|
||||
export const MLIT_RIVER_IMAGE_ORIGIN = 'https://cam.river.go.jp/cam/now/';
|
||||
export const DEFAULT_WORLD_CCTV_SOURCE_FILE =
|
||||
'config/cctv_sources.world-image-cameras.json';
|
||||
export const WORLD_CCTV_SOURCE_COUNT = 2230;
|
||||
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
|
||||
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
|
||||
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
||||
|
||||
@@ -10,11 +10,15 @@ import {
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
@@ -527,11 +531,15 @@ for (const prefix of [
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
A22_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
@@ -623,6 +631,216 @@ export function cctvUpstreamUserAgent(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
|
||||
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
|
||||
*
|
||||
* The first part may follow a CRLF preamble, and its delimiter may be written
|
||||
* as `--<boundary>` or, when the declared boundary already starts with `--`,
|
||||
* as the boundary itself. A part with Content-Length is read to that length;
|
||||
* a part without one ends at the next delimiter, so a JPEG end marker inside
|
||||
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
|
||||
* into a preallocated buffer and every search resumes where it stopped, so a
|
||||
* trickling upstream costs linear time.
|
||||
*/
|
||||
async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||
const mediaType = cctvLiveMediaType(
|
||||
'mjpeg',
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
|
||||
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
|
||||
if (
|
||||
!boundary ||
|
||||
!upstream.body ||
|
||||
!Number.isSafeInteger(frameMax) ||
|
||||
frameMax < 4
|
||||
) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
|
||||
if (boundary.startsWith('--') && boundary.length > 2)
|
||||
delimiters.push(Buffer.from(boundary, 'latin1'));
|
||||
const lineEnd = Buffer.from('\r\n', 'ascii');
|
||||
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
|
||||
const jpegStart = Buffer.from([0xff, 0xd8]);
|
||||
const jpegEnd = Buffer.from([0xff, 0xd9]);
|
||||
const maxPreambleBytes = 64;
|
||||
const maxHeaderBytes = 8 * 1024;
|
||||
const maxBufferedBytes =
|
||||
maxPreambleBytes +
|
||||
delimiters[0].length +
|
||||
lineEnd.length +
|
||||
maxHeaderBytes +
|
||||
headerTerminator.length +
|
||||
frameMax +
|
||||
lineEnd.length +
|
||||
delimiters[0].length;
|
||||
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
|
||||
let length = 0;
|
||||
let reader;
|
||||
let headerStart = -1;
|
||||
let delimiter = null;
|
||||
let headerScanFrom = 0;
|
||||
let bodyStart = -1;
|
||||
let declaredLength = null;
|
||||
let boundaryScanFrom = -1;
|
||||
|
||||
/** Match the opening delimiter after an optional CRLF preamble. */
|
||||
function findOpening() {
|
||||
let at = 0;
|
||||
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
|
||||
if (at > maxPreambleBytes) return { failed: true };
|
||||
for (const candidate of delimiters) {
|
||||
const line = Buffer.concat([candidate, lineEnd]);
|
||||
const need = at + line.length;
|
||||
if (length < need) {
|
||||
const seen = buffer.subarray(at, length);
|
||||
if (line.subarray(0, seen.length).equals(seen))
|
||||
return { pending: true };
|
||||
continue;
|
||||
}
|
||||
if (buffer.subarray(at, need).equals(line))
|
||||
return { start: need, delimiter: candidate };
|
||||
}
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
/** Parse the part headers; false rejects the stream. */
|
||||
function parseHeaders(headerEnd) {
|
||||
const headerText = buffer
|
||||
.subarray(headerStart, headerEnd)
|
||||
.toString('latin1');
|
||||
const headers = new Map();
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
const name = line.slice(0, colon).trim().toLowerCase();
|
||||
if (!name || headers.has(name)) return false;
|
||||
headers.set(name, line.slice(colon + 1).trim());
|
||||
}
|
||||
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
|
||||
return false;
|
||||
const declared = headers.get('content-length');
|
||||
if (declared !== undefined) {
|
||||
if (!/^\d+$/.test(declared)) return false;
|
||||
declaredLength = Number(declared);
|
||||
if (
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < jpegStart.length + jpegEnd.length ||
|
||||
declaredLength > frameMax
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A complete JPEG body, or null. */
|
||||
function asJpeg(frame) {
|
||||
return frame.length >= jpegStart.length + jpegEnd.length &&
|
||||
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
|
||||
frame.subarray(-jpegEnd.length).equals(jpegEnd)
|
||||
? Buffer.from(frame)
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
reader = upstream.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return null;
|
||||
|
||||
const chunk = Buffer.from(value);
|
||||
const room = maxBufferedBytes - length;
|
||||
const included = Math.min(room, chunk.length);
|
||||
chunk.copy(buffer, length, 0, included);
|
||||
length += included;
|
||||
const overflow = included < chunk.length;
|
||||
|
||||
if (headerStart < 0) {
|
||||
const opening = findOpening();
|
||||
if (opening.failed) return null;
|
||||
if (opening.pending) {
|
||||
if (overflow) return null;
|
||||
continue;
|
||||
}
|
||||
headerStart = opening.start;
|
||||
delimiter = opening.delimiter;
|
||||
headerScanFrom = headerStart;
|
||||
}
|
||||
|
||||
if (bodyStart < 0) {
|
||||
const headerEnd = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(headerTerminator, headerScanFrom);
|
||||
if (headerEnd < 0) {
|
||||
if (length - headerStart > maxHeaderBytes || overflow) return null;
|
||||
headerScanFrom = Math.max(
|
||||
headerStart,
|
||||
length - headerTerminator.length + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (headerEnd - headerStart > maxHeaderBytes) return null;
|
||||
if (!parseHeaders(headerEnd)) return null;
|
||||
bodyStart = headerEnd + headerTerminator.length;
|
||||
boundaryScanFrom = bodyStart;
|
||||
}
|
||||
|
||||
const available = length - bodyStart;
|
||||
if (available >= jpegStart.length) {
|
||||
if (
|
||||
!buffer
|
||||
.subarray(bodyStart, bodyStart + jpegStart.length)
|
||||
.equals(jpegStart)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
|
||||
const nextPart = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(nextDelimiter, boundaryScanFrom);
|
||||
if (declaredLength !== null) {
|
||||
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
|
||||
return null;
|
||||
if (available >= declaredLength)
|
||||
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
|
||||
} else if (nextPart >= bodyStart) {
|
||||
const frame = buffer.subarray(bodyStart, nextPart);
|
||||
return frame.length > frameMax ? null : asJpeg(frame);
|
||||
} else if (available > frameMax) {
|
||||
return null;
|
||||
}
|
||||
if (nextPart < 0)
|
||||
boundaryScanFrom = Math.max(
|
||||
bodyStart,
|
||||
length - nextDelimiter.length + 1,
|
||||
);
|
||||
if (overflow) return null;
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already closed or aborted */
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
@@ -635,6 +853,10 @@ export function cctvUpstreamUserAgent(url) {
|
||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
|
||||
* @param {boolean} [options.allowMjpeg=false] - Accept a multipart MJPEG
|
||||
* answer and keep its first JPEG. Only sources registered as MJPEG stills
|
||||
* (the Taiwan Highway Bureau feeds) set it; any other host answering
|
||||
* multipart falls through to the next fallback.
|
||||
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||
*/
|
||||
export async function fetchCctvImageFromUpstream(
|
||||
@@ -643,6 +865,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
||||
allowMjpeg = false,
|
||||
} = {},
|
||||
) {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
@@ -662,10 +885,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
if (!upstream.ok) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
@@ -673,9 +893,24 @@ export async function fetchCctvImageFromUpstream(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
const upstreamType = upstream.headers.get('content-type');
|
||||
const contentType = cctvRasterImageType(upstreamType);
|
||||
if (contentType) {
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
}
|
||||
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readFirstMjpegFrame(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
return { ok: true, body, contentType: 'image/jpeg' };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -650,6 +650,10 @@ export function normalizeSourceItem(item) {
|
||||
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
||||
url: typeof item.url === 'string' ? item.url : '',
|
||||
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
||||
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
|
||||
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
|
||||
accuracyM: toFiniteNumber(item.accuracyM),
|
||||
positionSource: String(item.positionSource || ''),
|
||||
license: String(item.license || item.licenseNote || ''),
|
||||
// Per-camera attribution for feeds a partner supplies inside a pack
|
||||
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
OTTAWA_ANCHORS,
|
||||
OTTAWA_API_USER_ID,
|
||||
OTTAWA_CAMERAS_URL,
|
||||
OTTAWA_FRAME_REFRESH_MS,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_URL,
|
||||
} from './constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
prioritizeSources,
|
||||
toFiniteNumber,
|
||||
} from './normalize.js';
|
||||
|
||||
let ottawaCertificateMissingLogged = false;
|
||||
|
||||
/** Test hook: let the next missing-certificate skip log again. */
|
||||
export function _resetOttawaCertificateWarningForTest() {
|
||||
ottawaCertificateMissingLogged = false;
|
||||
}
|
||||
|
||||
/** The operator's Traffic Camera Images API certificate, trimmed. */
|
||||
function ottawaCertificate(env = process.env) {
|
||||
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the operator's certificate to a registered keyless Ottawa frame URL.
|
||||
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
|
||||
* number is signed, so the certificate never goes to any other path or host.
|
||||
*
|
||||
* @param {string} url - Canonical frame URL from the catalog.
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {string} The signed URL, or '' when there is no certificate or the
|
||||
* URL is not a registered Ottawa frame.
|
||||
*/
|
||||
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
|
||||
const certificate = ottawaCertificate(env);
|
||||
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
|
||||
if (!certificate || !pinned) return '';
|
||||
const parsed = new URL(pinned);
|
||||
const cameraNumber = parsed.searchParams.get('c') || '';
|
||||
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
|
||||
return '';
|
||||
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
|
||||
signed.searchParams.set('certificate', certificate);
|
||||
signed.searchParams.set('id', OTTAWA_API_USER_ID);
|
||||
return signed.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
|
||||
* slack for the MTO cameras on the 416/417 that the city feed also carries.
|
||||
*/
|
||||
const OTTAWA_BOUNDS = Object.freeze({
|
||||
minLat: 45.1,
|
||||
maxLat: 45.65,
|
||||
minLon: -76.2,
|
||||
maxLon: -75.1,
|
||||
});
|
||||
|
||||
/** Whether a point is inside the city feed's service area. */
|
||||
function isOttawaCoordinate(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= OTTAWA_BOUNDS.minLat &&
|
||||
lat <= OTTAWA_BOUNDS.maxLat &&
|
||||
lon >= OTTAWA_BOUNDS.minLon &&
|
||||
lon <= OTTAWA_BOUNDS.maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the City of Ottawa's traffic cameras.
|
||||
*
|
||||
* Camera images come from the city's Traffic Camera Images API, which needs a
|
||||
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
|
||||
* and allows one fetch per camera per minute for a certificate and user id.
|
||||
* Without a certificate the pack is skipped, with no request and one log line.
|
||||
* The camera list is the documented `map/service/camera` JSON: a camera
|
||||
* number, English and French names, coordinates and the owner (CITY or MTO).
|
||||
* The feed carries no bearing, no elevation and no status field, so headings
|
||||
* use the same deterministic low-confidence fallback as the other
|
||||
* heading-less packs and the client's ground snap refines the elevation
|
||||
* prior.
|
||||
*
|
||||
* @param {object} [_options]
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
|
||||
* a missing certificate or any upstream or payload failure.
|
||||
*/
|
||||
export async function loadOttawaSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
if (!ottawaCertificate(env)) {
|
||||
if (!ottawaCertificateMissingLogged) {
|
||||
ottawaCertificateMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(OTTAWA_CAMERAS_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = await resp.json();
|
||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
|
||||
if (!rows) return [];
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const number = toFiniteNumber(row?.camera_number);
|
||||
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
|
||||
continue;
|
||||
const lat = toFiniteNumber(row?.latitude);
|
||||
const lon = toFiniteNumber(row?.longitude);
|
||||
if (!isOttawaCoordinate(lat, lon)) continue;
|
||||
seen.add(number);
|
||||
|
||||
const cameraId = `ottawa-${number}`;
|
||||
const imageUrl = OTTAWA_IMAGE_URL(number);
|
||||
// The docs name the field camera_owner; the live feed sends cameraOwner.
|
||||
const owner = String(row?.camera_owner || row?.cameraOwner || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
|
||||
city: 'Ottawa',
|
||||
cityId: 'ottawa',
|
||||
provider: 'City of Ottawa',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
|
||||
sourceKind: 'ottawa-traffic',
|
||||
license: 'City of Ottawa Traffic Camera Images API (registered access)',
|
||||
credit:
|
||||
owner === 'MTO'
|
||||
? 'Ministry of Transportation of Ontario (MTO) camera'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_OTTAWA_MAX_SOURCES || DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
|
||||
: DEFAULT_OTTAWA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, OTTAWA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Ottawa camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Ottawa camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
|
||||
|
||||
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
|
||||
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
|
||||
|
||||
/**
|
||||
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
|
||||
* default: the camera locations are CC BY 4.0, but whether Québec 511's
|
||||
* service terms allow another site to embed its clips is unconfirmed, so an
|
||||
* unset flag lists the cameras with a link to the 511 viewer page only.
|
||||
*/
|
||||
export function quebecBrowserVideoEnabled(env = process.env) {
|
||||
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
|
||||
}
|
||||
|
||||
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
|
||||
export function quebec511VideoUrl(cameraId) {
|
||||
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
|
||||
return match
|
||||
? `https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=${match[1]}&format=mp4`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeViewerUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return url.protocol === 'https:' && !url.username && !url.password
|
||||
? url.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sourceId(camera) {
|
||||
const id = String(camera?.id || '').trim();
|
||||
if (camera?.kind === 'traffic' && /^\d+$/.test(id)) return `qc-511-${id}`;
|
||||
if (camera?.kind === 'montreal' && /^mtl-\d+(?:-\d+)*$/.test(id))
|
||||
return `qc-mtl-${id.slice(4)}`;
|
||||
if (camera?.kind === 'webcam' && /^web-\d+$/.test(id))
|
||||
return `qc-web-${id.slice(4)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the checked-in, no-key Québec location catalog and adapt it to the
|
||||
* regular CCTV source schema. MTQ video stays browser-only; other rows link to
|
||||
* their operator page because this dataset has no direct image URL for them.
|
||||
*/
|
||||
export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, QUEBEC_CATALOG_PATH);
|
||||
let cameras;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] Québec camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources = [];
|
||||
const seen = new Set();
|
||||
for (const camera of cameras) {
|
||||
const id = sourceId(camera);
|
||||
const lat = Number(camera.lat);
|
||||
const lon = Number(camera.lon);
|
||||
if (
|
||||
!id ||
|
||||
seen.has(id) ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
lat < 44.9 ||
|
||||
lat > 62.6 ||
|
||||
lon < -79.9 ||
|
||||
lon > -57.1
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
|
||||
const name = String(camera.nameEn || camera.name || id).trim();
|
||||
const isTraffic = camera.kind === 'traffic';
|
||||
const isMontreal = camera.kind === 'montreal';
|
||||
const sourceKind = isTraffic
|
||||
? 'quebec-511-browser'
|
||||
: isMontreal
|
||||
? 'quebec-montreal-osm'
|
||||
: 'quebec-webcam-link';
|
||||
const provider = isTraffic
|
||||
? 'Québec 511 / MTQ'
|
||||
: isMontreal
|
||||
? 'Ville de Montréal / OpenStreetMap'
|
||||
: String(camera.operator || 'Québec webcam');
|
||||
|
||||
sources.push({
|
||||
id,
|
||||
name: name.slice(0, 180),
|
||||
city: String(camera.region || 'Québec'),
|
||||
cityId: 'quebec',
|
||||
provider,
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(id),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 74,
|
||||
rangeM: 400,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
// No frame URL is registered: the server never fetches 511 media. With
|
||||
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
|
||||
// overlay after a play click; otherwise the camera links to 511.
|
||||
url: '',
|
||||
snapshotUrl: '',
|
||||
sourceKind,
|
||||
sourceUrl: safeViewerUrl(camera.viewerUrl),
|
||||
accuracyM: Number.isFinite(Number(camera.accuracyM))
|
||||
? Number(camera.accuracyM)
|
||||
: null,
|
||||
positionSource: String(camera.positionSource || ''),
|
||||
license: isTraffic
|
||||
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors, ODbL 1.0'
|
||||
: 'Operator terms apply; location provenance shown per camera',
|
||||
credit: isTraffic
|
||||
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors · ODbL'
|
||||
: provider,
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
+158
-11
@@ -1,4 +1,7 @@
|
||||
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
cameraDisplayCode,
|
||||
fallbackHeadingFromId,
|
||||
@@ -10,6 +13,8 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
||||
export const TAIWAN_FREEWAY_CCTV_URL =
|
||||
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
|
||||
export const TAIWAN_HIGHWAY_CCTV_URL =
|
||||
'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
/** The whole network is ~1.2 MB. */
|
||||
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
||||
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
||||
@@ -35,6 +40,9 @@ const STREAM_TO_FRAME = new Map([
|
||||
]);
|
||||
|
||||
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
||||
const TAIWAN_HIGHWAY_CAMERA_HOSTS = new Set(
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS.map((origin) => new URL(origin).hostname),
|
||||
);
|
||||
|
||||
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||
* rather than only greater Taipei. */
|
||||
@@ -211,6 +219,74 @@ export function taiwanCameraToSource(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One Highway Bureau row -> a source whose first MJPEG frame is served as a
|
||||
* snapshot by the existing camera-frame proxy.
|
||||
*
|
||||
* @param {object} row - Output of parseTaiwanCctvXml.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function taiwanHighwayCameraToSource(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const { lat, lon } = row;
|
||||
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(row.streamUrl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!TAIWAN_HIGHWAY_CAMERA_HOSTS.has(url.hostname) ||
|
||||
!/^\/[A-Za-z0-9._+~-]{1,100}$/.test(url.pathname) ||
|
||||
url.pathname.includes('..')
|
||||
)
|
||||
return null;
|
||||
|
||||
const rawId = String(row.cctvId ?? '').trim();
|
||||
if (!/^[A-Za-z0-9-]{1,80}$/.test(rawId)) return null;
|
||||
const cameraId = 'tw-thb-' + rawId.toLowerCase();
|
||||
const road = String(row.roadName ?? '').trim();
|
||||
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
|
||||
const mile = String(row.mile ?? '').trim();
|
||||
const section = [row.sectionStart, row.sectionEnd]
|
||||
.map((part) => String(part ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join('→');
|
||||
const name =
|
||||
(road || mile || section
|
||||
? [road, direction, mile, section].filter(Boolean).join(' ')
|
||||
: '') || rawId;
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: name.slice(0, 160),
|
||||
city: 'Taiwan / 臺灣',
|
||||
cityId: 'taiwan',
|
||||
provider: 'Highway Bureau, MOTC',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
feedType: 'mjpeg',
|
||||
url: url.href,
|
||||
snapshotUrl: url.href,
|
||||
sourceKind: 'taiwan-highway-motc',
|
||||
license: 'Highway Bureau, MOTC open data',
|
||||
code: cameraDisplayCode((road + ' ' + mile).trim().toUpperCase() || rawId),
|
||||
};
|
||||
}
|
||||
let lastGood = null;
|
||||
let lastGoodAt = 0;
|
||||
let retryAfter = 0;
|
||||
@@ -237,6 +313,49 @@ function taiwanSourceCap() {
|
||||
: DEFAULT_TAIWAN_MAX_SOURCES;
|
||||
}
|
||||
|
||||
/** Fetch the public provincial-road CCTV table as a no-key fallback. */
|
||||
async function loadTaiwanHighwaySources() {
|
||||
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
||||
const response = await fetch(TAIWAN_HIGHWAY_CCTV_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'Highway Bureau catalog redirected; redirects are not followed'
|
||||
: 'Highway Bureau catalog HTTP ' + response.status,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
response,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
signal,
|
||||
);
|
||||
const cameras = new Map();
|
||||
for (const row of parseTaiwanCctvXml(xml)) {
|
||||
const camera = taiwanHighwayCameraToSource(row);
|
||||
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
||||
}
|
||||
if (!cameras.size)
|
||||
throw new Error('no usable Highway Bureau cameras in the table');
|
||||
const sources = prioritizeSources(
|
||||
[...cameras.values()],
|
||||
taiwanSourceCap(),
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
'[CCTV] Loaded Taiwan Highway Bureau fallback: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
|
||||
* One bounded request per shared catalog refresh. A failed download serves the
|
||||
@@ -262,7 +381,7 @@ export async function loadTaiwanSources() {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `HTTP ${response.status}`,
|
||||
: 'HTTP ' + response.status,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
@@ -282,7 +401,11 @@ export async function loadTaiwanSources() {
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||
'[CCTV] Loaded Taiwan camera sources: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
);
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
@@ -291,13 +414,37 @@ export async function loadTaiwanSources() {
|
||||
} catch (error) {
|
||||
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
|
||||
const fallback = fallbackSources();
|
||||
console.warn(
|
||||
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
|
||||
(fallback.length
|
||||
? `serving the last good ${fallback.length} cameras`
|
||||
: 'no Taiwan cameras') +
|
||||
', next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
if (fallback.length) {
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); serving the last good ' +
|
||||
fallback.length +
|
||||
' cameras, next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const sources = await loadTaiwanHighwaySources();
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); using ' +
|
||||
sources.length +
|
||||
' Highway Bureau cameras as fallback, next attempt in 1 h',
|
||||
);
|
||||
return sources;
|
||||
} catch (fallbackError) {
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); Highway Bureau fallback failed (' +
|
||||
(fallbackError?.message || fallbackError) +
|
||||
'); no Taiwan cameras, next attempt in 1 h',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { pinnedCameraUrl } from './normalize.js';
|
||||
|
||||
const SOURCE_POLICIES = Object.freeze({
|
||||
'tripcheck-odot': {
|
||||
idPrefix: 'world-or-',
|
||||
origin: TRIPCHECK_IMAGE_ORIGIN,
|
||||
bounds: [41, 47, -125, -116],
|
||||
filename: /^\/roadcams\/cams\/[^/]+\.jpe?g$/i,
|
||||
frameRefreshMs: 5 * 60 * 1000,
|
||||
},
|
||||
'mlit-river': {
|
||||
idPrefix: 'world-jp-river-',
|
||||
origin: MLIT_RIVER_IMAGE_ORIGIN,
|
||||
bounds: [24, 46, 122, 154],
|
||||
filename: /^\/cam\/now\/\d+\.jpg$/i,
|
||||
// The provider asks clients to avoid frequent repeated image collection.
|
||||
frameRefreshMs: 10 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// MLIT currently serves this fixed JPEG on cameras whose images are paused
|
||||
// for maintenance (verified against two Yamagata cameras on 2026-11-02).
|
||||
const MLIT_ADJUSTMENT_IMAGE_SHA256 =
|
||||
'dbd2c3790eb75edf9ca44162f7979f2a3799090e23f2c5142f63211a035cb191';
|
||||
|
||||
export function isMlitRiverAdjustmentImage(image) {
|
||||
return (
|
||||
image?.ok === true &&
|
||||
Buffer.isBuffer(image.body) &&
|
||||
createHash('sha256').update(image.body).digest('hex') ===
|
||||
MLIT_ADJUSTMENT_IMAGE_SHA256
|
||||
);
|
||||
}
|
||||
|
||||
function safeFrameUrl(value, policy) {
|
||||
const normalized = pinnedCameraUrl(value, policy.origin);
|
||||
if (!normalized) return '';
|
||||
const url = new URL(normalized);
|
||||
return !url.search && !url.hash && policy.filename.test(url.pathname)
|
||||
? url.href
|
||||
: '';
|
||||
}
|
||||
|
||||
function inBounds(lat, lon, [minLat, maxLat, minLon, maxLon]) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= minLat &&
|
||||
lat <= maxLat &&
|
||||
lon >= minLon &&
|
||||
lon <= maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the new image-backed networks from the add-on export. Existing
|
||||
* Canadian networks, Québec entries, JMA placeholder images, and NZTA's
|
||||
* scraped Journey Planner rows are deliberately excluded. Vegagerðin (Iceland)
|
||||
* rows are excluded too: the Iceland pack loads the full live list of those
|
||||
* cameras, so a World copy would draw each one twice.
|
||||
*/
|
||||
export function loadWorldCameraSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
let rows;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
rows = Array.isArray(payload) ? payload : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] World camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const sources = [];
|
||||
for (const row of rows) {
|
||||
const sourceKind = String(row?.sourceKind || '');
|
||||
const policy = SOURCE_POLICIES[sourceKind];
|
||||
if (!policy) continue;
|
||||
|
||||
const id = String(row.id || '').trim();
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
const url = safeFrameUrl(row.url || row.snapshotUrl, policy);
|
||||
const snapshotUrl = safeFrameUrl(row.snapshotUrl || row.url, policy);
|
||||
if (
|
||||
!id.startsWith(policy.idPrefix) ||
|
||||
seen.has(id) ||
|
||||
!inBounds(lat, lon, policy.bounds) ||
|
||||
!url ||
|
||||
!snapshotUrl ||
|
||||
url !== snapshotUrl
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
sources.push({
|
||||
id,
|
||||
name: String(row.name || id)
|
||||
.trim()
|
||||
.slice(0, 180),
|
||||
city: String(row.city || row.country || 'World'),
|
||||
cityId: String(row.cityId || ''),
|
||||
provider: String(row.provider || 'World traffic camera'),
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: Number.isFinite(Number(row.headingDeg))
|
||||
? Number(row.headingDeg)
|
||||
: undefined,
|
||||
headingConfidence: String(row.headingConfidence || 'low'),
|
||||
feedType: 'image',
|
||||
sourceKind,
|
||||
url,
|
||||
snapshotUrl,
|
||||
frameRefreshMs: policy.frameRefreshMs,
|
||||
license: String(row.license || ''),
|
||||
credit: String(row.credit || row.provider || ''),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[CCTV] Loaded World camera image sources: ${sources.length}/${WORLD_CCTV_SOURCE_COUNT}`,
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CALGARY_ROWS_URL,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const pack = (name, count, prefix = name) => ({
|
||||
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
|
||||
]);
|
||||
});
|
||||
|
||||
/** Shared packs plus two reserved ones, shaped like the default catalog. */
|
||||
const reservedFixture = () => [
|
||||
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
|
||||
{ ...pack('quebec', 1120), reservedCount: 1120 },
|
||||
{ ...pack('world', 2230), reservedCount: 2230 },
|
||||
];
|
||||
const keptByName = (packs) =>
|
||||
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
|
||||
|
||||
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
|
||||
assert.equal(
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS +
|
||||
QUEBEC_CAMERA_SOURCE_COUNT +
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
reservedFixture(),
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(kept.quebec, 1120);
|
||||
assert.equal(kept.world, 2230);
|
||||
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
|
||||
assert.match(
|
||||
sources[0].id,
|
||||
/^shared0-/,
|
||||
'the first camera stays in a shared pack',
|
||||
);
|
||||
});
|
||||
|
||||
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4000);
|
||||
const kept = keptByName(packs);
|
||||
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
|
||||
for (const count of Object.values(kept))
|
||||
assert.ok(count >= 181 && count <= 182);
|
||||
});
|
||||
|
||||
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
|
||||
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, 48);
|
||||
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
|
||||
assert.match(sources[0].id, /^shared0-/);
|
||||
});
|
||||
|
||||
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4100);
|
||||
const kept = keptByName(packs);
|
||||
const shared = Object.entries(kept)
|
||||
.filter(([name]) => name.startsWith('shared'))
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
|
||||
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
|
||||
assert.equal(shared + kept.quebec + kept.world, 4100);
|
||||
});
|
||||
|
||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
||||
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES,
|
||||
DEFAULT_TXDOT_MAX_SOURCES,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
@@ -222,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the shared catalog slots', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -231,15 +233,18 @@ test('default caps of the original packs fit inside the default catalog cap', ()
|
||||
DEFAULT_ONTARIO_MAX_SOURCES +
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES +
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES +
|
||||
DEFAULT_OTTAWA_MAX_SOURCES +
|
||||
DEFAULT_TXDOT_MAX_SOURCES +
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
// The reserved Québec and World packs sit above the shared floor, so the
|
||||
// shared packs must fit the floor itself, not the whole default cap.
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
||||
packs <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
loadHoustonSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
HOUSTON_CAMERAS_URL,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
@@ -387,8 +387,8 @@ test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
@@ -375,8 +375,8 @@ for (const [label, flag] of [
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
OTTAWA_CAMERAS_URL,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
_resetOttawaCertificateWarningForTest,
|
||||
loadOttawaSourcesFromOpenData,
|
||||
ottawaAuthorizedFrameUrl,
|
||||
} from '../../server/providers/cctv/ottawa.js';
|
||||
|
||||
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loader's progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** One documented `/map/service/camera` row carrying the fields the loader reads. */
|
||||
function ottawaRow(number, lat, lon, overrides = {}) {
|
||||
return {
|
||||
id: number + 10000,
|
||||
camera_number: number,
|
||||
name: `Camera ${number}`,
|
||||
name_french: `Caméra ${number}`,
|
||||
cameraOwner: 'CITY',
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The feed's envelope around its rows. */
|
||||
const ottawaPayload = (rows) => Response.json({ cameras: rows });
|
||||
|
||||
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
|
||||
_resetOttawaCertificateWarningForTest();
|
||||
const lines = [];
|
||||
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('no request expected');
|
||||
});
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
|
||||
});
|
||||
|
||||
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
|
||||
assert.equal(
|
||||
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
|
||||
);
|
||||
assert.equal(
|
||||
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
|
||||
'',
|
||||
'no certificate, no signed URL',
|
||||
);
|
||||
for (const url of [
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16x',
|
||||
'https://traffic.ottawa.ca/opendata/other?c=16',
|
||||
'https://traffic.ottawa.ca/map/camera?id=16',
|
||||
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
|
||||
'http://traffic.ottawa.ca/opendata/camera?c=16',
|
||||
])
|
||||
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
|
||||
});
|
||||
|
||||
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return ottawaPayload([
|
||||
ottawaRow(2025, 45.341343, -75.81467, {
|
||||
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
|
||||
cameraOwner: 'MTO',
|
||||
}),
|
||||
ottawaRow(1, 45.4215, -75.6972, {
|
||||
name: 'Bank St at Somerset St',
|
||||
camera_owner: 'CITY',
|
||||
cameraOwner: undefined,
|
||||
}),
|
||||
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
|
||||
ottawaRow(0, 45.4215, -75.6972),
|
||||
ottawaRow(-3, 45.4215, -75.6972),
|
||||
ottawaRow(7, null, -75.7),
|
||||
ottawaRow(8, 45.4, null),
|
||||
ottawaRow(9, 43.6532, -79.3832, { description: 'Toronto' }),
|
||||
ottawaRow(10, 45.9, -75.7),
|
||||
]);
|
||||
});
|
||||
|
||||
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
|
||||
|
||||
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'ottawa-1',
|
||||
'ottawa-2025',
|
||||
]);
|
||||
|
||||
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
|
||||
const city = cameras.find((camera) => camera.id === 'ottawa-1');
|
||||
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
|
||||
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
|
||||
assert.equal(mto.snapshotUrl, mto.url);
|
||||
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
|
||||
assert.equal(city.name, 'Bank St at Somerset St');
|
||||
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
|
||||
assert.equal(mto.city, 'Ottawa');
|
||||
assert.equal(mto.provider, 'City of Ottawa');
|
||||
assert.equal(mto.feedType, 'image');
|
||||
assert.equal(mto.sourceKind, 'ottawa-traffic');
|
||||
assert.equal(mto.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
mto.headingDeg,
|
||||
fallbackHeadingFromId('ottawa-2025'),
|
||||
'the feed publishes no bearing, so the id-hash fallback is deterministic',
|
||||
);
|
||||
assert.match(mto.license, /City of Ottawa/);
|
||||
assert.equal(
|
||||
mto.credit,
|
||||
'Ministry of Transportation of Ontario (MTO) camera',
|
||||
'a provincial camera carried on the city feed names its owner',
|
||||
);
|
||||
assert.equal(city.credit, '', 'a city camera carries no partner credit');
|
||||
});
|
||||
|
||||
test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: '8' });
|
||||
const rows = [
|
||||
// Far suburbs come first in payload order, so only distance can drop them.
|
||||
...[45.55, 45.54, 45.53, 45.52].map((lat, i) =>
|
||||
ottawaRow(100 + i, lat, -76.1),
|
||||
),
|
||||
...[45.42, 45.421, 45.422, 45.423, 45.424, 45.425, 45.426, 45.427].map(
|
||||
(lat, i) => ottawaRow(200 + i, lat, -75.7),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => ottawaPayload(rows));
|
||||
|
||||
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
|
||||
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id).filter((id) => /^ottawa-10\d$/.test(id)),
|
||||
[],
|
||||
'the far-suburb cameras are the ones dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
|
||||
quiet(t);
|
||||
let respond;
|
||||
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => Response.json({ cameras: [] }),
|
||||
async () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_OTTAWA_ENABLED: undefined,
|
||||
CCTV_OTTAWA_MAX_SOURCES: undefined,
|
||||
CCTV_QUEBEC_ENABLED: '0',
|
||||
CCTV_WORLD_CAMERAS_ENABLED: '0',
|
||||
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only Ottawa answers.
|
||||
return String(url) === OTTAWA_CAMERAS_URL
|
||||
? ottawaPayload([ottawaRow(1, 45.4215, -75.6972)])
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['ottawa-1'],
|
||||
);
|
||||
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/opendata/camera?c=1');
|
||||
|
||||
process.env.CCTV_OTTAWA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.includes(OTTAWA_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa frames stay inside the city image directory', () => {
|
||||
assert.ok(
|
||||
pinnedCameraUrl(
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=309',
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
),
|
||||
);
|
||||
for (const url of [
|
||||
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
|
||||
'https://traffic.ottawa.ca/map/camera?id=309',
|
||||
'https://user:[email protected]/opendata/camera?c=309',
|
||||
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
|
||||
'http://traffic.ottawa.ca/opendata/camera?c=309',
|
||||
])
|
||||
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
|
||||
});
|
||||
@@ -313,3 +313,234 @@ test('CCTV upstream frame fetch accepts only raster image types and sends their
|
||||
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
|
||||
}
|
||||
});
|
||||
|
||||
function multipartResponse(contentType, chunks, { onCancel = () => {} } = {}) {
|
||||
let nextChunk = 0;
|
||||
const body = new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
if (nextChunk >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunks[nextChunk++]);
|
||||
},
|
||||
cancel() {
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
}
|
||||
|
||||
function jpegPart(boundary, body, contentType = 'image/jpeg', length = body.length) {
|
||||
return Buffer.concat([
|
||||
Buffer.from(
|
||||
'--' +
|
||||
boundary +
|
||||
'\r\nContent-Type: ' +
|
||||
contentType +
|
||||
'\r\nContent-Length: ' +
|
||||
length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
body,
|
||||
Buffer.from('\r\n'),
|
||||
]);
|
||||
}
|
||||
|
||||
test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const first = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
const second = Buffer.from([0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]);
|
||||
for (const type of [
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
'multipart/x-mixed-replace; boundary="DIGIEVER"',
|
||||
]) {
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
type,
|
||||
[
|
||||
jpegPart(boundary, first),
|
||||
jpegPart(boundary, second),
|
||||
Buffer.from('--' + boundary + '--\r\n'),
|
||||
],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result?.contentType, 'image/jpeg', type);
|
||||
assert.deepEqual(result?.body, first, type);
|
||||
assert.equal(cancelled, true, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
|
||||
for (const [type, chunks] of [
|
||||
['multipart/x-mixed-replace', []],
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
[jpegPart('DIGIEVER', jpeg, 'image/png')],
|
||||
],
|
||||
]) {
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null, type);
|
||||
assert.equal(cancelled, true, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const full = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
|
||||
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
|
||||
for (const [chunks, maxBytes] of [
|
||||
[jpegPart(boundary, truncated, 'image/jpeg', full.length), 1024],
|
||||
[jpegPart(boundary, full), 5],
|
||||
]) {
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[chunks],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch does not cross into a later part when the first JPEG is truncated', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
|
||||
const valid = Buffer.from([0xff, 0xd8, 0x03, 0x04, 0xff, 0xd9]);
|
||||
const wire = Buffer.concat([
|
||||
Buffer.from(
|
||||
'--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n',
|
||||
),
|
||||
truncated,
|
||||
Buffer.from(
|
||||
'\r\n--' +
|
||||
boundary +
|
||||
'\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
|
||||
valid.length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
valid,
|
||||
Buffer.from('\r\n--' + boundary + '--\r\n'),
|
||||
]);
|
||||
let cancelled = false;
|
||||
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[wire],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[jpegPart(boundary, jpeg)],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
for (const [type, wire] of [
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
|
||||
],
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=--myboundary',
|
||||
Buffer.concat([
|
||||
Buffer.from(
|
||||
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
|
||||
jpeg.length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
jpeg,
|
||||
Buffer.from('\r\n--myboundary\r\n'),
|
||||
]),
|
||||
],
|
||||
]) {
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () => multipartResponse(type, [wire]),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result?.body, jpeg, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
// An EXIF thumbnail's own FF D9 sits inside the image.
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
|
||||
const wire = Buffer.concat([
|
||||
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
|
||||
jpeg,
|
||||
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
|
||||
]);
|
||||
// Delivered one byte at a time, as a trickling upstream would.
|
||||
const chunks = [...wire].map((byte) => Buffer.from([byte]));
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result?.body, jpeg);
|
||||
});
|
||||
@@ -182,6 +182,86 @@ test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to Highway Bureau MJPEG cameras when the Freeway catalog fails', async (t) => {
|
||||
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
const requests = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
const href = String(url);
|
||||
requests.push(href);
|
||||
assert.equal(options.redirect, 'manual');
|
||||
if (href === TAIWAN_FREEWAY_CCTV_URL)
|
||||
return new Response('', { status: 503 });
|
||||
if (href === highwayUrl) {
|
||||
return new Response(
|
||||
cctvXml(
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-002',
|
||||
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
|
||||
lat: '24.9967',
|
||||
lon: '121.4521',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'content-type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
throw new Error('Unexpected Taiwan feed URL: ' + href);
|
||||
});
|
||||
|
||||
const sources = await loadTaiwanSources();
|
||||
|
||||
assert.deepEqual(requests, [TAIWAN_FREEWAY_CCTV_URL, highwayUrl]);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0].id, 'tw-thb-cctv-14-0620-009-002');
|
||||
assert.equal(sources[0].cityId, 'taiwan');
|
||||
assert.equal(sources[0].provider, 'Highway Bureau, MOTC');
|
||||
assert.equal(sources[0].feedType, 'mjpeg');
|
||||
assert.equal(sources[0].sourceKind, 'taiwan-highway-motc');
|
||||
assert.equal(new URL(sources[0].url).hostname, 'cctv-ss02.thb.gov.tw');
|
||||
assert.equal(new URL(sources[0].url).pathname, '/T62-9K+020');
|
||||
});
|
||||
|
||||
test('the Highway Bureau fallback registers only approved HTTPS camera servers', async (t) => {
|
||||
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) === TAIWAN_FREEWAY_CCTV_URL)
|
||||
return new Response('', { status: 503 });
|
||||
if (String(url) === highwayUrl) {
|
||||
return new Response(
|
||||
cctvXml(
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-001',
|
||||
stream: 'https://cctv-ss09.thb.gov.tw/T62-9K+019',
|
||||
}),
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-003',
|
||||
stream: 'http://cctv-ss02.thb.gov.tw/T62-9K+021',
|
||||
}),
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-004',
|
||||
stream: 'https://cctv-ss02.thb.gov.tw:8443/T62-9K+022',
|
||||
}),
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-002',
|
||||
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'content-type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
throw new Error('Unexpected Taiwan feed URL: ' + String(url));
|
||||
});
|
||||
|
||||
const sources = await loadTaiwanSources();
|
||||
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['tw-thb-cctv-14-0620-009-002'],
|
||||
);
|
||||
});
|
||||
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const status of [302, 503]) {
|
||||
@@ -214,13 +294,17 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog request', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('cctv-maintain.thb.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||
});
|
||||
|
||||
@@ -250,12 +334,12 @@ test('a failed download is not retried until the backoff ends', async (t) => {
|
||||
t.mock.method(Date, 'now', () => 1_000_000);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.equal(calls, 1, 'the second refresh made no request');
|
||||
assert.equal(calls, 2, 'the second refresh made no request');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
|
||||
);
|
||||
await loadTaiwanSources();
|
||||
assert.equal(calls, 2, 'the backoff ends after an hour');
|
||||
assert.equal(calls, 4, 'the backoff ends after an hour');
|
||||
});
|
||||
|
||||
test('a failed download serves the last good table', async (t) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
isMlitRiverAdjustmentImage,
|
||||
loadWorldCameraSources,
|
||||
} from '../../server/providers/cctv/worldCameras.js';
|
||||
import {
|
||||
loadQuebecSources,
|
||||
QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
quebec511VideoUrl,
|
||||
quebecBrowserVideoEnabled,
|
||||
} from '../../server/providers/cctv/quebec.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** A temporary source root holding the given World and Québec catalogs. */
|
||||
function sourceRoot(t, { world = [], quebec = null } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
|
||||
fs.writeFileSync(worldFile, JSON.stringify(world));
|
||||
if (quebec) {
|
||||
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'public/quebec-cameras.json'),
|
||||
JSON.stringify({ cameras: quebec }),
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const oregonRow = (overrides = {}) => ({
|
||||
id: 'world-or-764',
|
||||
name: '3rd St at Harwood MP18.35',
|
||||
provider: 'TripCheck (ODOT)',
|
||||
lat: 44.3029,
|
||||
lon: -120.8549,
|
||||
sourceKind: 'tripcheck-odot',
|
||||
url: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
|
||||
snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('the shipped World catalog loads every curated row', (t) => {
|
||||
quiet(t);
|
||||
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
|
||||
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
|
||||
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
|
||||
// Iceland comes from the live Iceland pack, never a second World copy.
|
||||
assert.equal(byKind['vegagerdin-road'], undefined);
|
||||
assert.equal(byKind['tripcheck-odot'].length, 1176);
|
||||
assert.equal(byKind['mlit-river'].length, 1054);
|
||||
for (const source of byKind['mlit-river'])
|
||||
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
|
||||
quiet(t);
|
||||
const rejected = [
|
||||
['unknown kind', { sourceKind: 'nzta-journeys' }],
|
||||
[
|
||||
'Iceland duplicates the Iceland pack',
|
||||
{
|
||||
sourceKind: 'vegagerdin-road',
|
||||
lat: 64.291877,
|
||||
lon: -15.028035,
|
||||
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
|
||||
snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
|
||||
},
|
||||
],
|
||||
['wrong id prefix', { id: 'world-jp-river-7075' }],
|
||||
['outside bounds', { lat: 40, lon: -15 }],
|
||||
['other host', { url: 'https://evil.test/roadcams/cams/a.jpg' }],
|
||||
['other directory', { url: 'https://www.tripcheck.com/other/a.jpg' }],
|
||||
['query string', { url: oregonRow().url + '?x=1' }],
|
||||
['not a jpg', { url: 'https://www.tripcheck.com/roadcams/cams/a.png' }],
|
||||
['plain http', { url: oregonRow().url.replace('https:', 'http:') }],
|
||||
[
|
||||
'url and snapshot differ',
|
||||
{ snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/b.jpg' },
|
||||
],
|
||||
];
|
||||
const root = sourceRoot(t, {
|
||||
world: [
|
||||
oregonRow(),
|
||||
oregonRow(), // duplicate id
|
||||
...rejected.map(([, overrides], i) =>
|
||||
oregonRow({ id: `world-or-${9000 + i}`, ...overrides }),
|
||||
),
|
||||
],
|
||||
});
|
||||
const sources = loadWorldCameraSources({ sourceRoot: root });
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['world-or-764'],
|
||||
);
|
||||
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
|
||||
assert.equal(sources[0].feedType, 'image');
|
||||
});
|
||||
|
||||
test('the World loader fails soft on a missing or malformed catalog', (t) => {
|
||||
quiet(t);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
|
||||
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, '{not json');
|
||||
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
|
||||
});
|
||||
|
||||
test('only the exact MLIT adjustment image is recognised', () => {
|
||||
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
|
||||
assert.equal(isMlitRiverAdjustmentImage(null), false);
|
||||
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
|
||||
});
|
||||
|
||||
test('the shipped Québec catalog loads every location', (t) => {
|
||||
quiet(t);
|
||||
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
|
||||
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
|
||||
for (const source of sources) {
|
||||
assert.equal(source.url, '', 'the server never fetches Québec media');
|
||||
assert.equal(source.snapshotUrl, '');
|
||||
}
|
||||
});
|
||||
|
||||
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
|
||||
quiet(t);
|
||||
const at = { lat: 45.51651, lon: -73.5502 };
|
||||
const root = sourceRoot(t, {
|
||||
quebec: [
|
||||
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
|
||||
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
|
||||
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
|
||||
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
|
||||
{ id: '3735', kind: 'traffic', ...at }, // duplicate
|
||||
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
|
||||
],
|
||||
});
|
||||
const sources = loadQuebecSources({ sourceRoot: root });
|
||||
const byId = new Map(sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
|
||||
|
||||
const mtq = byId.get('qc-511-3735');
|
||||
assert.equal(mtq.sourceKind, 'quebec-511-browser');
|
||||
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
|
||||
assert.match(mtq.credit, /Video: Québec 511/);
|
||||
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
|
||||
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
|
||||
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
|
||||
assert.equal(
|
||||
quebec511VideoUrl('qc-511-3735'),
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
|
||||
);
|
||||
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
|
||||
});
|
||||
|
||||
test('Québec 511 browser video is opt-in', () => {
|
||||
assert.equal(quebecBrowserVideoEnabled({}), false);
|
||||
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
|
||||
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
|
||||
});
|
||||
|
||||
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
|
||||
function mount(t, root, respond) {
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: 'absent-source-file.json',
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: '0',
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
// A configured key makes any Street View fallback observable.
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
|
||||
});
|
||||
const requests = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
|
||||
requests.push(String(url));
|
||||
return respond(String(url), init) || new Response('down', { status: 503 });
|
||||
});
|
||||
let handler;
|
||||
cctvProxy({ sourceRoot: root }).configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const call = async (url) => {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
};
|
||||
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
|
||||
return res;
|
||||
};
|
||||
return { call, requests };
|
||||
}
|
||||
|
||||
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t, {
|
||||
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
|
||||
});
|
||||
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
|
||||
const app = mount(t, root, () => null);
|
||||
const off = JSON.parse((await app.call('/sources')).body).sources;
|
||||
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
|
||||
|
||||
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
|
||||
const on = JSON.parse((await app.call('/sources')).body).sources;
|
||||
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
|
||||
});
|
||||
|
||||
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t, { world: [oregonRow()] });
|
||||
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
|
||||
const app = mount(t, root, (url) =>
|
||||
url === oregonRow().url ? answer() : null,
|
||||
);
|
||||
const frame = '/frame/world-or-764';
|
||||
const upstreamHits = () =>
|
||||
app.requests.filter((url) => url === oregonRow().url).length;
|
||||
|
||||
const first = await app.call(frame);
|
||||
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const second = await app.call(frame);
|
||||
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
|
||||
|
||||
// A camera whose first fetch fails is inside its failure backoff: it gets
|
||||
// the placeholder, not Google Street View, and no second upstream request.
|
||||
const failRoot = sourceRoot(t, { world: [oregonRow()] });
|
||||
answer = () => new Response('down', { status: 503 });
|
||||
const failing = mount(t, failRoot, (url) =>
|
||||
url === oregonRow().url ? answer() : null,
|
||||
);
|
||||
const failed = await failing.call(frame);
|
||||
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const retry = await failing.call(frame);
|
||||
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.equal(
|
||||
failing.requests.filter((url) => url === oregonRow().url).length,
|
||||
1,
|
||||
'the backoff holds the provider cadence',
|
||||
);
|
||||
assert.equal(
|
||||
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
|
||||
1,
|
||||
'only the first miss, outside any cooldown, may try Street View',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t);
|
||||
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
|
||||
const app = mount(t, root, (url) => {
|
||||
if (url === 'https://traffic.ottawa.ca/map/service/camera')
|
||||
return Response.json({
|
||||
cameras: [
|
||||
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
|
||||
],
|
||||
});
|
||||
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
|
||||
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
|
||||
return null;
|
||||
});
|
||||
const sources = JSON.parse((await app.call('/sources')).body).sources;
|
||||
const ottawa = sources.find((source) => source.id === 'ottawa-16');
|
||||
assert.ok(ottawa);
|
||||
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
|
||||
|
||||
const frame = await app.call('/frame/ottawa-16');
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
await app.call('/frame/ottawa-16');
|
||||
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
|
||||
assert.deepEqual(hits, [
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
|
||||
], 'one signed request per camera per minute');
|
||||
});
|
||||
@@ -299,6 +299,38 @@ export const DATA_CREDITS = [
|
||||
'CCTV static data (MOTC 2.0), released under the ' +
|
||||
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
|
||||
},
|
||||
{
|
||||
key: 'ottawa-cctv',
|
||||
html:
|
||||
'Traffic cameras (Ottawa, with an access certificate): ' +
|
||||
'<a href="https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data" target="_blank" rel="noopener">City of Ottawa Traffic Camera Images API</a>',
|
||||
},
|
||||
{
|
||||
key: 'quebec-cctv',
|
||||
html:
|
||||
'Camera locations (Québec): Ministère des Transports et de la Mobilité durable via ' +
|
||||
'<a href="https://www.donneesquebec.ca/" target="_blank" rel="noopener">Données Québec</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
|
||||
'Montréal camera locations © OpenStreetMap contributors (ODbL). ' +
|
||||
'Video (when enabled) and linked webcam pages stay with ' +
|
||||
'<a href="https://www.quebec511.info/" target="_blank" rel="noopener">Québec 511</a> and each operator, under their own terms',
|
||||
},
|
||||
{
|
||||
key: 'tripcheck-cctv',
|
||||
html:
|
||||
'Road camera stills (Oregon): camera courtesy of ODOT, via ' +
|
||||
'<a href="https://www.tripcheck.com/" target="_blank" rel="noopener">TripCheck</a>; ' +
|
||||
'partner cameras (counties, cities, Caltrans, WSDOT) are named in the camera label',
|
||||
},
|
||||
{
|
||||
key: 'mlit-river-cctv',
|
||||
html:
|
||||
'River camera stills (Japan): 出典:国土交通省 ' +
|
||||
'<a href="https://www.river.go.jp/" target="_blank" rel="noopener">川の防災情報</a> ' +
|
||||
'(Ministry of Land, Infrastructure, Transport and Tourism; ' +
|
||||
'<a href="https://www.river.go.jp/kawabou/kwb_apend/html/caution.html" target="_blank" rel="noopener">terms</a>), ' +
|
||||
'fetched on view, at most every ten minutes per camera',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
|
||||
@@ -153,3 +153,17 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the PR #1506 camera packs are credited with their sources and terms', () => {
|
||||
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
|
||||
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
|
||||
assert.match(html('quebec-cctv') || '', /CC BY 4\.0/);
|
||||
assert.match(html('quebec-cctv') || '', /OpenStreetMap contributors/);
|
||||
assert.match(html('tripcheck-cctv') || '', /courtesy of ODOT/);
|
||||
// MLIT's terms ask for the source in this form, with the page link.
|
||||
assert.match(html('mlit-river-cctv') || '', /出典:国土交通省/);
|
||||
assert.match(
|
||||
html('mlit-river-cctv') || '',
|
||||
/href="https:\/\/www\.river\.go\.jp\//,
|
||||
);
|
||||
});
|
||||
@@ -3480,5 +3480,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||
"event age filter|All": "الكل",
|
||||
"Event age": "عمر الحدث",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
|
||||
"Close camera preview": "إغلاق معاينة الكاميرا",
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
|
||||
}
|
||||
@@ -3180,5 +3180,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||
"event age filter|All": "Alle",
|
||||
"Event age": "Ereignisalter",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
|
||||
"Close camera preview": "Kameravorschau schließen",
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
|
||||
}
|
||||
@@ -3255,5 +3255,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Antigüedad",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
|
||||
"Close camera preview": "Cerrar vista previa de la cámara",
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
|
||||
}
|
||||
@@ -3255,5 +3255,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||
"Close camera preview": "Fermer l’aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
|
||||
}
|
||||
@@ -3255,5 +3255,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||
"event age filter|All": "Tutti",
|
||||
"Event age": "Età degli eventi",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
|
||||
"Close camera preview": "Chiudi anteprima della telecamera",
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
|
||||
}
|
||||
@@ -3105,5 +3105,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
|
||||
"event age filter|All": "すべて",
|
||||
"Event age": "経過日数",
|
||||
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
|
||||
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持",
|
||||
"Close camera preview": "カメラのプレビューを閉じる",
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
|
||||
}
|
||||
@@ -3255,5 +3255,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Idade do evento",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos",
|
||||
"Close camera preview": "Fechar prévia da câmera",
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP."
|
||||
}
|
||||
@@ -3330,5 +3330,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||
"event age filter|All": "Все",
|
||||
"Event age": "Возраст события",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены",
|
||||
"Close camera preview": "Закрыть просмотр камеры",
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
|
||||
}
|
||||
@@ -3330,5 +3330,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||
"event age filter|All": "Усі",
|
||||
"Event age": "Вік події",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено",
|
||||
"Close camera preview": "Закрити перегляд камери",
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
|
||||
}
|
||||
@@ -3105,5 +3105,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件时间",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记",
|
||||
"Close camera preview": "关闭摄像头预览",
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
|
||||
}
|
||||
@@ -3105,5 +3105,9 @@
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件時間",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記",
|
||||
"Close camera preview": "關閉攝影機預覽",
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
|
||||
}
|
||||
@@ -132,9 +132,11 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
record.camera.lon,
|
||||
),
|
||||
inView,
|
||||
isVideo: parts.model.isVideoFeedType(
|
||||
parts.model.normalizeFeedType(record.camera.feedType),
|
||||
),
|
||||
// Québec's browser-only sources have no registered frame endpoint;
|
||||
// keep them out of still-card fill just like video feeds.
|
||||
isVideo:
|
||||
parts.model.isVideoFeedCamera(record.camera) ||
|
||||
parts.model.isBrowserOverlayCamera(record.camera),
|
||||
sx,
|
||||
sy,
|
||||
});
|
||||
|
||||
@@ -237,9 +237,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
groundElevationM,
|
||||
absoluteHeightM: groundElevationM + mountHeightM,
|
||||
pitchDeg,
|
||||
frameRefreshMs: parts.model.safeNumber(source.frameRefreshMs, NaN),
|
||||
license: String(source.license || source.licenseNote || ''),
|
||||
credit: String(source.credit || ''),
|
||||
code: String(source.code || ''),
|
||||
sourceUrl: String(source.sourceUrl || ''),
|
||||
browserMediaUrl: String(source.browserMediaUrl || ''),
|
||||
accuracyM: parts.model.safeNumber(source.accuracyM, NaN),
|
||||
positionSource: String(source.positionSource || ''),
|
||||
// Shipped precompute (see server/providers/cctv/groundHeights.js).
|
||||
groundHeights:
|
||||
source.groundHeights && typeof source.groundHeights === 'object'
|
||||
|
||||
@@ -255,11 +255,20 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
// clears this latch so the next normal tick can refresh.
|
||||
if (runtime.imageLoading) return;
|
||||
const now = Date.now();
|
||||
const refreshMs =
|
||||
const defaultRefreshMs =
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? PROJECTION_ACTIVE_REFRESH_MS
|
||||
: PROJECTION_IDLE_REFRESH_MS;
|
||||
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
|
||||
const sourceRefreshMs = parts.model.safeNumber(
|
||||
record.camera.frameRefreshMs,
|
||||
0,
|
||||
);
|
||||
const refreshMs = Math.max(defaultRefreshMs, sourceRefreshMs);
|
||||
const sinceLastRefresh = now - runtime.lastImageRefreshAt;
|
||||
// Forced activation may skip the normal active interval, but it cannot
|
||||
// bypass a provider's explicit minimum between image requests.
|
||||
if (sourceRefreshMs > 0 && sinceLastRefresh < sourceRefreshMs) return;
|
||||
if (!force && sinceLastRefresh < refreshMs) return;
|
||||
runtime.lastImageRefreshAt = now;
|
||||
|
||||
const frameUrl = frameUrlFor(record.camera, refreshMs);
|
||||
|
||||
@@ -49,9 +49,8 @@ export function createHover({ state: layerState, services, parts, source }) {
|
||||
!!record &&
|
||||
cameraId !== layerState._activeCameraId &&
|
||||
!layerState._cardIds.has(cameraId) &&
|
||||
!parts.model.isVideoFeedType(
|
||||
parts.model.normalizeFeedType(record.camera.feedType),
|
||||
);
|
||||
!parts.model.isBrowserOverlayCamera(record.camera) &&
|
||||
!parts.model.isVideoFeedCamera(record.camera);
|
||||
if (eligible) {
|
||||
cancelHoverRelease();
|
||||
layerState._hoverCardId = cameraId;
|
||||
|
||||
@@ -96,6 +96,19 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm';
|
||||
}
|
||||
|
||||
/** A browser-only source still counts as live-capable for map icon/LOD policy. */
|
||||
function isVideoFeedCamera(camera) {
|
||||
return (
|
||||
isVideoFeedType(normalizeFeedType(camera?.feedType)) ||
|
||||
Boolean(camera?.browserMediaUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/** Québec source pages and browser-played MP4s use the DOM overlay path. */
|
||||
function isBrowserOverlayCamera(camera) {
|
||||
return String(camera?.sourceKind || '').startsWith('quebec-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a value to a finite number or returns the fallback.
|
||||
*
|
||||
@@ -447,7 +460,12 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function projectionLoopIsNeeded() {
|
||||
if (!layerState._enabled || !layerState._viewer) return false;
|
||||
if (layerState._showProjection && parts.selection.getActiveRecord())
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (
|
||||
layerState._showProjection &&
|
||||
active &&
|
||||
!isBrowserOverlayCamera(active.camera)
|
||||
)
|
||||
return true;
|
||||
return focusPassIsNeeded(
|
||||
getFocusTarget(),
|
||||
@@ -553,6 +571,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
angularDeltaAbs,
|
||||
normalizeFeedType,
|
||||
isVideoFeedType,
|
||||
isVideoFeedCamera,
|
||||
isBrowserOverlayCamera,
|
||||
safeNumber,
|
||||
headingFromId,
|
||||
quantize,
|
||||
|
||||
@@ -160,9 +160,14 @@ export function createPresentation({
|
||||
const camera = record.camera;
|
||||
const health = layerState._healthById.get(camera.id) || null;
|
||||
const isActive = camera.id === resolvedActiveId;
|
||||
const refreshMs = isActive
|
||||
const defaultRefreshMs = isActive
|
||||
? ACTIVE_FRAME_REFRESH_MS
|
||||
: IDLE_FRAME_REFRESH_MS;
|
||||
const sourceRefreshMs = Number(camera.frameRefreshMs);
|
||||
const refreshMs =
|
||||
Number.isFinite(sourceRefreshMs) && sourceRefreshMs > 0
|
||||
? Math.max(defaultRefreshMs, sourceRefreshMs)
|
||||
: defaultRefreshMs;
|
||||
return {
|
||||
id: camera.id,
|
||||
name: camera.name,
|
||||
@@ -192,13 +197,18 @@ export function createPresentation({
|
||||
mountHeightM: camera.mountHeightM,
|
||||
active: isActive,
|
||||
feedType: camera.feedType,
|
||||
sourceUrl: camera.sourceUrl || '',
|
||||
browserMediaUrl: camera.browserMediaUrl || '',
|
||||
accuracyM: Number.isFinite(camera.accuracyM) ? camera.accuracyM : null,
|
||||
positionSource: camera.positionSource || '',
|
||||
isVideo:
|
||||
parts.model.isVideoFeedType(camera.feedType) &&
|
||||
parts.model.isVideoFeedCamera(camera) &&
|
||||
record.projection?.mode !== 'image',
|
||||
sourceKind:
|
||||
health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
(camera.feedConfigured ? 'configured' : 'seed'),
|
||||
sourceKind: camera.sourceKind?.startsWith('quebec-')
|
||||
? camera.sourceKind
|
||||
: health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
(camera.feedConfigured ? 'configured' : 'seed'),
|
||||
sourceStatus: health?.status || 'unknown',
|
||||
sourceMessage: health?.message || '',
|
||||
sourceLabel: health?.label || camera.provider || '',
|
||||
@@ -276,6 +286,7 @@ export function createPresentation({
|
||||
hoverId: layerState._hoverCardId,
|
||||
},
|
||||
activeCameraId: activeId,
|
||||
activeCameraReselection: layerState._activeCameraReselection,
|
||||
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
||||
cameraIndex: cameraIndex(),
|
||||
summary: buildSummaryText(),
|
||||
|
||||
@@ -171,6 +171,9 @@ export function createProjection({
|
||||
|
||||
function createProjectionRuntime(record) {
|
||||
if (!layerState._viewer) return null;
|
||||
// Québec 511 denies CORS pixel access; Québec map previews are native DOM
|
||||
// videos positioned over the globe, never Cesium video textures.
|
||||
if (parts.model.isBrowserOverlayCamera(record.camera)) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECTION_CANVAS_WIDTH;
|
||||
canvas.height = PROJECTION_CANVAS_HEIGHT;
|
||||
|
||||
@@ -35,8 +35,7 @@ export function createRendering({
|
||||
function cameraColorFor(record) {
|
||||
if (record.camera.id === layerState._activeCameraId)
|
||||
return ACTIVE_CAMERA_COLOR;
|
||||
if (parts.model.isVideoFeedType(record.camera.feedType))
|
||||
return LIVE_CAMERA_COLOR;
|
||||
if (parts.model.isVideoFeedCamera(record.camera)) return LIVE_CAMERA_COLOR;
|
||||
return IDLE_CAMERA_COLOR;
|
||||
}
|
||||
|
||||
@@ -405,8 +404,9 @@ export function createRendering({
|
||||
if (
|
||||
layerState._enabled &&
|
||||
isActive &&
|
||||
!parts.model.isBrowserOverlayCamera(record.camera) &&
|
||||
(layerState._showProjection ||
|
||||
parts.model.isVideoFeedType(record.camera.feedType))
|
||||
parts.model.isVideoFeedCamera(record.camera))
|
||||
) {
|
||||
parts.projection.ensureProjectionRuntime(record);
|
||||
}
|
||||
@@ -416,7 +416,8 @@ export function createRendering({
|
||||
const planeShowing = !!(
|
||||
layerState._enabled &&
|
||||
layerState._showProjection &&
|
||||
isActive
|
||||
isActive &&
|
||||
!parts.model.isBrowserOverlayCamera(record.camera)
|
||||
);
|
||||
|
||||
const inVisibleSet = coverageVisible.has(record.camera.id);
|
||||
|
||||
@@ -56,7 +56,12 @@ test('focus de-emphasis fades each icon from its own color', () => {
|
||||
const { refreshCctvFocusStyles } = createRendering({
|
||||
state,
|
||||
services: { focus },
|
||||
parts: { model: { isVideoFeedType } },
|
||||
parts: {
|
||||
model: {
|
||||
isVideoFeedCamera: (camera) =>
|
||||
isVideoFeedType(camera.feedType) || Boolean(camera.browserMediaUrl),
|
||||
},
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ export function createSelection({
|
||||
record,
|
||||
)
|
||||
) {
|
||||
// Browser-only feeds have a separate player; a reselect retries it
|
||||
// without re-running activation or touching the projection geometry.
|
||||
if (parts.model.isBrowserOverlayCamera(record.camera)) {
|
||||
layerState._activeCameraReselection += 1;
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
return CCTV_ACTIVATION_RESULT.UNCHANGED;
|
||||
}
|
||||
layerState._activeCameraId = cameraId;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CCTV_ACTIVATION_RESULT } from '../../cctvFocusRequest.js';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
function makeSelection(sourceKind) {
|
||||
const camera = { id: 'same-camera', sourceKind };
|
||||
const record = { camera };
|
||||
const state = {
|
||||
_activeCameraId: camera.id,
|
||||
_activeCameraReselection: 0,
|
||||
_recordById: new Map([[camera.id, record]]),
|
||||
};
|
||||
let notifications = 0;
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
activation: { CCTV_ACTIVATION_RESULT },
|
||||
picking: { resolvePickId: () => null },
|
||||
},
|
||||
parts: {
|
||||
model: {
|
||||
cctvRecordNeedsActivation: () => false,
|
||||
isBrowserOverlayCamera: (value) =>
|
||||
String(value?.sourceKind || '').startsWith('quebec-'),
|
||||
},
|
||||
presentation: { notifyListeners: () => notifications++ },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return { selection, state, notifications: () => notifications };
|
||||
}
|
||||
|
||||
test('same-camera selection does not notify or mutate snapshot-feed state', () => {
|
||||
const fixture = makeSelection('tripcheck-odot');
|
||||
|
||||
assert.equal(
|
||||
fixture.selection.setActiveCamera('same-camera'),
|
||||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||
);
|
||||
assert.equal(fixture.state._activeCameraReselection, 0);
|
||||
assert.equal(fixture.notifications(), 0);
|
||||
});
|
||||
|
||||
test('same-camera selection notifies browser-overlay feeds so playback can retry', () => {
|
||||
const fixture = makeSelection('quebec-511-browser');
|
||||
|
||||
assert.equal(
|
||||
fixture.selection.setActiveCamera('same-camera'),
|
||||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||
);
|
||||
assert.equal(fixture.state._activeCameraReselection, 1);
|
||||
assert.equal(fixture.notifications(), 1);
|
||||
});
|
||||
@@ -29,6 +29,10 @@ export function createState({ services }) {
|
||||
|
||||
layerState._activeCameraId = null;
|
||||
|
||||
// Advances when a user reselects the already-active camera. The geometry
|
||||
// activation remains a no-op, but browser-only camera UI can retry playback.
|
||||
layerState._activeCameraReselection = 0;
|
||||
|
||||
layerState._coverageMode = 'on';
|
||||
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
|
||||
|
||||
|
||||
@@ -1154,6 +1154,7 @@ export class StyleManager extends ShellFacade {
|
||||
_cctvSummary: this._cctvSummary,
|
||||
},
|
||||
cctv: cctvLayer,
|
||||
viewer: this.viewer,
|
||||
actions: {
|
||||
isEnabled: () => this._dataManager?.isEnabled('cctv'),
|
||||
setParams: (params, options) =>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useCctvStore } from './stores/cctv.js';
|
||||
import { useStatusStore } from './stores/status.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
import { onLocaleChange } from '../i18n/index.js';
|
||||
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
|
||||
|
||||
/**
|
||||
* Own camera-panel interaction and presentation; receive the camera port and
|
||||
@@ -34,6 +35,7 @@ export class CctvControls {
|
||||
constructor({
|
||||
elements,
|
||||
cctv,
|
||||
viewer = null,
|
||||
actions,
|
||||
store = useCctvStore(applicationPinia()),
|
||||
statusStore = useStatusStore(applicationPinia()),
|
||||
@@ -42,11 +44,13 @@ export class CctvControls {
|
||||
this.store = store;
|
||||
this.statusStore = statusStore;
|
||||
this.cctv = cctv;
|
||||
this._quebecCctvOverlay = createQuebecCctvOverlay(viewer);
|
||||
this.actions = actions;
|
||||
this.destroyed = false;
|
||||
this.listeners = new AbortController();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._cctvState = null;
|
||||
this._lastQuebecSelectionRevision = null;
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
@@ -146,6 +150,7 @@ export class CctvControls {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._offLocale();
|
||||
this._quebecCctvOverlay?.dispose();
|
||||
this._cctvVideoSurface?.stop();
|
||||
this._videoVisibilityObserver?.disconnect();
|
||||
this._actionGeneration++;
|
||||
|
||||
@@ -154,6 +154,20 @@ export function _renderCctvState(state) {
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
const selectionRevision = Number(state?.activeCameraReselection);
|
||||
const reselected =
|
||||
this._lastQuebecSelectionRevision !== null &&
|
||||
Number.isFinite(selectionRevision) &&
|
||||
selectionRevision !== this._lastQuebecSelectionRevision;
|
||||
this._lastQuebecSelectionRevision = Number.isFinite(selectionRevision)
|
||||
? selectionRevision
|
||||
: null;
|
||||
const isQuebecCamera =
|
||||
enabled && activeCamera?.sourceKind?.startsWith('quebec-');
|
||||
this._quebecCctvOverlay?.setCamera(isQuebecCamera ? activeCamera : null, {
|
||||
reselected,
|
||||
});
|
||||
if (this._cctvFrameWrap) this._cctvFrameWrap.hidden = Boolean(isQuebecCamera);
|
||||
|
||||
// Auto-expand the panel when the active camera CHANGES to a new non-null
|
||||
// id while the layer is enabled. Covers click-on-globe, panel controls,
|
||||
@@ -241,7 +255,8 @@ export function _renderCctvState(state) {
|
||||
|
||||
this.store.publish(presentCctvPanel(state, enabled));
|
||||
|
||||
const liveIntent = enabled && !!activeCamera?.isVideo;
|
||||
const liveIntent =
|
||||
enabled && !!activeCamera?.isVideo && !activeCamera?.browserMediaUrl;
|
||||
this.store.setLive(liveIntent);
|
||||
if (this._cctvVideo) {
|
||||
const visible =
|
||||
@@ -261,8 +276,9 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
if (this._cctvFrame && !liveIntent) {
|
||||
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
||||
const nextCameraId = enabled ? activeCamera?.id || '' : '';
|
||||
const nextSrc = enabled && !isQuebecCamera ? activeCamera?.frameUrl : null;
|
||||
const nextCameraId =
|
||||
enabled && !isQuebecCamera ? activeCamera?.id || '' : '';
|
||||
const cameraChanged = this._frame.cameraId !== nextCameraId;
|
||||
const frameLoading = this._frame.loading;
|
||||
// A same-camera refresh waits for the current image to settle. Replacing
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../data/iconOrientation.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
function isQuebecCamera(camera) {
|
||||
return String(camera?.sourceKind || '').startsWith('quebec-');
|
||||
}
|
||||
|
||||
function safeSourceUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return url.protocol === 'https:' && !url.username && !url.password
|
||||
? url.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function safeMediaUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.hostname !== 'www.quebec511.info' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== '/Carte/Fenetres/camera.ashx' ||
|
||||
url.searchParams.get('format') !== 'mp4' ||
|
||||
!/^\d+$/.test(url.searchParams.get('id') || '')
|
||||
)
|
||||
return '';
|
||||
return url.href;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setMedia(video, url) {
|
||||
const current = video.currentSrc || video.src;
|
||||
if (current === url) return;
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
if (!url) {
|
||||
video.load();
|
||||
return;
|
||||
}
|
||||
video.src = url;
|
||||
video.load();
|
||||
}
|
||||
|
||||
/** Treat an interrupted play() (a pause or a new source) as no failure. */
|
||||
function onPlayFailure(mark) {
|
||||
return (error) => {
|
||||
if (error?.name !== 'AbortError') mark();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Québec 511 serves a new clip at the same URL every few minutes, so a refresh
|
||||
* adds a timestamp to get past the browser's media cache.
|
||||
*/
|
||||
function mediaRefreshUrl(value) {
|
||||
const url = new URL(value);
|
||||
url.searchParams.set('_', String(Date.now()));
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the active Québec source to a floating browser player and map
|
||||
* preview. The browser contacts Québec 511 only after the viewer presses Play
|
||||
* for that camera; selecting it (by click, auto-hop, a scene or voice) only
|
||||
* shows its details and the play control.
|
||||
*/
|
||||
export function createQuebecCctvOverlay(viewer) {
|
||||
if (!viewer?.scene || typeof document === 'undefined') return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'qci-panel';
|
||||
panel.hidden = true;
|
||||
panel.setAttribute('role', 'region');
|
||||
panel.setAttribute('aria-label', t('CCTV camera'));
|
||||
|
||||
const header = document.createElement('header');
|
||||
header.className = 'qci-header';
|
||||
const title = document.createElement('strong');
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.textContent = '×';
|
||||
close.setAttribute('aria-label', t('Close camera preview'));
|
||||
header.append(title, close);
|
||||
|
||||
const coordinates = document.createElement('p');
|
||||
coordinates.className = 'qci-coordinates';
|
||||
const credit = document.createElement('p');
|
||||
credit.className = 'qci-credit';
|
||||
const live = document.createElement('video');
|
||||
live.className = 'qci-live';
|
||||
live.controls = true;
|
||||
live.muted = true;
|
||||
live.autoplay = true;
|
||||
live.loop = true;
|
||||
live.playsInline = true;
|
||||
live.setAttribute('aria-label', t('CCTV camera'));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'qci-actions';
|
||||
const play = document.createElement('button');
|
||||
play.type = 'button';
|
||||
play.className = 'qci-play';
|
||||
play.textContent = t('Play');
|
||||
const refresh = document.createElement('button');
|
||||
refresh.type = 'button';
|
||||
refresh.textContent = t('Refresh map still');
|
||||
actions.append(play, refresh);
|
||||
const note = document.createElement('p');
|
||||
note.className = 'qci-note';
|
||||
const sourceLink = document.createElement('a');
|
||||
sourceLink.target = '_blank';
|
||||
sourceLink.rel = 'noopener noreferrer';
|
||||
sourceLink.textContent = t("Open this image on the provider's site");
|
||||
panel.append(header, coordinates, credit, live, actions, note, sourceLink);
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'qci-marker';
|
||||
marker.hidden = true;
|
||||
const still = document.createElement('video');
|
||||
still.className = 'qci-marker-still';
|
||||
still.muted = true;
|
||||
still.playsInline = true;
|
||||
still.preload = 'none';
|
||||
still.setAttribute('aria-hidden', 'true');
|
||||
const markerLabel = document.createElement('span');
|
||||
markerLabel.className = 'qci-marker-label';
|
||||
marker.append(still, markerLabel);
|
||||
document.body.append(panel, marker);
|
||||
|
||||
let selected = null;
|
||||
let selectedPosition = null;
|
||||
let closedWindowCameraId = null;
|
||||
let selectedMediaUrl = '';
|
||||
/** Camera id the viewer pressed Play for; media loads only for this one. */
|
||||
let playingCameraId = null;
|
||||
let mediaRefreshTimer = 0;
|
||||
let removeRender = null;
|
||||
let canvasRect = null;
|
||||
let liveUnavailable = false;
|
||||
let stillUnavailable = false;
|
||||
|
||||
function markerLabelText(camera, hasMedia) {
|
||||
return hasMedia
|
||||
? `${t('PREVIEW')} · ${camera.positionSource || camera.name}`
|
||||
: Number.isFinite(camera.accuracyM) && camera.accuracyM > 0
|
||||
? `${camera.name} · ±${Math.round(camera.accuracyM)} m`
|
||||
: camera.name;
|
||||
}
|
||||
|
||||
function markLiveUnavailable() {
|
||||
liveUnavailable = true;
|
||||
note.textContent = t('Source unavailable');
|
||||
note.hidden = false;
|
||||
}
|
||||
|
||||
function markStillUnavailable() {
|
||||
stillUnavailable = true;
|
||||
markerLabel.textContent = t('Source unavailable');
|
||||
}
|
||||
|
||||
function startRender() {
|
||||
if (!removeRender)
|
||||
removeRender = viewer.scene.postRender.addEventListener(updateMarker);
|
||||
}
|
||||
|
||||
function stopRender() {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
}
|
||||
|
||||
function stopMediaRefresh() {
|
||||
window.clearInterval(mediaRefreshTimer);
|
||||
mediaRefreshTimer = 0;
|
||||
}
|
||||
|
||||
function startMediaRefresh() {
|
||||
stopMediaRefresh();
|
||||
if (!selectedMediaUrl) return;
|
||||
mediaRefreshTimer = window.setInterval(
|
||||
() => {
|
||||
if (document.hidden || closedWindowCameraId === selected?.id) return;
|
||||
setMedia(live, mediaRefreshUrl(selectedMediaUrl));
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
refreshStill();
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
function refreshStill() {
|
||||
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
|
||||
setMedia(still, '');
|
||||
setMedia(still, mediaRefreshUrl(selectedMediaUrl));
|
||||
void still.play().catch(onPlayFailure(markStillUnavailable));
|
||||
}
|
||||
|
||||
/** Load and play the selected camera's media: only from a Play click. */
|
||||
function startPlayback() {
|
||||
if (!selected || !selectedMediaUrl) return;
|
||||
playingCameraId = selected.id;
|
||||
liveUnavailable = false;
|
||||
stillUnavailable = false;
|
||||
play.hidden = true;
|
||||
refresh.hidden = false;
|
||||
live.hidden = false;
|
||||
still.hidden = false;
|
||||
marker.classList.remove('qci-marker-link-only');
|
||||
note.textContent = t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
);
|
||||
note.hidden = false;
|
||||
markerLabel.textContent = markerLabelText(selected, true);
|
||||
setMedia(live, selectedMediaUrl);
|
||||
setMedia(still, selectedMediaUrl);
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
void still.play().catch(onPlayFailure(markStillUnavailable));
|
||||
startMediaRefresh();
|
||||
}
|
||||
|
||||
function setCamera(camera, { reselected = false } = {}) {
|
||||
if (!isQuebecCamera(camera)) {
|
||||
selected = null;
|
||||
selectedPosition = null;
|
||||
closedWindowCameraId = null;
|
||||
selectedMediaUrl = '';
|
||||
playingCameraId = null;
|
||||
liveUnavailable = false;
|
||||
stillUnavailable = false;
|
||||
stopMediaRefresh();
|
||||
stopRender();
|
||||
panel.hidden = true;
|
||||
marker.hidden = true;
|
||||
setMedia(live, '');
|
||||
setMedia(still, '');
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = selected?.id !== camera.id;
|
||||
if (changed || reselected) closedWindowCameraId = null;
|
||||
selected = camera;
|
||||
startRender();
|
||||
selectedPosition = Cesium.Cartesian3.fromDegrees(
|
||||
Number(camera.lon),
|
||||
Number(camera.lat),
|
||||
Number(camera.elevationM) || 10,
|
||||
);
|
||||
if (closedWindowCameraId === camera.id && !reselected) {
|
||||
panel.hidden = true;
|
||||
updateMarker();
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaUrl = safeMediaUrl(camera.browserMediaUrl);
|
||||
const sourceUrl = safeSourceUrl(camera.sourceUrl);
|
||||
if (changed || mediaUrl !== selectedMediaUrl) {
|
||||
liveUnavailable = false;
|
||||
stillUnavailable = false;
|
||||
playingCameraId = null;
|
||||
}
|
||||
const playing = Boolean(mediaUrl) && playingCameraId === camera.id;
|
||||
title.textContent = camera.name || t('CCTV camera');
|
||||
coordinates.textContent = [
|
||||
`${Number(camera.lat).toFixed(5)}, ${Number(camera.lon).toFixed(5)}`,
|
||||
Number.isFinite(camera.accuracyM) && camera.accuracyM > 0
|
||||
? `±${Math.round(camera.accuracyM)} m`
|
||||
: '',
|
||||
camera.positionSource || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
credit.textContent = camera.credit || camera.provider || '';
|
||||
credit.hidden = !credit.textContent;
|
||||
live.hidden = !playing;
|
||||
play.hidden = !mediaUrl || playing;
|
||||
refresh.hidden = !playing;
|
||||
actions.hidden = !mediaUrl;
|
||||
note.hidden = !mediaUrl;
|
||||
note.textContent = mediaUrl
|
||||
? !playing
|
||||
? t('Video plays from Québec 511, which sees your IP address.')
|
||||
: liveUnavailable
|
||||
? t('Source unavailable')
|
||||
: t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
)
|
||||
: sourceUrl
|
||||
? ''
|
||||
: t('Source unavailable');
|
||||
note.hidden = !note.textContent;
|
||||
sourceLink.href = sourceUrl || '#';
|
||||
sourceLink.hidden = !sourceUrl;
|
||||
markerLabel.textContent = stillUnavailable
|
||||
? t('Source unavailable')
|
||||
: markerLabelText(camera, playing);
|
||||
marker.classList.toggle('qci-marker-link-only', !playing);
|
||||
still.hidden = !playing;
|
||||
panel.hidden = false;
|
||||
|
||||
if (changed || mediaUrl !== selectedMediaUrl) {
|
||||
selectedMediaUrl = mediaUrl;
|
||||
// A new camera never inherits playback: the browser stays off 511
|
||||
// until the viewer presses Play for it.
|
||||
stopMediaRefresh();
|
||||
setMedia(live, '');
|
||||
setMedia(still, '');
|
||||
} else if (reselected && playing) {
|
||||
if (live.error || live.networkState === 3 || live.readyState < 2) {
|
||||
setMedia(live, mediaRefreshUrl(mediaUrl));
|
||||
}
|
||||
if (live.paused)
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
startMediaRefresh();
|
||||
if (still.error || still.networkState === 3 || still.readyState < 2)
|
||||
refreshStill();
|
||||
}
|
||||
updateMarker();
|
||||
}
|
||||
|
||||
function updateMarker() {
|
||||
if (!selected || !selectedPosition) {
|
||||
marker.hidden = true;
|
||||
return;
|
||||
}
|
||||
const canvas = viewer.scene.canvas;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(selectedPosition);
|
||||
const visible =
|
||||
point &&
|
||||
horizonOccluder(viewer.camera).isPointVisible(selectedPosition) &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= canvas.clientWidth &&
|
||||
point.y <= canvas.clientHeight;
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
// Measured once per resize or scroll, not every frame, so the per-frame
|
||||
// style writes never force a layout.
|
||||
canvasRect ||= canvas.getBoundingClientRect();
|
||||
const rect = canvasRect;
|
||||
marker.style.left = `${rect.left + point.x}px`;
|
||||
marker.style.top = `${rect.top + point.y}px`;
|
||||
const distance = Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
selectedPosition,
|
||||
);
|
||||
marker.style.setProperty(
|
||||
'--qci-scale',
|
||||
String(Math.min(1, 600 / Math.max(1, distance))),
|
||||
);
|
||||
}
|
||||
|
||||
still.addEventListener(
|
||||
'loadeddata',
|
||||
() => {
|
||||
stillUnavailable = false;
|
||||
if (selected)
|
||||
markerLabel.textContent = markerLabelText(
|
||||
selected,
|
||||
Boolean(selectedMediaUrl),
|
||||
);
|
||||
still.pause();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
still.addEventListener('error', markStillUnavailable, { signal });
|
||||
live.addEventListener('error', markLiveUnavailable, { signal });
|
||||
live.addEventListener(
|
||||
'loadeddata',
|
||||
() => {
|
||||
liveUnavailable = false;
|
||||
note.textContent = t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
);
|
||||
note.hidden = false;
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
refresh.addEventListener('click', refreshStill, { signal });
|
||||
play.addEventListener('click', startPlayback, { signal });
|
||||
const resetCanvasRect = () => {
|
||||
canvasRect = null;
|
||||
};
|
||||
window.addEventListener('resize', resetCanvasRect, { signal });
|
||||
window.addEventListener('scroll', resetCanvasRect, {
|
||||
signal,
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
panel.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (event.key === 'Escape') close.click();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
close.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
closedWindowCameraId = selected?.id || null;
|
||||
panel.hidden = true;
|
||||
live.pause();
|
||||
stopMediaRefresh();
|
||||
// The floating player closes independently; keep the paused map still.
|
||||
updateMarker();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
header.addEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (event.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const offsetY = event.clientY - rect.top;
|
||||
header.setPointerCapture(event.pointerId);
|
||||
const move = (moveEvent) => {
|
||||
panel.style.left = `${Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, moveEvent.clientX - offsetX))}px`;
|
||||
panel.style.top = `${Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, moveEvent.clientY - offsetY))}px`;
|
||||
panel.style.right = 'auto';
|
||||
};
|
||||
const stop = () => {
|
||||
header.removeEventListener('pointermove', move);
|
||||
header.removeEventListener('pointerup', stop);
|
||||
header.removeEventListener('pointercancel', stop);
|
||||
};
|
||||
header.addEventListener('pointermove', move, { signal });
|
||||
header.addEventListener('pointerup', stop, { once: true, signal });
|
||||
header.addEventListener('pointercancel', stop, { once: true, signal });
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
() => {
|
||||
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
|
||||
if (document.hidden) {
|
||||
live.pause();
|
||||
still.pause();
|
||||
} else if (closedWindowCameraId !== selected?.id) {
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
refreshStill();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return {
|
||||
setCamera,
|
||||
dispose() {
|
||||
controller.abort();
|
||||
stopMediaRefresh();
|
||||
stopRender();
|
||||
setMedia(live, '');
|
||||
setMedia(still, '');
|
||||
panel.remove();
|
||||
marker.remove();
|
||||
selected = null;
|
||||
selectedPosition = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Window } from 'happy-dom';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
|
||||
|
||||
const MEDIA_URL =
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4';
|
||||
|
||||
const CAMERA = Object.freeze({
|
||||
id: 'qc-511-3735',
|
||||
sourceKind: 'quebec-511-browser',
|
||||
name: 'Route 136 at rue Panet',
|
||||
lat: 45.51651,
|
||||
lon: -73.5502,
|
||||
positionSource: 'MTQ georeferenced point',
|
||||
browserMediaUrl: MEDIA_URL,
|
||||
sourceUrl:
|
||||
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
|
||||
});
|
||||
|
||||
/**
|
||||
* Mount the overlay in a happy-dom window with recorded media loads, timers
|
||||
* and postRender listeners. `play` decides what each play() returns.
|
||||
*/
|
||||
function mountOverlay(t, { play = () => Promise.resolve() } = {}) {
|
||||
const window = new Window({ url: 'http://localhost:4173/' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.window = window;
|
||||
globalThis.document = window.document;
|
||||
const loads = [];
|
||||
window.HTMLMediaElement.prototype.load = function () {
|
||||
if (this.getAttribute('src')) loads.push(this.getAttribute('src'));
|
||||
};
|
||||
window.HTMLMediaElement.prototype.play = function () {
|
||||
return play(this);
|
||||
};
|
||||
window.HTMLMediaElement.prototype.pause = function () {};
|
||||
const intervals = new Map();
|
||||
let nextTimer = 1;
|
||||
window.setInterval = (fn) => {
|
||||
const id = nextTimer++;
|
||||
intervals.set(id, fn);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = (id) => intervals.delete(id);
|
||||
const renderListeners = new Set();
|
||||
const viewer = {
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
cartesianToCanvasCoordinates: () => null,
|
||||
postRender: {
|
||||
addEventListener(fn) {
|
||||
renderListeners.add(fn);
|
||||
return () => renderListeners.delete(fn);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const overlay = createQuebecCctvOverlay(viewer);
|
||||
t.after(() => {
|
||||
overlay?.dispose();
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
window.close();
|
||||
});
|
||||
const [live, still] = window.document.querySelectorAll('video');
|
||||
return {
|
||||
window,
|
||||
overlay,
|
||||
loads,
|
||||
intervals,
|
||||
renderListeners,
|
||||
live,
|
||||
still,
|
||||
note: () => window.document.querySelector('.qci-note').textContent,
|
||||
label: () => window.document.querySelector('.qci-marker-label').textContent,
|
||||
playButton: () => window.document.querySelector('.qci-play'),
|
||||
};
|
||||
}
|
||||
|
||||
test('selecting a Québec camera contacts Québec 511 only after Play', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
assert.equal(ui.renderListeners.size, 0, 'no per-frame work before a camera');
|
||||
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
assert.deepEqual(ui.loads, [], 'selection alone loads no 511 media');
|
||||
assert.equal(ui.intervals.size, 0, 'and starts no refresh timer');
|
||||
assert.equal(ui.playButton().hidden, false);
|
||||
assert.match(ui.note(), /sees your IP address/);
|
||||
assert.equal(ui.renderListeners.size, 1);
|
||||
|
||||
ui.playButton().click();
|
||||
assert.deepEqual(ui.loads, [MEDIA_URL, MEDIA_URL], 'live player and map still');
|
||||
assert.equal(ui.playButton().hidden, true);
|
||||
assert.equal(ui.intervals.size, 1);
|
||||
|
||||
// A different camera never inherits playback.
|
||||
ui.loads.length = 0;
|
||||
ui.overlay.setCamera({
|
||||
...CAMERA,
|
||||
id: 'qc-511-4103',
|
||||
browserMediaUrl: MEDIA_URL.replace('3735', '4103'),
|
||||
});
|
||||
assert.deepEqual(ui.loads, []);
|
||||
assert.equal(ui.intervals.size, 0);
|
||||
assert.equal(ui.playButton().hidden, false);
|
||||
|
||||
ui.overlay.setCamera(null);
|
||||
assert.equal(ui.renderListeners.size, 0, 'deselect drops the per-frame listener');
|
||||
});
|
||||
|
||||
test('a camera without browser media shows only its provider link', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
ui.overlay.setCamera({ ...CAMERA, browserMediaUrl: '' });
|
||||
assert.equal(ui.playButton().hidden, true);
|
||||
assert.deepEqual(ui.loads, []);
|
||||
const link = ui.window.document.querySelector('.qci-panel a');
|
||||
assert.equal(link.hidden, false);
|
||||
assert.equal(link.href, CAMERA.sourceUrl);
|
||||
});
|
||||
|
||||
test('routine same-camera updates keep Québec feed errors visible until recovery', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
ui.live.dispatchEvent(new ui.window.Event('error'));
|
||||
ui.still.dispatchEvent(new ui.window.Event('error'));
|
||||
|
||||
ui.overlay.setCamera({ ...CAMERA });
|
||||
|
||||
assert.equal(ui.note(), 'Source unavailable');
|
||||
assert.equal(ui.label(), 'Source unavailable');
|
||||
|
||||
ui.live.dispatchEvent(new ui.window.Event('loadeddata'));
|
||||
ui.still.dispatchEvent(new ui.window.Event('loadeddata'));
|
||||
assert.match(ui.note(), /separate request/);
|
||||
assert.match(ui.label(), /^PREVIEW ·/);
|
||||
});
|
||||
|
||||
test('an interrupted play() is not reported as an unavailable source', async (t) => {
|
||||
const abort = () =>
|
||||
Promise.reject(new DOMException('interrupted by pause()', 'AbortError'));
|
||||
const ui = mountOverlay(t, { play: abort });
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(ui.note(), /separate request/);
|
||||
assert.match(ui.label(), /^PREVIEW ·/);
|
||||
});
|
||||
|
||||
test('a real play() failure is reported', async (t) => {
|
||||
const fail = () =>
|
||||
Promise.reject(new DOMException('no supported source', 'NotSupportedError'));
|
||||
const ui = mountOverlay(t, { play: fail });
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(ui.note(), 'Source unavailable');
|
||||
});
|
||||
|
||||
test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stops on dispose', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
const [refresh] = ui.intervals.values();
|
||||
|
||||
ui.loads.length = 0;
|
||||
Object.defineProperty(ui.window.document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
refresh();
|
||||
assert.deepEqual(ui.loads, [], 'a hidden tab does not refresh');
|
||||
|
||||
Object.defineProperty(ui.window.document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
refresh();
|
||||
assert.equal(ui.loads.length, 2);
|
||||
for (const src of ui.loads) assert.match(src, /[?&]_=\d+$/);
|
||||
|
||||
ui.overlay.dispose();
|
||||
assert.equal(ui.intervals.size, 0);
|
||||
assert.equal(ui.renderListeners.size, 0);
|
||||
assert.equal(ui.window.document.querySelector('.qci-panel'), null);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
.qci-panel {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
top: 80px;
|
||||
right: 24px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
overflow: hidden;
|
||||
border: 1px solid #3eabbc;
|
||||
border-radius: 8px;
|
||||
background: #071117;
|
||||
color: #e6f8fb;
|
||||
box-shadow: 0 12px 36px #000a;
|
||||
font: 12px/1.4 sans-serif;
|
||||
}
|
||||
.qci-panel[hidden],
|
||||
.qci-live[hidden],
|
||||
.qci-note[hidden],
|
||||
.qci-panel a[hidden],
|
||||
.qci-marker[hidden],
|
||||
.qci-marker-still[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.qci-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
.qci-panel button {
|
||||
border: 1px solid #43818d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #102830;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qci-panel button:focus-visible {
|
||||
outline: 2px solid #82e9f7;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.qci-coordinates,
|
||||
.qci-credit,
|
||||
.qci-note {
|
||||
margin: 0 10px 8px;
|
||||
color: #a9c5ca;
|
||||
font: 10px/1.45 'Cascadia Code', Consolas, monospace;
|
||||
}
|
||||
.qci-credit {
|
||||
color: #7fc7d2;
|
||||
}
|
||||
.qci-live {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
.qci-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
.qci-actions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.qci-panel a {
|
||||
display: inline-block;
|
||||
margin: 0 10px 10px;
|
||||
color: #82e9f7;
|
||||
}
|
||||
.qci-marker {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
width: 160px;
|
||||
transform: translate(-50%, -100%) scale(var(--qci-scale, 1));
|
||||
transform-origin: 50% 100%;
|
||||
pointer-events: none;
|
||||
padding: 3px;
|
||||
border: 1px solid #65d7e7;
|
||||
background: #071117;
|
||||
color: #d8f9ff;
|
||||
font: 10px/1.3 sans-serif;
|
||||
}
|
||||
.qci-marker-still {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
.qci-marker-label {
|
||||
display: block;
|
||||
padding: 3px;
|
||||
}
|
||||
.qci-marker-link-only {
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
@import './src/ui/styles/layers.css';
|
||||
@import './src/ui/styles/radio.css';
|
||||
@import './src/ui/styles/cctv.css';
|
||||
@import './src/ui/styles/quebecCctvOverlay.css';
|
||||
@import './src/ui/styles/street-level.css';
|
||||
@import './src/ui/styles/scenes.css';
|
||||
@import './src/ui/styles/bhote-koshi.css';
|
||||
|
||||
Reference in new issue
Block a user