This commit is contained in:
Garry Williams 2026-09-09 07:30:43 +01:00 committed by GitHub
commit f80e53407a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1680 additions and 16 deletions

View File

@ -136,6 +136,9 @@ AISSTREAM_API_KEY=
# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL
# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
# CCTV_TRAFFIC_SCOTLAND_ENABLED=1 # 0 disables the Traffic Scotland (Scottish trunk road) pack
# CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES=300 # cap Traffic Scotland views (nearest Edinburgh/Glasgow first; 500 = whole network)
# CCTV_BUNDLED_PACKS_ENABLED=1 # 0 disables the repo's curated packs (Perth & Kinross road cams, Scottish mountain webcams)
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force Austin-only sources
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)

View File

@ -5,6 +5,19 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
### Added
- CCTV: Traffic Scotland trunk-road cameras (Edinburgh, Glasgow and the rest of
the Scottish network) join Austin, Caltrans and TfL as a keyless default
source. Frames are decoded from the public site's per-site fragments and
cached server-side for four minutes; `CCTV_TRAFFIC_SCOTLAND_ENABLED=0`
disables the pack and `CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES` caps it.
- CCTV: a bundled, hand-curated Scotland pack adds Perth & Kinross Council's
23 road cameras and 22 mountain and ski webcams (Cairngorm, Glencoe,
Glenshee, Aboyne, Ben Nevis, Leadhills). Curated packs load alongside the
live sources, carry each operator's stated refresh cadence, and can be
switched off with `CCTV_BUNDLED_PACKS_ENABLED=0`.
### Fixed
- Mapped-site outages show their scheduled retry countdown and distinguish

View File

@ -34,6 +34,9 @@ How to read this:
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
| **Traffic Scotland (traffic.gov.scot)** | CCTV camera catalog + frames, Scottish trunk roads | Public Traffic Scotland website content (Scottish Ministers / Transport Scotland); the [developer hub](https://www.traffic.gov.scot/traffic-scotland-developer-hub) asks that Traffic Scotland be clearly credited as the data source — see note | "Traffic Scotland — traffic.gov.scot" |
| **Perth & Kinross Council** (localapps.pkc.gov.uk) | CCTV frames, 23 council road cameras (Perth & Kinross + A90 Forfar); positions hand-curated | Public council web page; no stated reuse licence — courtesy credit | "Perth & Kinross Council — pkc.gov.uk/trafficcameras" |
| **Scottish mountain & ski webcams** (Cairngorm Mountain, Glencoe Mountain Resort / Winterhighland, Glenshee Ski Centre via webcam-hd.com, Deeside Gliding Club, About Fort William) | CCTV frames, 22 hand-curated webcams | Privately operated public webcams; no stated reuse licences — courtesy credit per operator | Operator name + link (registered per operator in the Data attribution popover) |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
@ -45,6 +48,8 @@ How to read this:
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Traffic Scotland.** The camera catalog (`/tsis/cameras`, JSON with coordinates) and per-site frame fragments (`/tsis/camerahtml?sid=…`, HTML wrapping the current stills as base64 JPEGs) are the same keyless endpoints the public Traffic Scotland website uses; they are not a documented API, and the documented developer route is the registered Live Traffic Camera FTP service. GEV decodes the stills server-side, caches each site's decoded views for four minutes (upstream republishes every ~5 min in the Edinburgh/Glasgow area and ~20 min elsewhere), serves a stale frame when a refresh fails, and never rehosts or redistributes images beyond the live proxy. Every frame already carries Traffic Scotland's own watermark and timestamp; the "Traffic Scotland" credit is registered in the Data attribution popover. Images are withheld upstream while a camera is in operational use by the control room or Police Scotland, so individual views can go dark without warning. Kill switch: `CCTV_TRAFFIC_SCOTLAND_ENABLED=0`; cap: `CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES` (default 300, prioritized nearest Edinburgh and Glasgow; 500 covers the whole network).
- **Bundled curated CCTV packs.** `config/cctv_sources.scotland.curated.json` ships in the repo and loads alongside the live packs (kill switch `CCTV_BUNDLED_PACKS_ENABLED=0`). Its cameras are operators that publish a plain still-image URL but no machine-readable catalog: Perth & Kinross Council's 23 road cameras (15-minute stills; trunk-road sites take Traffic Scotland's published coordinates, the others are placed from OSM features matching the council's descriptions and the caption burned into each frame, so treat positions as ±100 m) and 22 mountain/ski webcams (Cairngorm Mountain ~1 min, Glencoe ~5 min, Glenshee daylight only, Aboyne airfield, Ben Nevis from Loch Linnhe — whose frames are ~1.6 MB — and Leadhills). None of these operators states a reuse licence; GEV proxies frames live, never stores or redistributes them, sets each camera's refresh from the operator's stated cadence (`frameRefreshMs`), and credits every operator by name and link. Remove an entry from the pack if an operator objects.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.

View File

@ -186,7 +186,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
![Moving from a full airport overhead down to close taxiway inspection with 3D flight models](docs/media/start-here/airport-ground-traffic-google-3d.gif)
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, Edinburgh, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
![Diving into an Austin intersection with a live public camera projected into the 3D scene](docs/media/03-austin-cctv.gif)
@ -273,7 +273,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **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** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~1,150 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL) · Scotland (Traffic Scotland, Perth & Kinross, mountain webcams). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📻 **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 | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |

View File

@ -0,0 +1,971 @@
[
{
"id": "pkc-475-cam2",
"name": "M90 Glenfarg (North)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/475_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/475_cam2.jpg",
"lat": 56.2902,
"lon": -3.3902,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 150,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-475-cam1",
"name": "M90 Glenfarg (South)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/475_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/475_cam1.jpg",
"lat": 56.2902,
"lon": -3.3902,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 150,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 180,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-2152-cam1",
"name": "M90 Kelty (North)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/2152_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/2152_cam1.jpg",
"lat": 56.1283,
"lon": -3.3926,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 150,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-2152-cam2",
"name": "M90 Kelty (South)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/2152_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/2152_cam2.jpg",
"lat": 56.1283,
"lon": -3.3926,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 150,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 180,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-474-cam1",
"name": "A90 Starr Farm (East)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/474_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/474_cam1.jpg",
"lat": 56.4655,
"lon": -3.07,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 20,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 90,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-474-cam2",
"name": "A90 Starr Farm (West)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/474_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/474_cam2.jpg",
"lat": 56.4655,
"lon": -3.07,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 20,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 270,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-472-cam1",
"name": "A9 Loaning Head (East)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/472_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/472_cam1.jpg",
"lat": 56.2669,
"lon": -3.7489,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 120,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 90,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-472-cam2",
"name": "A9 Loaning Head (West)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/472_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/472_cam2.jpg",
"lat": 56.2669,
"lon": -3.7489,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 120,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 270,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-522-cam1",
"name": "A9 Balhaldie (North)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/522_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/522_cam1.jpg",
"lat": 56.2069,
"lon": -3.9474,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 90,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-522-cam2",
"name": "A9 Balhaldie (South)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/522_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/522_cam2.jpg",
"lat": 56.2069,
"lon": -3.9474,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 90,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 180,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-8781-cam2",
"name": "A93 Glenshee (North)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/8781_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/8781_cam2.jpg",
"lat": 56.876,
"lon": -3.4265,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 600,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-8781-cam1",
"name": "A93 Glenshee (South)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/8781_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/8781_cam1.jpg",
"lat": 56.876,
"lon": -3.4265,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 600,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 180,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-401-cam1",
"name": "A93 South Persie (North)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/401_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/401_cam1.jpg",
"lat": 56.6778,
"lon": -3.4124,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 200,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-402-cam2",
"name": "A94 Rosefield (North)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/402_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/402_cam2.jpg",
"lat": 56.4674,
"lon": -3.341,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 60,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-402-cam1",
"name": "A94 Rosefield (South)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/402_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/402_cam1.jpg",
"lat": 56.4674,
"lon": -3.341,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 60,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 180,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-484-cam1",
"name": "A822 Amulree",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/484_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/484_cam1.jpg",
"lat": 56.5,
"lon": -3.79,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 300,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingConfidence": "low"
},
{
"id": "pkc-485-cam2",
"name": "A977 Gelvan (East)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/485_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/485_cam2.jpg",
"lat": 56.197,
"lon": -3.5267,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 120,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 90,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-485-cam1",
"name": "A977 Gelvan (West)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/485_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/485_cam1.jpg",
"lat": 56.197,
"lon": -3.5267,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 120,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 270,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-9623-cam1",
"name": "Kinloch Rannoch",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/9623_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/9623_cam1.jpg",
"lat": 56.7006,
"lon": -4.1865,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 210,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingConfidence": "low"
},
{
"id": "pkc-12187-cam1",
"name": "A827 Kiltyrie (East)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/12187_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/12187_cam1.jpg",
"lat": 56.5022,
"lon": -4.2274,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 180,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 90,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-10634-cam1",
"name": "Muirend Road, Perth (West)",
"city": "Perth & Kinross",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/10634_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/10634_cam1.jpg",
"lat": 56.3962,
"lon": -3.47,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 50,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 270,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-391-cam1",
"name": "A90 Forfar (North)",
"city": "Angus",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/391_cam1.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/391_cam1.jpg",
"lat": 56.6425,
"lon": -2.9258,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 70,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 0,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "pkc-391-cam2",
"name": "A90 Forfar (South)",
"city": "Angus",
"provider": "Perth & Kinross Council",
"sourceKind": "pkc-council",
"feedType": "image",
"url": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/391_cam2.jpg",
"snapshotUrl": "https://localapps.pkc.gov.uk/RoadsCameraImages/images/391_cam2.jpg",
"lat": 56.6425,
"lon": -2.9258,
"pitchDeg": -16,
"fovDeg": 50,
"rangeM": 300,
"mountHeightM": 8,
"groundElevationM": 70,
"frameRefreshMs": 900000,
"license": "Perth & Kinross Council live road traffic cameras — pkc.gov.uk/trafficcameras (public council page, courtesy credit)",
"headingDeg": 180,
"headingConfidence": "high",
"poseSource": "curated"
},
{
"id": "cgm-base-station",
"name": "Cairngorm Base Station",
"city": "Cairngorms",
"provider": "Cairngorm Mountain",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/BaseStation-full.jpg",
"snapshotUrl": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/BaseStation-full.jpg",
"lat": 57.1332,
"lon": -3.6702,
"headingDeg": 150,
"headingConfidence": "medium",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 630,
"frameRefreshMs": 120000,
"license": "Cairngorm Mountain (Scotland) Ltd live webcams — cairngormmountain.co.uk/live-webcams (courtesy credit)",
"poseSource": "curated"
},
{
"id": "cgm-top-station",
"name": "Cairngorm Top Station (Ptarmigan)",
"city": "Cairngorms",
"provider": "Cairngorm Mountain",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/PtarmiganStation-full.jpg",
"snapshotUrl": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/PtarmiganStation-full.jpg",
"lat": 57.1152,
"lon": -3.6646,
"headingDeg": 340,
"headingConfidence": "medium",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 1090,
"frameRefreshMs": 120000,
"license": "Cairngorm Mountain (Scotland) Ltd live webcams — cairngormmountain.co.uk/live-webcams (courtesy credit)",
"poseSource": "curated"
},
{
"id": "cgm-middle-station",
"name": "Cairngorm Middle Station",
"city": "Cairngorms",
"provider": "Cairngorm Mountain",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/sunkid-full.jpg",
"snapshotUrl": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/sunkid-full.jpg",
"lat": 57.1288,
"lon": -3.665,
"headingDeg": 160,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 780,
"frameRefreshMs": 120000,
"license": "Cairngorm Mountain (Scotland) Ltd live webcams — cairngormmountain.co.uk/live-webcams (courtesy credit)"
},
{
"id": "cgm-fiacaill-ridge",
"name": "Cairngorm Fiacaill Ridge",
"city": "Cairngorms",
"provider": "Cairngorm Mountain",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/fiacaillridge-full.jpg",
"snapshotUrl": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/fiacaillridge-full.jpg",
"lat": 57.1288,
"lon": -3.6662,
"headingDeg": 250,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 780,
"frameRefreshMs": 120000,
"license": "Cairngorm Mountain (Scotland) Ltd live webcams — cairngormmountain.co.uk/live-webcams (courtesy credit)"
},
{
"id": "cgm-loch-morlich",
"name": "Loch Morlich Boathouse (towards Cairn Gorm)",
"city": "Cairngorms",
"provider": "Cairngorm Mountain",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/morlich-full.jpg",
"snapshotUrl": "https://www.cairngormmountain.co.uk/wp-content/uploads/webcams/morlich-full.jpg",
"lat": 57.1668,
"lon": -3.696,
"headingDeg": 150,
"headingConfidence": "medium",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 320,
"frameRefreshMs": 120000,
"license": "Cairngorm Mountain (Scotland) Ltd live webcams — cairngormmountain.co.uk/live-webcams (courtesy credit)",
"poseSource": "curated"
},
{
"id": "glencoe-base",
"name": "Glencoe Mountain Base",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/base.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/base.jpeg",
"lat": 56.6262,
"lon": -4.846,
"headingDeg": 225,
"headingConfidence": "medium",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 330,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)",
"poseSource": "curated"
},
{
"id": "glencoe-base-1200",
"name": "Glencoe Mountain Base 1200",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/base1200.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/base1200.jpeg",
"lat": 56.6258,
"lon": -4.847,
"headingDeg": 230,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 340,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-dry-slope",
"name": "Glencoe Mountain Dry Slope",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/dryslope.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/dryslope.jpeg",
"lat": 56.6255,
"lon": -4.8475,
"headingDeg": 210,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 335,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-access-2200",
"name": "Glencoe Access Chair 2200 ft",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/access2200.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/access2200.jpeg",
"lat": 56.6195,
"lon": -4.857,
"headingDeg": 230,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 670,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-new-chair",
"name": "Glencoe New Chair",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/newchair.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/newchair.jpeg",
"lat": 56.617,
"lon": -4.86,
"headingDeg": 220,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 740,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-cliffhanger",
"name": "Glencoe Cliffhanger",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/cliffy.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/cliffy.jpeg",
"lat": 56.618,
"lon": -4.859,
"headingDeg": 220,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 720,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-main-basin",
"name": "Glencoe Main Basin",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/mainbasin.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/mainbasin.jpeg",
"lat": 56.613,
"lon": -4.865,
"headingDeg": 200,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 830,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-flypaper",
"name": "Glencoe Flypaper",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/flypaper.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/flypaper.jpeg",
"lat": 56.6138,
"lon": -4.864,
"headingDeg": 180,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 820,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-spring-run",
"name": "Glencoe Spring Run",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/spring.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/spring.jpeg",
"lat": 56.615,
"lon": -4.8625,
"headingDeg": 200,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 800,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-the-wall",
"name": "Glencoe The Wall",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/wall.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/wall.jpeg",
"lat": 56.6145,
"lon": -4.8635,
"headingDeg": 190,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 810,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glencoe-summit",
"name": "Glencoe Summit (Meall a Bhuiridh)",
"city": "Glencoe",
"provider": "Glencoe Mountain Resort",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/glencoe/summit.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/glencoe/summit.jpeg",
"lat": 56.611,
"lon": -4.8705,
"headingDeg": 60,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 1100,
"frameRefreshMs": 300000,
"license": "Glencoe Mountain Resort webcams, hosted by Winterhighland — glencoemountain.co.uk/webcams (courtesy credit)"
},
{
"id": "glenshee-cam-1",
"name": "Glenshee Ski Centre cam 1",
"city": "Glenshee",
"provider": "Glenshee Ski Centre",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.webcam-hd.com/images/ski-scotland_glenshee/zooms/ski-scotland_glenshee1.jpg",
"snapshotUrl": "https://www.webcam-hd.com/images/ski-scotland_glenshee/zooms/ski-scotland_glenshee1.jpg",
"lat": 56.8815,
"lon": -3.4245,
"headingDeg": 200,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 665,
"frameRefreshMs": 300000,
"license": "Glenshee Ski Centre webcams via webcam-hd.com — ski-glenshee.co.uk/Webcam (courtesy credit; daylight only)"
},
{
"id": "glenshee-cam-2",
"name": "Glenshee Ski Centre cam 2",
"city": "Glenshee",
"provider": "Glenshee Ski Centre",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.webcam-hd.com/images/ski-scotland_glenshee/zooms/ski-scotland_glenshee2.jpg",
"snapshotUrl": "https://www.webcam-hd.com/images/ski-scotland_glenshee/zooms/ski-scotland_glenshee2.jpg",
"lat": 56.8818,
"lon": -3.4238,
"headingDeg": 120,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 665,
"frameRefreshMs": 300000,
"license": "Glenshee Ski Centre webcams via webcam-hd.com — ski-glenshee.co.uk/Webcam (courtesy credit; daylight only)"
},
{
"id": "glenshee-cam-3",
"name": "Glenshee Ski Centre cam 3",
"city": "Glenshee",
"provider": "Glenshee Ski Centre",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.webcam-hd.com/images/ski-scotland_glenshee/zooms/ski-scotland_glenshee3.jpg",
"snapshotUrl": "https://www.webcam-hd.com/images/ski-scotland_glenshee/zooms/ski-scotland_glenshee3.jpg",
"lat": 56.8812,
"lon": -3.4252,
"headingDeg": 300,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 665,
"frameRefreshMs": 300000,
"license": "Glenshee Ski Centre webcams via webcam-hd.com — ski-glenshee.co.uk/Webcam (courtesy credit; daylight only)"
},
{
"id": "dgc-aboyne-airfield",
"name": "Aboyne Airfield (Deeside Gliding Club)",
"city": "Deeside",
"provider": "Deeside Gliding Club",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.deesideglidingclub.co.uk/webcam/image.jpg",
"snapshotUrl": "https://www.deesideglidingclub.co.uk/webcam/image.jpg",
"lat": 57.0749,
"lon": -2.8408,
"headingDeg": 270,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 140,
"frameRefreshMs": 300000,
"license": "Deeside Gliding Club webcam — deesideglidingclub.co.uk (courtesy credit)"
},
{
"id": "afw-ben-nevis",
"name": "Ben Nevis from Loch Linnhe (Fort William)",
"city": "Lochaber",
"provider": "About Fort William",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://ah.cdn.licr.co.uk/images/webcams/ben-nevis-and-fort-william-static.jpg",
"snapshotUrl": "https://ah.cdn.licr.co.uk/images/webcams/ben-nevis-and-fort-william-static.jpg",
"lat": 56.816,
"lon": -5.1125,
"headingDeg": 60,
"headingConfidence": "medium",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 10,
"frameRefreshMs": 300000,
"license": "Ben Nevis & Fort William webcam — aboutfortwilliam.com (courtesy credit; ~1.6 MB frames)",
"poseSource": "curated"
},
{
"id": "wh-leadhills",
"name": "Leadhills (towards Lowther Hill)",
"city": "Leadhills",
"provider": "Winterhighland",
"sourceKind": "mountain-webcam",
"feedType": "image",
"url": "https://www.winterhighland.info/cams/lowtherhill/leadhills.jpeg",
"snapshotUrl": "https://www.winterhighland.info/cams/lowtherhill/leadhills.jpeg",
"lat": 55.4185,
"lon": -3.76,
"headingDeg": 175,
"headingConfidence": "low",
"pitchDeg": -6,
"fovDeg": 60,
"rangeM": 2000,
"mountHeightM": 5,
"groundElevationM": 400,
"frameRefreshMs": 300000,
"license": "Winterhighland Leadhills webcam — winterhighland.info/cams/lowtherhill (courtesy credit)"
}
]

View File

@ -1618,7 +1618,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven |
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Traffic Scotland + bundled curated packs (Perth & Kinross, Scottish mountain webcams) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
@ -1967,7 +1967,18 @@ silently demoting every later lookup for the session.
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
sources — ~800 cameras total, all RAW PRIOR poses, stills-first.
sources — ~800 cameras total, all RAW PRIOR poses, stills-first. Traffic Scotland (2026-09-08):
the public site's `/tsis/cameras` JSON (415 sites, 500 views, coordinates) becomes one camera
per view (`ts-<view id>`, cap 300 nearest Edinburgh/Glasgow, `CCTV_TRAFFIC_SCOTLAND_*`), and
because its stills only exist as base64 data URIs inside a per-site HTML fragment, the proxy
decodes them server-side (`fetchTrafficScotlandFrame`), caches each site's views for 4 min,
and serves stale on a failed refresh. Bundled curated packs (2026-09-09): `BUNDLED_CCTV_PACK_FILES`
(`config/cctv_sources.scotland.curated.json`, 23 Perth & Kinross Council road cams + 22 mountain/ski
webcams) load as additive peers of the live packs via `loadBundledCuratedSources` — unlike
`CCTV_SOURCES_FILE` they never suppress the live packs; `CCTV_BUNDLED_PACKS_ENABLED=0` skips them.
Sources may now carry `frameRefreshMs` (server → `/sources` → client `staticFrameRefreshMs`), so a
pack states its operator's cadence instead of relying on the provider-name table.
`CCTV_MAX_SOURCES` default rose 900 → 1,100 → 1,150 to fit.
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
validated 2026-07-21): the COVERAGE toggle is a
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum

View File

@ -11,9 +11,10 @@ PORT="${PORT:-4173}"
HOST="${HOST:-localhost}"
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
# pack; override per-run for lighter/heavier loads. Kill switches:
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
# TfL London JamCams (~870 live upstream), Traffic Scotland trunk-road cameras
# (~500 views upstream). Caps keep the densest cores per pack; override per-run
# for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
# CCTV_TFL_ENABLED=0 and CCTV_TRAFFIC_SCOTLAND_ENABLED=0.
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
# is preserved rather than replaced by the default. Still set-u-safe when unset.
@ -21,7 +22,11 @@ CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
CCTV_TRAFFIC_SCOTLAND_ENABLED="${CCTV_TRAFFIC_SCOTLAND_ENABLED:-1}"
CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES="${CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES:-300}"
# Repo-bundled hand-curated packs (Perth & Kinross road cams, Scottish mountain webcams).
CCTV_BUNDLED_PACKS_ENABLED="${CCTV_BUNDLED_PACKS_ENABLED:-1}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-1150}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
@ -364,6 +369,9 @@ put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
put_env CCTV_TRAFFIC_SCOTLAND_ENABLED "${CCTV_TRAFFIC_SCOTLAND_ENABLED}"
put_env CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES "${CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES}"
put_env CCTV_BUNDLED_PACKS_ENABLED "${CCTV_BUNDLED_PACKS_ENABLED}"
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"

View File

@ -1170,6 +1170,9 @@ function buildCatalogFromSources(rawSources) {
license: String(source.license || source.licenseNote || ''),
poseSource,
};
// Operator-stated still cadence (curated packs); staticFrameRefreshMs bounds it.
const frameRefreshMs = safeNumber(source.frameRefreshMs, NaN);
if (Number.isFinite(frameRefreshMs) && frameRefreshMs > 0) camera.frameRefreshMs = frameRefreshMs;
ensureCameraPose(camera);
catalog.push(camera);
}

View File

@ -31,6 +31,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'austin transportation & public works': 5 * 60 * 1000,
'transport for london': 3 * 60 * 1000,
caltrans: 3 * 60 * 1000,
// Traffic Scotland republishes stills every ~5 min in the central belt and
// ~20 min on rural routes; the proxy caches decoded frames for 4 min.
'traffic scotland': 5 * 60 * 1000,
});
/**

View File

@ -500,6 +500,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }), 300_000);
assert.equal(staticFrameRefreshMs({ provider: 'Transport for London' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Traffic Scotland' }), 300_000);
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);

View File

@ -0,0 +1,69 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadBundledCuratedSources } from '../../vite.config.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const SCOTLAND_PACK = path.resolve(here, '../../config/cctv_sources.scotland.curated.json');
test('the bundled Scotland pack is well-formed: unique ids, https stills, Scottish coordinates, bounded cadence', () => {
const pack = JSON.parse(fs.readFileSync(SCOTLAND_PACK, 'utf8'));
assert.ok(Array.isArray(pack) && pack.length >= 40, 'pack should hold the 23 PKC + 22 mountain cameras');
const ids = new Set();
for (const camera of pack) {
assert.ok(camera.id && !ids.has(camera.id), `duplicate or missing id: ${camera.id}`);
ids.add(camera.id);
assert.match(camera.url, /^https:\/\//, `${camera.id} must use https`);
assert.equal(camera.snapshotUrl, camera.url);
assert.equal(camera.feedType, 'image');
assert.ok(camera.lat > 54.5 && camera.lat < 61 && camera.lon > -8.5 && camera.lon < -0.5, `${camera.id} is outside Scotland`);
assert.ok(camera.frameRefreshMs >= 60_000 && camera.frameRefreshMs <= 20 * 60_000, `${camera.id} cadence out of client bounds`);
assert.ok(camera.provider && camera.license && camera.city, `${camera.id} needs provider, license, city`);
assert.ok(['pkc-council', 'mountain-webcam'].includes(camera.sourceKind), `${camera.id} unexpected sourceKind`);
if (camera.poseSource === 'curated') {
assert.ok(Number.isFinite(camera.headingDeg), `${camera.id} claims a curated pose without a heading`);
assert.notEqual(camera.headingConfidence, 'low', `${camera.id} curated pose cannot be low confidence`);
}
}
const pkc = pack.filter((camera) => camera.sourceKind === 'pkc-council');
assert.equal(pkc.length, 23);
assert.ok(pkc.every((camera) => camera.url.startsWith('https://localapps.pkc.gov.uk/RoadsCameraImages/images/')));
assert.ok(pkc.every((camera) => camera.frameRefreshMs === 900_000), 'PKC publishes every 15 minutes');
// Directional council cameras take their heading from the caption burned into the frame.
const east = pkc.find((camera) => camera.id === 'pkc-474-cam1');
assert.equal(east.headingDeg, 90);
assert.equal(east.poseSource, 'curated');
const amulree = pkc.find((camera) => camera.id === 'pkc-484-cam1');
assert.equal(amulree.headingDeg, undefined, 'a camera with no stated facing keeps the id-hash prior');
assert.equal(amulree.poseSource, undefined);
});
test('loadBundledCuratedSources loads the shipped packs and drops malformed entries', () => {
const shipped = loadBundledCuratedSources({ env: {} });
assert.ok(shipped.length >= 40);
assert.ok(shipped.some((camera) => camera.id === 'cgm-base-station'));
assert.ok(shipped.some((camera) => camera.id === 'pkc-475-cam2'));
const tmp = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'gev-cctv-pack-')), 'pack.json');
fs.writeFileSync(tmp, JSON.stringify([
{ id: 'ok-1', url: 'https://example.com/a.jpg', lat: 56.1, lon: -3.2 },
{ id: '', url: 'https://example.com/b.jpg', lat: 56.1, lon: -3.2 },
{ id: 'no-url', lat: 56.1, lon: -3.2 },
{ id: 'bad-scheme', url: 'ftp://example.com/c.jpg', lat: 56.1, lon: -3.2 },
{ id: 'no-coords', url: 'https://example.com/d.jpg', lat: 'x', lon: null },
null,
'garbage',
]));
const filtered = loadBundledCuratedSources({ files: [tmp], env: {} });
assert.deepEqual(filtered.map((camera) => camera.id), ['ok-1']);
});
test('loadBundledCuratedSources honours the kill switch and survives missing or non-array files', () => {
assert.deepEqual(loadBundledCuratedSources({ env: { CCTV_BUNDLED_PACKS_ENABLED: '0' } }), []);
assert.deepEqual(loadBundledCuratedSources({ files: ['/nonexistent/pack.json'], env: {} }), []);
const tmp = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'gev-cctv-pack-')), 'obj.json');
fs.writeFileSync(tmp, JSON.stringify({ sources: [] }));
assert.deepEqual(loadBundledCuratedSources({ files: [tmp], env: {} }), []);
});

View File

@ -127,6 +127,30 @@ export const DATA_CREDITS = [
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
'Contains OS data © Crown copyright and database rights.',
},
{
key: 'traffic-scotland-cctv',
html:
'CCTV cameras &amp; frames (Scotland): ' +
'<a href="https://www.traffic.gov.scot/traffic-cameras" target="_blank" rel="noopener">Traffic Scotland</a> ' +
'(Transport Scotland, on behalf of the Scottish Ministers) — traffic.gov.scot',
},
{
key: 'pkc-cctv',
html:
'CCTV cameras &amp; frames (Perth &amp; Kinross): ' +
'<a href="https://www.pkc.gov.uk/trafficcameras" target="_blank" rel="noopener">Perth &amp; Kinross Council</a>',
},
{
key: 'scotland-mountain-webcams',
html:
'Mountain &amp; ski webcams (Scotland), courtesy of their operators: ' +
'<a href="https://www.cairngormmountain.co.uk/live-webcams/" target="_blank" rel="noopener">Cairngorm Mountain</a>, ' +
'<a href="https://www.glencoemountain.co.uk/webcams" target="_blank" rel="noopener">Glencoe Mountain Resort</a> / ' +
'<a href="https://www.winterhighland.info/" target="_blank" rel="noopener">Winterhighland</a>, ' +
'<a href="https://www.ski-glenshee.co.uk/Webcam" target="_blank" rel="noopener">Glenshee Ski Centre</a>, ' +
'<a href="https://www.deesideglidingclub.co.uk/" target="_blank" rel="noopener">Deeside Gliding Club</a>, ' +
'<a href="https://aboutfortwilliam.com/webcams/ben-nevis-and-fort-william" target="_blank" rel="noopener">About Fort William</a>',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',

View File

@ -0,0 +1,201 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
TRAFFIC_SCOTLAND_FRAME_CACHE_MS,
fetchTrafficScotlandFrame,
loadTrafficScotlandSourcesFromOpenData,
parseTrafficScotlandFrameFragment,
trafficScotlandFrameRef,
} from '../../vite.config.js';
// Smallest byte strings the parser accepts as raster stills (magic + padding).
const JPEG_A = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x41, 0x41, 0x41, 0x41]);
const JPEG_B = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x42, 0x42, 0x42, 0x42]);
const PNG_C = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x43, 0x43]);
function fragment(views) {
const links = views.length > 1
? `<div class="cam-links">${views.map(([tid, label]) => `<a class="cam-link" href="#" tid="${tid}"><span>${label}</span></a>`).join('')}</div>`
: '';
const images = views.map(([tid, , bytes, mime = 'jpeg'], index) =>
`<div class="camera-image"${index ? ' style="display: none;"' : ''} tid="${tid}"><img src="data:image/${mime};base64,${bytes.toString('base64')}" /></div>`).join('');
return `<div class="iw-holder"><div class="iw-header"><img src="/themes/ud/img/tsis-cameras.png"><span></span></div>${links}${images}</div>`;
}
const CATALOG = {
status: 'ok',
results: [
{ sid: '24', title: 'A720 Dreghorn', lat: '55.899100000000', lng: '-3.244200000000', roadname: 'A720', images: '43', region: 'SW Scotland, Lothian and Borders' },
{ sid: '77', title: 'A702 Boghall ', lat: '55.878', lng: '-3.212', roadname: 'A702', images: '143,144', region: 'SW Scotland, Lothian and Borders' },
{ sid: '901', title: 'No coordinates', lat: '', lng: null, roadname: 'A9', images: '900', region: 'Highland and Western Isles' },
{ sid: '902', title: 'Outside Scotland', lat: '51.5', lng: '-0.1', roadname: 'M25', images: '901', region: 'Strathclyde' },
{ sid: '903', title: 'No views', lat: '56.1', lng: '-3.9', roadname: 'M9', images: '', region: 'Central, Tayside and Fife' },
{ sid: 'abc', title: 'Bad site id', lat: '56.1', lng: '-3.9', roadname: 'M9', images: '77', region: 'Central, Tayside and Fife' },
{ sid: '904', title: 'Duplicate view', lat: '56.2', lng: '-3.8', roadname: 'M9', images: '43, x, 45', region: 'Central, Tayside and Fife' },
],
};
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function htmlResponse(html, status = 200) {
return new Response(html, { status, headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}
test('Traffic Scotland loader emits one camera per published view with a stable id', async () => {
const requested = [];
const cameras = await loadTrafficScotlandSourcesFromOpenData({
fetchImpl: async (url, options) => {
requested.push({ url, signal: options?.signal });
return jsonResponse(CATALOG);
},
});
assert.equal(requested.length, 1);
assert.equal(requested[0].url, 'https://www.traffic.gov.scot/tsis/cameras');
assert.ok(requested[0].signal instanceof AbortSignal, 'catalog fetch must carry a timeout signal');
const ids = cameras.map((camera) => camera.id).sort();
assert.deepEqual(ids, ['ts-143', 'ts-144', 'ts-43', 'ts-45']);
const single = cameras.find((camera) => camera.id === 'ts-43');
assert.equal(single.name, 'A720 Dreghorn');
assert.equal(single.lat, 55.8991);
assert.equal(single.lon, -3.2442);
assert.equal(single.city, 'Scotland');
assert.equal(single.provider, 'Traffic Scotland');
assert.equal(single.sourceKind, 'traffic-scotland');
assert.equal(single.feedType, 'image');
assert.equal(single.headingConfidence, 'low');
assert.equal(single.snapshotUrl, 'https://www.traffic.gov.scot/tsis/camerahtml?sid=24');
assert.equal(single.url, single.snapshotUrl);
assert.ok(Number.isFinite(single.headingDeg));
const north = cameras.find((camera) => camera.id === 'ts-143');
const south = cameras.find((camera) => camera.id === 'ts-144');
assert.equal(north.name, 'A702 Boghall (cam 1)', 'multi-view titles are whitespace-normalized and numbered');
assert.equal(south.name, 'A702 Boghall (cam 2)');
assert.equal(north.snapshotUrl, south.snapshotUrl, 'views at one site share the site fragment');
assert.notEqual(north.headingDeg, south.headingDeg, 'each view gets its own heading prior');
const duplicate = cameras.find((camera) => camera.id === 'ts-45');
assert.equal(duplicate.name, 'Duplicate view (cam 2)', 'a view id already claimed by another site is skipped, not re-registered');
});
test('Traffic Scotland loader fails soft on upstream errors and malformed payloads', async () => {
assert.deepEqual(await loadTrafficScotlandSourcesFromOpenData({ fetchImpl: async () => jsonResponse({}, 503) }), []);
assert.deepEqual(await loadTrafficScotlandSourcesFromOpenData({ fetchImpl: async () => jsonResponse({ status: 'ok', results: 'nope' }) }), []);
assert.deepEqual(await loadTrafficScotlandSourcesFromOpenData({ fetchImpl: async () => { throw new Error('offline'); } }), []);
});
test('trafficScotlandFrameRef only resolves loader-shaped sources on the official origin', () => {
assert.deepEqual(
trafficScotlandFrameRef({ id: 'ts-144', sourceKind: 'traffic-scotland', snapshotUrl: 'https://www.traffic.gov.scot/tsis/camerahtml?sid=77' }),
{ sid: '77', tid: '144' },
);
assert.equal(trafficScotlandFrameRef({ id: 'ts-144', sourceKind: 'tfl-open-data', snapshotUrl: 'https://www.traffic.gov.scot/tsis/camerahtml?sid=77' }), null);
assert.equal(trafficScotlandFrameRef({ id: 'tfl-144', sourceKind: 'traffic-scotland', snapshotUrl: 'https://www.traffic.gov.scot/tsis/camerahtml?sid=77' }), null);
assert.equal(trafficScotlandFrameRef({ id: 'ts-144', sourceKind: 'traffic-scotland', snapshotUrl: 'https://evil.example/tsis/camerahtml?sid=77' }), null);
assert.equal(trafficScotlandFrameRef({ id: 'ts-144', sourceKind: 'traffic-scotland', snapshotUrl: 'https://www.traffic.gov.scot/tsis/camerahtml?sid=77;drop' }), null);
assert.equal(trafficScotlandFrameRef(null), null);
});
test('parseTrafficScotlandFrameFragment decodes every raster view and ignores everything else', () => {
const html = fragment([
['143', 'A702 Boghall North', JPEG_A],
['144', 'A702 Boghall South', PNG_C, 'png'],
]) + '<div class="camera-image" tid="145"><img src="data:image/svg+xml;base64,PHN2Zy8+" /></div>'
+ `<div class="camera-image" tid="146"><img src="data:image/jpeg;base64,${Buffer.from('not a jpeg').toString('base64')}" /></div>`
+ '<div class="camera-image" tid="147"><img src="https://www.traffic.gov.scot/not-inline.jpg" /></div>';
const frames = parseTrafficScotlandFrameFragment(html);
assert.deepEqual([...frames.keys()], ['143', '144']);
assert.deepEqual(frames.get('143').body, JPEG_A);
assert.equal(frames.get('143').contentType, 'image/jpeg');
assert.deepEqual(frames.get('144').body, PNG_C);
assert.equal(frames.get('144').contentType, 'image/png');
assert.equal(parseTrafficScotlandFrameFragment('').size, 0);
assert.equal(parseTrafficScotlandFrameFragment(null).size, 0);
});
test('fetchTrafficScotlandFrame shares one upstream fragment across views and cache windows', async () => {
let fetches = 0;
const fetchImpl = async () => {
fetches += 1;
return htmlResponse(fragment([['2143', 'North', JPEG_A], ['2144', 'South', JPEG_B]]));
};
const site = 'https://www.traffic.gov.scot/tsis/camerahtml?sid=2077';
const north = { id: 'ts-2143', sourceKind: 'traffic-scotland', snapshotUrl: site };
const south = { id: 'ts-2144', sourceKind: 'traffic-scotland', snapshotUrl: site };
const t0 = 1_000_000;
const [first, second] = await Promise.all([
fetchTrafficScotlandFrame(north, { fetchImpl, nowMs: t0 }),
fetchTrafficScotlandFrame(south, { fetchImpl, nowMs: t0 }),
]);
assert.equal(fetches, 1, 'concurrent views at one site share a single in-flight fetch');
assert.equal(first.ok, true);
assert.deepEqual(first.body, JPEG_A);
assert.equal(first.contentType, 'image/jpeg');
assert.equal(first.cached, false);
assert.deepEqual(second.body, JPEG_B);
const again = await fetchTrafficScotlandFrame(south, { fetchImpl, nowMs: t0 + TRAFFIC_SCOTLAND_FRAME_CACHE_MS - 1 });
assert.equal(fetches, 1, 'inside the window the decoded set is served from cache');
assert.equal(again.cached, true);
assert.deepEqual(again.body, JPEG_B);
const missing = await fetchTrafficScotlandFrame({ id: 'ts-2999', sourceKind: 'traffic-scotland', snapshotUrl: site }, { fetchImpl, nowMs: t0 });
assert.equal(missing, null, 'a view the site no longer publishes falls through to the fallback chain');
const refreshed = await fetchTrafficScotlandFrame(north, { fetchImpl, nowMs: t0 + TRAFFIC_SCOTLAND_FRAME_CACHE_MS + 1 });
assert.equal(fetches, 2, 'an expired window refetches once');
assert.equal(refreshed.cached, false);
});
test('fetchTrafficScotlandFrame serves a stale frame when the refresh fails and null when nothing is known', async () => {
const site = 'https://www.traffic.gov.scot/tsis/camerahtml?sid=3077';
const source = { id: 'ts-3143', sourceKind: 'traffic-scotland', snapshotUrl: site };
const t0 = 5_000_000;
const warm = await fetchTrafficScotlandFrame(source, {
fetchImpl: async () => htmlResponse(fragment([['3143', 'Only', JPEG_A]])),
nowMs: t0,
});
assert.equal(warm?.ok, true);
const stale = await fetchTrafficScotlandFrame(source, {
fetchImpl: async () => htmlResponse('<html>maintenance</html>', 503),
nowMs: t0 + TRAFFIC_SCOTLAND_FRAME_CACHE_MS + 1,
});
assert.equal(stale?.ok, true);
assert.equal(stale.stale, true);
assert.deepEqual(stale.body, JPEG_A);
const wrongType = await fetchTrafficScotlandFrame(
{ id: 'ts-4143', sourceKind: 'traffic-scotland', snapshotUrl: 'https://www.traffic.gov.scot/tsis/camerahtml?sid=4077' },
{ fetchImpl: async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), nowMs: t0 },
);
assert.equal(wrongType, null, 'a non-HTML upstream body is never parsed');
let observedSignal = null;
const startedAt = Date.now();
const timedOut = await fetchTrafficScotlandFrame(
{ id: 'ts-5143', sourceKind: 'traffic-scotland', snapshotUrl: 'https://www.traffic.gov.scot/tsis/camerahtml?sid=5077' },
{
timeoutMs: 20,
nowMs: t0,
fetchImpl: (_url, options) => new Promise((_resolve, reject) => {
observedSignal = options.signal;
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
}),
},
);
assert.equal(timedOut, null);
assert.ok(observedSignal instanceof AbortSignal);
assert.equal(observedSignal.aborted, true);
assert.ok(Date.now() - startedAt < 500, 'timeout must settle promptly');
assert.equal(await fetchTrafficScotlandFrame({ id: 'tfl-1', sourceKind: 'tfl-open-data', snapshotUrl: 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/1.jpg' }), null);
});

View File

@ -3517,12 +3517,23 @@ function isVideoFeedType(feedType) {
// ---------------------------------------------------------------------------
/** Path to the optional static CCTV source list (JSON array). */
const DEFAULT_CCTV_SOURCE_FILE = 'config/cctv_sources.austin.json';
/** Hand-authored packs that ship with the repo and load as peers of the live
* open-data packs (same gate, same kill-switch style): cameras whose operators
* publish a plain still-image URL but no machine-readable catalog, so their
* positions and poses are curated by hand. Each file is a JSON array in the
* CCTV_SOURCES_FILE shape. `CCTV_BUNDLED_PACKS_ENABLED=0` skips them all. */
const BUNDLED_CCTV_PACK_FILES = Object.freeze([
'config/cctv_sources.scotland.curated.json', // Perth & Kinross Council road cams + Scottish mountain/ski webcams
]);
/** Austin Open Data portal endpoint for traffic camera records. */
const DEFAULT_AUSTIN_ROWS_URL = 'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD';
/** Default cap on Austin cameras after distance-based prioritization. */
const DEFAULT_AUSTIN_MAX_SOURCES = 250;
/** Global cap on total CCTV sources served by the proxy. */
const DEFAULT_CCTV_MAX_SOURCES = 900;
/** Global cap on total CCTV sources served by the proxy. Sized to the sum of
* the default per-pack caps (Austin 250 + Caltrans 300 + TfL 250 + Traffic
* Scotland 300) plus the bundled curated packs (~45) so no default pack is
* silently truncated. */
const DEFAULT_CCTV_MAX_SOURCES = 1150;
/** Reference point for Austin camera prioritization (Congress & 6th). */
const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
@ -3543,6 +3554,29 @@ const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
const DEFAULT_TFL_MAX_SOURCES = 250;
const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
/** Traffic Scotland (Transport Scotland trunk-road cameras). The public site
* serves a keyless JSON catalog with coordinates, and one HTML fragment per
* SITE whose `<img>` tags carry the current stills as base64 data URIs. Both
* are the endpoints the public website itself uses, not a documented API; the
* documented developer route is a registered FTP service. Frames are decoded
* and cached per site (see fetchTrafficScotlandFrame) so one upstream request
* serves every view at a site for one cache window. */
const TRAFFIC_SCOTLAND_CAMERAS_URL = 'https://www.traffic.gov.scot/tsis/cameras';
const TRAFFIC_SCOTLAND_FRAME_ORIGIN = 'https://www.traffic.gov.scot/tsis/camerahtml';
const DEFAULT_TRAFFIC_SCOTLAND_MAX_SOURCES = 300;
/** Prioritization anchors: Edinburgh and Glasgow centres the central belt
* holds roughly two thirds of the ~500 published views. */
const SCOTLAND_ANCHORS = [
{ lat: 55.9533, lon: -3.1883 }, // Edinburgh
{ lat: 55.8642, lon: -4.2518 }, // Glasgow
];
/** Traffic Scotland publishes new stills about every 5 min in the Edinburgh /
* Glasgow area and about every 20 min elsewhere; a 4 min server cache keeps
* the active 10 s client refresh from re-downloading an unchanged fragment. */
export const TRAFFIC_SCOTLAND_FRAME_CACHE_MS = 4 * 60 * 1000;
const TRAFFIC_SCOTLAND_FRAME_CACHE_MAX_SITES = 600;
/** A two-view fragment is ~16 KB; anything near this cap is not a camera page. */
const TRAFFIC_SCOTLAND_FRAGMENT_MAX_BYTES = 4 * 1024 * 1024;
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) infrequent. Frames are fetched per-request and are unaffected. */
const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
@ -3609,6 +3643,48 @@ function loadSourcesFromFile() {
}
}
/**
* Load the repo's bundled curated CCTV packs (BUNDLED_CCTV_PACK_FILES).
*
* Unlike CCTV_SOURCES_FILE these never suppress the live packs they are
* additive defaults. Every entry must carry an https(?) image URL and finite
* coordinates; anything else is dropped here so a hand-edit mistake degrades
* to a missing camera, not a broken catalog. Each file fails independently.
*
* @param {object} [options]
* @param {readonly string[]} [options.files=BUNDLED_CCTV_PACK_FILES] - Injectable for tests.
* @param {NodeJS.ProcessEnv} [options.env=process.env] - Injectable for tests.
* @returns {Array<object>} Raw source objects ready for normalizeSourceItem.
*/
export function loadBundledCuratedSources({ files = BUNDLED_CCTV_PACK_FILES, env = process.env } = {}) {
if (String(env.CCTV_BUNDLED_PACKS_ENABLED || '1').trim() === '0') return [];
const sources = [];
for (const file of files) {
const resolved = path.isAbsolute(file) ? file : path.resolve(__dirname, file);
try {
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
if (!Array.isArray(parsed)) {
console.warn('[CCTV] bundled pack is not a JSON array:', file);
continue;
}
let kept = 0;
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const id = String(item.id || '').trim();
const url = typeof item.url === 'string' ? item.url.trim() : '';
if (!id || !/^https?:\/\//i.test(url)) continue;
if (!Number.isFinite(toFiniteNumber(item.lat)) || !Number.isFinite(toFiniteNumber(item.lon))) continue;
sources.push(item);
kept += 1;
}
console.log(`[CCTV] Loaded bundled pack ${path.basename(file)}: ${kept} cameras`);
} catch (error) {
console.warn('[CCTV] failed to read bundled pack:', file, error?.message || error);
}
}
return sources;
}
/**
* Load CCTV sources from the CCTV_SOURCES_JSON env variable (inline JSON).
*
@ -4168,6 +4244,239 @@ async function loadTflSourcesFromOpenData() {
}
}
/**
* Load Traffic Scotland trunk-road cameras from the public site's camera catalog.
*
* Each catalog row is a SITE (`sid`) with coordinates and a comma-separated
* list of image ids (`images`, e.g. `"143,144"` for a north/south pair). Every
* image id becomes one camera so each view gets its own frame, pose, and
* calibration. Frames are not direct image URLs (see fetchTrafficScotlandFrame):
* `snapshotUrl` carries the site fragment URL and the camera id carries the
* view id, and the proxy resolves the pair server-side.
*
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl=fetch] - Injectable for unit tests only.
* @returns {Promise<object[]>} Normalizable camera sources, nearest-first to the anchors.
*/
export async function loadTrafficScotlandSourcesFromOpenData({ fetchImpl = fetch } = {}) {
try {
const resp = await fetchImpl(TRAFFIC_SCOTLAND_CAMERAS_URL, {
headers: { Accept: 'application/json', 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] Traffic Scotland catalog download failed:', resp.status);
return [];
}
const payload = await resp.json();
const rows = Array.isArray(payload?.results) ? payload.results : [];
const cameras = [];
const seenIds = new Set();
for (const row of rows) {
const sid = String(row?.sid ?? '').trim();
if (!/^\d+$/.test(sid)) continue;
const lat = toFiniteNumber(row?.lat);
const lon = toFiniteNumber(row?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Scotland bbox sanity gate (Shetland to the Solway; Western Isles to the east coast).
if (lat < 54.5 || lat > 61 || lon < -8.5 || lon > -0.5) continue;
const title = String(row?.title || '').replace(/\s+/g, ' ').trim() || `Traffic Scotland site ${sid}`;
const views = String(row?.images ?? '')
.split(',')
.map((token) => token.trim())
.filter((token) => /^\d+$/.test(token));
if (!views.length) continue;
const frameUrl = `${TRAFFIC_SCOTLAND_FRAME_ORIGIN}?sid=${sid}`;
views.forEach((tid, index) => {
const cameraId = `ts-${tid}`;
if (seenIds.has(cameraId)) return;
seenIds.add(cameraId);
cameras.push({
id: cameraId,
name: views.length > 1 ? `${title} (cam ${index + 1})` : title,
city: 'Scotland',
cityId: '',
provider: 'Traffic Scotland',
lat,
lon,
// The catalog carries no facing; multi-view labels ("... North" /
// "... South") only exist inside the frame fragment. Id-hash prior,
// corrected by manual calibration like every other live pack.
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
// Roadside masts over a trunk road / motorway: lower and flatter than
// an urban pole camera, looking along the carriageway.
pitchDeg: -14,
fovDeg: 48,
rangeM: 260,
mountHeightM: 10,
groundElevationM: 60, // central-belt prior; the terrain lookup corrects it.
feedType: 'image',
url: frameUrl,
snapshotUrl: frameUrl,
sourceKind: 'traffic-scotland',
license: 'Traffic Scotland (Transport Scotland) — traffic.gov.scot',
});
});
}
const maxRaw = Number(process.env.CCTV_TRAFFIC_SCOTLAND_MAX_SOURCES || DEFAULT_TRAFFIC_SCOTLAND_MAX_SOURCES);
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(600, Math.floor(maxRaw))) : DEFAULT_TRAFFIC_SCOTLAND_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, SCOTLAND_ANCHORS);
console.log(`[CCTV] Loaded Traffic Scotland sources: ${cameras.length} views at ${rows.length} sites (using nearest ${prioritized.length})`);
return prioritized;
} catch (error) {
console.warn('[CCTV] Traffic Scotland catalog download error:', error?.message || error);
return [];
}
}
/**
* Resolve the Traffic Scotland site/view pair a registered source points at.
*
* Only sources the loader above produced qualify: the kind must match, the id
* must be `ts-<view id>`, and the snapshot URL must sit on the official
* fragment origin. Anything else (file/env packs, other providers) returns
* null and takes the ordinary image path.
*
* @param {object} source - Normalized CCTV source.
* @returns {{sid:string,tid:string}|null}
*/
export function trafficScotlandFrameRef(source) {
if (!source || source.sourceKind !== 'traffic-scotland') return null;
const tid = /^ts-(\d+)$/.exec(String(source.id || ''))?.[1];
if (!tid) return null;
const snapshot = String(source.snapshotUrl || source.url || '');
if (!snapshot.startsWith(`${TRAFFIC_SCOTLAND_FRAME_ORIGIN}?`)) return null;
let sid = '';
try {
sid = new URL(snapshot).searchParams.get('sid') || '';
} catch {
return null;
}
if (!/^\d+$/.test(sid)) return null;
return { sid, tid };
}
/**
* Extract every embedded still from a Traffic Scotland site fragment.
*
* The fragment is `<div class="camera-image" tid="N"><img src="data:image/jpeg;base64,…">`
* once per view (hidden views included). Only raster data URIs are accepted and
* the decoded bytes must start like a JPEG or PNG the fragment is third-party
* HTML and nothing else in it is trusted.
*
* @param {string} html
* @returns {Map<string,{body:Buffer,contentType:string}>} keyed by view id.
*/
export function parseTrafficScotlandFrameFragment(html) {
const frames = new Map();
const pattern = /<div\b[^>]*\bclass="camera-image"[^>]*\btid="(\d+)"[^>]*>\s*<img\b[^>]*\bsrc="data:image\/(jpeg|jpg|png);base64,([A-Za-z0-9+/=]+)"/g;
for (const match of String(html || '').matchAll(pattern)) {
const [, tid, subtype, base64] = match;
const body = Buffer.from(base64, 'base64');
const isJpeg = body.length > 4 && body[0] === 0xff && body[1] === 0xd8;
const isPng = body.length > 8 && body[0] === 0x89 && body[1] === 0x50 && body[2] === 0x4e && body[3] === 0x47;
if (!isJpeg && !isPng) continue;
frames.set(tid, {
body,
contentType: isPng ? 'image/png' : 'image/jpeg',
});
}
return frames;
}
/** @type {Map<string,{at:number,frames:Map<string,{body:Buffer,contentType:string}>}>} site id -> decoded views */
const _trafficScotlandFrameCache = new Map();
/** @type {Map<string,Promise<Map<string,{body:Buffer,contentType:string}>|null>>} site id -> in-flight fragment fetch */
const _trafficScotlandFrameInflight = new Map();
async function fetchTrafficScotlandSiteFrames(sid, { fetchImpl, timeoutMs }) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(new DOMException('Traffic Scotland frame fetch timed out', 'TimeoutError'));
}, timeoutMs);
try {
const upstream = await fetchImpl(`${TRAFFIC_SCOTLAND_FRAME_ORIGIN}?sid=${encodeURIComponent(sid)}`, {
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', Accept: 'text/html' },
signal: controller.signal,
});
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok || !contentType.includes('text/html')) return null;
const { tooLarge, text } = await readCappedResponseText(upstream, TRAFFIC_SCOTLAND_FRAGMENT_MAX_BYTES);
if (tooLarge) return null;
return parseTrafficScotlandFrameFragment(text);
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Fetch one Traffic Scotland view within the frame-refresh budget.
*
* Mirrors fetchCctvImageFromUpstream's contract (`{ok, body, contentType}` or
* null so the caller falls through to Street View / synthetic), but the
* upstream is an HTML fragment carrying every view at the site, so the decoded
* set is cached per site for `cacheMs` and shared across concurrent requests.
* When a refresh fails but a stale set exists, the stale frame is served
* the site's own images are only replaced every 520 min anyway.
*
* @param {object} source - Normalized CCTV source (see trafficScotlandFrameRef).
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl=fetch]
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS]
* @param {number} [options.nowMs=Date.now()] - Injectable clock for cache tests.
* @param {number} [options.cacheMs=TRAFFIC_SCOTLAND_FRAME_CACHE_MS]
* @returns {Promise<{ok:true,body:Buffer,contentType:string,cached:boolean,stale:boolean}|null>}
*/
export async function fetchTrafficScotlandFrame(source, {
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
nowMs = Date.now(),
cacheMs = TRAFFIC_SCOTLAND_FRAME_CACHE_MS,
} = {}) {
const ref = trafficScotlandFrameRef(source);
if (!ref) return null;
const { sid, tid } = ref;
const toResult = (hit, cached, stale) => (hit
? { ok: true, body: hit.body, contentType: hit.contentType, cached, stale }
: null);
const cached = _trafficScotlandFrameCache.get(sid);
if (cached && nowMs - cached.at < cacheMs) {
return toResult(cached.frames.get(tid), true, false);
}
let inflight = _trafficScotlandFrameInflight.get(sid);
if (!inflight) {
inflight = fetchTrafficScotlandSiteFrames(sid, { fetchImpl, timeoutMs })
.then((frames) => {
if (frames && frames.size > 0) {
if (_trafficScotlandFrameCache.size >= TRAFFIC_SCOTLAND_FRAME_CACHE_MAX_SITES) {
const oldest = _trafficScotlandFrameCache.keys().next().value;
if (oldest !== undefined) _trafficScotlandFrameCache.delete(oldest);
}
_trafficScotlandFrameCache.delete(sid);
_trafficScotlandFrameCache.set(sid, { at: nowMs, frames });
}
return frames;
})
.finally(() => {
_trafficScotlandFrameInflight.delete(sid);
});
_trafficScotlandFrameInflight.set(sid, inflight);
}
const frames = await inflight;
const fresh = frames?.get(tid);
if (fresh) return toResult(fresh, false, false);
return toResult(cached?.frames.get(tid), true, true);
}
/**
* Normalize a raw CCTV source item into a canonical shape with safe defaults.
*
@ -4195,6 +4504,9 @@ function normalizeSourceItem(item) {
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
license: String(item.license || item.licenseNote || ''),
sourceKind: String(item.sourceKind || item.kind || 'configured'),
// Optional per-camera still cadence hint (ms). Curated packs set it from
// the operator's stated update interval; the client bounds it (60 s..20 min).
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
// Optional CAL badge input (cctv-v2 design §3b/§9.2, additive-only per the
// global constraints — nothing else in this file changes): hand-authored
// file/env catalog entries may declare poseSource:'curated' so the panel
@ -4239,27 +4551,34 @@ async function refreshCctvSources() {
const forceAustin = String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
const preferAustin = String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
// Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack
// is configured and live packs aren't forced — same gate that governed the
// Austin-only fetch, now governing all three. Each pack fails independently.
// Live open-data packs (Austin + Caltrans + TfL + Traffic Scotland) load
// unless a file/env pack is configured and live packs aren't forced — same
// gate that governed the Austin-only fetch, now governing all four. Each
// pack fails independently.
const needsLiveSources = forceAustin || ((fromFile.length + fromEnv.length) === 0 && preferAustin);
const tflEnabled = String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
const trafficScotlandEnabled = String(process.env.CCTV_TRAFFIC_SCOTLAND_ENABLED || '1').trim() !== '0';
let fromAustin = [];
let fromCaltrans = [];
let fromTfl = [];
let fromTrafficScotland = [];
let fromBundled = [];
if (needsLiveSources) {
const [austinResult, caltransResult, tflResult] = await Promise.allSettled([
fromBundled = loadBundledCuratedSources();
const [austinResult, caltransResult, tflResult, trafficScotlandResult] = await Promise.allSettled([
loadAustinSourcesFromOpenData(),
loadCaltransSourcesFromOpenData(),
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
trafficScotlandEnabled ? loadTrafficScotlandSourcesFromOpenData() : Promise.resolve([]),
]);
fromAustin = austinResult.status === 'fulfilled' ? austinResult.value : [];
fromCaltrans = caltransResult.status === 'fulfilled' ? caltransResult.value : [];
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
fromTrafficScotland = trafficScotlandResult.status === 'fulfilled' ? trafficScotlandResult.value : [];
}
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv];
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromTrafficScotland, ...fromBundled, ...fromFile, ...fromEnv];
// Deduplicate by camera ID (last-write wins because of Map.set)
const byId = new Map();
@ -4621,6 +4940,7 @@ function cctvProxy() {
sourceKind: source.sourceKind || (source.url ? 'configured' : 'fallback'),
poseSource: source.poseSource,
license: source.license,
frameRefreshMs: Number.isFinite(source.frameRefreshMs) ? source.frameRefreshMs : undefined,
})),
};
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
@ -4661,6 +4981,36 @@ function cctvProxy() {
return;
}
// Traffic Scotland registers an HTML fragment as its upstream; the
// still has to be decoded out of it rather than piped through.
if (trafficScotlandFrameRef(source)) {
const frame = await fetchTrafficScotlandFrame(source);
if (frame?.ok) {
setHealth(cameraId, {
status: 'ok',
sourceKind: 'snapshot',
label: source.provider,
message: frame.stale ? 'Snapshot feed connected (stale frame)' : 'Snapshot feed connected',
});
res.writeHead(200, {
'Content-Type': frame.contentType,
'Cache-Control': 'no-store',
'X-CCTV-Source': 'upstream-image',
});
res.end(frame.body);
return;
}
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
label: source.provider,
message: 'Traffic Scotland frame unavailable',
});
res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: 'Media proxy failed' }));
return;
}
try {
const upstreamHeaders = { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' };
const requestRange = req.headers?.range;
@ -4736,7 +5086,9 @@ function cctvProxy() {
source?.snapshotUrl
|| (!isVideoFeedType(normalizeFeedType(source?.feedType)) ? source?.url : '');
const upstreamImage = await fetchCctvImageFromUpstream(upstreamCandidate);
const upstreamImage = trafficScotlandFrameRef(source)
? await fetchTrafficScotlandFrame(source)
: await fetchCctvImageFromUpstream(upstreamCandidate);
if (upstreamImage?.ok) {
setHealth(cameraId, {
status: 'ok',