diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9c3262770..6e8c2edd8 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -40,6 +40,7 @@ How to read this: | **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) | | **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" | | **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | +| **DelDOT (video.deldot.gov)** | CCTV camera catalog + live RTMP/HLS video, Delaware | Public DelDOT traffic camera streams via the [DelDOT traffic map](https://deldot.gov/map/) |"DelDOT — Delaware Department of Transportation" (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" | | **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario | | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | @@ -58,6 +59,7 @@ How to read this: - **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. - **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. +- **DelDOT live video cameras.** The camera list is the keyless `tmc.deldot.gov/json/videocamera.json` catalog; only cameras reporting `Active` are registered, and each camera's `rtmpt://video.deldot.gov:80` stream URL is preferred (the identical stream to `rtmp://…:1935`, tunneled over HTTP). Live video is remuxed transiently on the server — segments live under the OS temp dir in a short rolling window, pruned and reaped when idle, never stored beyond that window or redistributed. Disable with `CCTV_DELDOT_ENABLED=0`. - **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/.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. - **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. diff --git a/README.md b/README.md index 5b0622ba5..e5d3299b7 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,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, California, or Finland. 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, California, Finland, or Delaware (live video). 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) @@ -277,7 +277,7 @@ Fourteen layers and map sources. **Twelve 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** | ~3,600 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,600 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. 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 | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | @@ -433,6 +433,32 @@ OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth cred +
+Live video cameras (HLS / RTMP) + +CCTV sources with `"feedType": "hls"` play as continuous video instead of +refreshed stills. The server owns the stream: it keeps one upstream session +per active camera, serves the browser a locally generated playlist, and the +client plays it through hls.js. Two upstream strategies, chosen by URL: + +- **`.m3u8` upstreams** are pulled directly in Node — no extra dependencies. +- **RTMP and other stream URLs** go through **ffmpeg** (`-c copy`, no + re-encode), which is optional: without it those cameras fall back to the + stills path and everything else is unchanged. If you self-host in Docker, + add `ffmpeg` to your image. + +The DelDOT Delaware pack is the working example — every `Active` camera in +DelDOT's public catalog registers with its `rtmpt://video.deldot.gov:80/…` +stream (the same stream as `rtmp://…:1935`, tunneled over HTTP, so it works +where port 1935 is blocked). `CCTV_DELDOT_ENABLED=0` turns the pack off. + +Some agencies restart their streams on a timer or run encoders whose clock +lags real time (DelDOT does both). The pipeline absorbs that automatically — +a brief hitch at a restart, and a playback-rate governor that holds the +stream a few seconds behind live so it never starves. Nothing to configure. + +
+ ### 💸 What it actually costs Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages: diff --git a/SECURITY.md b/SECURITY.md index 9bf718b74..ee0a1625d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -62,6 +62,7 @@ browser-key fallback for existing single-key setups. The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay: - **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 video stays origin-pinned.** `/api/cctv/media//…` sub-paths resolve only against the registered camera's origin; a playlist reference or segment that would resolve elsewhere is refused. Segment stores live under the OS temp dir, keyed by a sanitized camera id, and are pruned to a fixed window and reaped when idle. When ffmpeg is present it is spawned only with a registered camera URL as input — never anything supplied by a client. - **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. - **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. - **Response-size caps and timeouts** on proxied responses. diff --git a/TESTING.md b/TESTING.md index ed070a03d..1c2e907af 100644 --- a/TESTING.md +++ b/TESTING.md @@ -210,6 +210,18 @@ While recording, call out anything in these areas — this is the feedback I mos happened? Any command it misunderstood? - **Anything that looks wrong, janky, or surprising** — screenshot it; that's the gold. +--- + +## 6. Live video CCTV (HLS / RTMP) — soak, no mic + +> Requires the DelDOT pack (loads by default; `CCTV_DELDOT_ENABLED=0` disables it) and, for RTMP cameras, `ffmpeg` on the server's PATH. + +1. Turn on **CCTV**, fly to Dover, DE, and activate **DE 1 @ Main Toll Plaza**. ✅ Within ~10 s the monitor plane and the panel preview both show moving video, and the panel widens for it. +2. Leave it for **five minutes**. ✅ Continuous playback; at most a short hitch about once a minute (the agency restarts its stream on a timer). ❌ A freeze that does not recover, or the panel picture stopping while the plane keeps moving. +3. Toggle **PROJECTION** off. ✅ The panel keeps playing; the plane and the active camera's cone hide. +4. Switch to **DE 8 @ Saulsbury Rd**, then back. ✅ Each switch resumes within ~10 s with no stale frame from the previous camera. +5. Optional: DevTools → Network, filter `media/`. ✅ Playlist responses carry `X-CCTV-Source: ffmpeg-remux` (RTMP) or `hls-pull` (HLS); segments return `video/mp2t`. + ## If something looks off - **Grey globe / slow tiles:** wait a few seconds after a camera flight; photoreal streams in. diff --git a/package-lock.json b/package-lock.json index c24590e4a..f854563a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@mapbox/vector-tile": "^3.0.0", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "hls.js": "^1.7.3", "mgrs": "^2.1.0", "pbf": "^5.1.2", "satellite.js": "^6.0.2" @@ -1944,6 +1945,12 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "license": "MIT" }, + "node_modules/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/package.json b/package.json index 6f8a5b411..fa28df559 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", @@ -49,6 +49,7 @@ "@mapbox/vector-tile": "^3.0.0", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "hls.js": "^1.7.3", "mgrs": "^2.1.0", "pbf": "^5.1.2", "satellite.js": "^6.0.2" diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 5f601532f..44be68bf3 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -1,3 +1,5 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; import { createCctvCatalog } from './cctv/catalog.js'; import { normalizeFeedType, @@ -17,6 +19,7 @@ import { CCTV_MAX_SOURCES_CEILING, } from './cctv/constants.js'; import { sanitizeCctvRangeHeader } from './cctv/range.js'; +import { createHlsPuller, createHlsRemuxer } from './cctv/stream.js'; import { googleServerApiKey } from './places/google-key.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; /** @@ -40,6 +43,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { * CCTV_MAX_SOURCES ceiling so health/status observability is never evicted * for any catalog the proxy can actually serve. */ const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING; + /** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */ + const puller = createHlsPuller(); + const remuxer = createHlsRemuxer(); /** Update the health entry for a camera, evicting the oldest entry if at capacity. */ const setHealth = (cameraId, patch) => { @@ -127,6 +133,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }; const installMiddleware = (server) => { + server.httpServer?.on('close', () => { + puller.shutdown(); + + remuxer.shutdown(); + }); server.middlewares.use('/api/cctv', async (req, res) => { try { const sources = await getCctvSources(); @@ -194,13 +205,134 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } if (url.pathname.startsWith('/media/')) { + // /media/ is the playlist (or the whole media for non-HLS feeds); + + // /media// is a playlist- or segment-relative sub-path. + + const mediaPath = url.pathname.replace('/media/', ''); + + const slash = mediaPath.indexOf('/'); + const cameraId = - decodeURIComponent(url.pathname.replace('/media/', '').trim()) || - 'camera'; + decodeURIComponent( + (slash === -1 ? mediaPath : mediaPath.slice(0, slash)).trim(), + ) || 'camera'; + + const rel = slash === -1 ? '' : mediaPath.slice(slash + 1); + const source = sourceById.get(cameraId); + const mediaUrl = source?.url || ''; + const feedType = normalizeFeedType(source?.feedType || 'image'); + // HLS feeds are served from a proxy-owned segment store with a + + // proxy-generated playlist, so upstream session churn never reaches + + // the browser. http(s) .m3u8 upstreams use the Node puller; RTMP and + + // other stream kinds use ffmpeg when it is available. Anything else + + // falls through to the direct proxy below. + + const hlsStrategy = + feedType === 'hls' && + mediaUrl && + /^(https?|rtmps?|rtmpt):\/\//i.test(mediaUrl) + ? /\.m3u8(\?|$)/i.test(mediaUrl) + ? puller + : remuxer.isAvailable() + ? remuxer + : null + : null; + + if (hlsStrategy) { + const entry = await hlsStrategy.ensure(cameraId, mediaUrl); + + const safe = path.basename(rel || 'out.m3u8'); + + const isPlaylist = safe.endsWith('.m3u8'); + + const starting = () => { + res.writeHead(503, { + 'Content-Type': 'application/json', + + 'Cache-Control': 'no-store', + + 'Retry-After': '2', + }); + + res.end(JSON.stringify({ error: 'Stream starting' })); + }; + + if (isPlaylist) { + if (!(await hlsStrategy.waitReady(entry))) { + starting(); + + return; + } + + const playlist = await hlsStrategy.buildPlaylist(entry, cameraId); + + if (!playlist) { + starting(); + + return; + } + + setHealth(cameraId, { + status: 'ok', + + sourceKind: 'live', + + label: source?.provider || 'Configured source', + + message: 'Live stream connected', + }); + + res.writeHead(200, { + 'Content-Type': 'application/vnd.apple.mpegurl', + + 'Cache-Control': 'no-store', + + 'X-CCTV-Source': + hlsStrategy === puller ? 'hls-pull' : 'ffmpeg-remux', + }); + + res.end(playlist); + + return; + } + + try { + entry.lastAccess = Date.now(); + + const buf = await fsp.readFile(path.join(entry.dir, safe)); + + res.writeHead(200, { + 'Content-Type': 'video/mp2t', + + 'Cache-Control': 'no-store', + + 'X-CCTV-Source': + hlsStrategy === puller ? 'hls-pull' : 'ffmpeg-remux', + }); + + res.end(buf); + } catch { + res.writeHead(404, { + 'Content-Type': 'application/json', + + 'Cache-Control': 'no-store', + }); + + res.end(JSON.stringify({ error: 'Segment not found' })); + } + + return; + } + if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) { setHealth(cameraId, { status: 'degraded', diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index a8519c85c..ae0ab9afa 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -17,6 +17,7 @@ import { loadWarendorfSourcesFromCatalog, loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, + loadDelDOTSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -86,6 +87,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CALGARY_ENABLED'), load: loadCalgarySourcesFromOpenData, }, + { + name: 'deldot', + enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), + load: loadDelDOTSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index d66806e45..13578d0b8 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -236,6 +236,14 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 }; * body cannot be buffered without limit. */ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ +export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; +export const DEFAULT_DELDOT_MAX_SOURCES = 300; +export const DELDOT_ANCHORS = [ + { lat: 39.7459, lon: -75.5466 }, // Wilmington (New Castle) + { lat: 39.1582, lon: -75.5244 }, // Dover (Kent) + { lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex) +]; /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; /** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 41471f5ba..9cb9910a5 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -56,6 +56,9 @@ import { CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, CCTV_SOURCE_FETCH_TIMEOUT_MS, + DELDOT_CCTV_URL, + DEFAULT_DELDOT_MAX_SOURCES, + DELDOT_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -1594,3 +1597,94 @@ export async function loadCalgarySourcesFromOpenData() { return []; } } + +export async function loadDelDOTSourcesFromOpenData() { + try { + const resp = await fetch(DELDOT_CCTV_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] DelDOT source download failed:', resp.status); + return []; + } + const payload = await resp.json(); + const rows = Array.isArray(payload?.videoCameras) + ? payload.videoCameras + : []; + if (!rows.length) return []; + + const cameras = []; + for (const row of rows) { + if (String(row?.status).toLowerCase() !== 'active') continue; + const lat = toFiniteNumber(row?.lat); + const lon = toFiniteNumber(row?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + // Delaware bounding box — a bad upstream coord can't place a camera out of state. + if (lat < 38.4 || lat > 39.9 || lon < -75.85 || lon > -75.0) continue; + + // Official-host pin: RTMP-over-HTTP (rtmpt:80) is the steady transport. + const streamUrl = String(row?.urls?.rtmp || ''); + if (!streamUrl.startsWith('rtmpt://video.deldot.gov:80/live/')) continue; + + const id = String(row?.id || '').trim(); + if (!id) continue; + const cameraId = `deldot-${id.toLowerCase()}`; + const title = String(row?.title || '').trim(); + + // Title carries an unambiguous travel token for ~1 in 5 cameras + // ("US 13 SB @ ..."). directionToHeading (allowBare=false) matches only + // NB/SB/EB/WB, never a bare "W"/"N" street prefix — "W NORTH ST" yields + // no heading and falls back. + const heading = directionToHeading(title); + const hasHeading = Number.isFinite(heading); + + cameras.push({ + id: cameraId, + name: title || `DelDOT ${id}`, + city: String(row?.county ? `${row.county} County` : 'Delaware'), + cityId: `deldot-${String(row?.county || 'de') + .toLowerCase() + .replace(/\s+/g, '-')}`, + provider: 'DelDOT', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Fabricated RAW PRIOR poses (same personalities as Austin/Caltrans); + // the client ground-snap + manual calibration own the truth. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: 10, // Delaware is near sea level and flat. + feedType: 'hls', + url: streamUrl, + snapshotUrl: streamUrl, + sourceKind: 'deldot-open-data', + license: 'Public DelDOT traffic camera', + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_DELDOT_MAX_SOURCES || DEFAULT_DELDOT_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(400, Math.floor(maxRaw))) + : DEFAULT_DELDOT_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, DELDOT_ANCHORS); + console.log( + `[CCTV] Loaded DelDOT camera sources: ${unique.length} Active (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] DelDOT source download error:', + error?.message || error, + ); + return []; + } +} diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js new file mode 100644 index 000000000..9310090b3 --- /dev/null +++ b/server/providers/cctv/stream.js @@ -0,0 +1,469 @@ +/** + * Live HLS delivery strategies for the CCTV media proxy. + * + * Both strategies own a per-camera segment store on disk (seg_.ts in a + * temp dir) and generate the playlist the browser sees, so upstream session + * behaviour never reaches the client: + * + * createHlsPuller — for http(s) .m3u8 upstreams. Pure Node, no dependencies. + * createHlsRemuxer — for RTMP and other stream kinds, via ffmpeg (-c copy). + * Optional: absent, those cameras fall back to stills. + * + * Both expose the same surface: ensure(cameraId, url) -> entry, + * waitReady(entry), buildPlaylist(entry, cameraId), shutdown(). The media + * route in ../cctv.js picks a strategy by URL and serves files from entry.dir. + */ +import os from 'node:os'; +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { CCTV_MEDIA_FETCH_TIMEOUT_MS } from './constants.js'; + +/** + * Read a text response with a byte cap, cancelling the body on overflow. + * media.js keeps its byte-buffer reader private; playlists need text. + * @returns {Promise<{tooLarge:boolean,text:string}>} + */ +export async function readCappedResponseText(upstream, maxBytes) { + const declared = Number(upstream.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + try { await upstream.body?.cancel(); } catch { /* no-op */ } + return { tooLarge: true, text: '' }; + } + if (!upstream.body || typeof upstream.body[Symbol.asyncIterator] !== 'function') { + const text = await upstream.text(); + return text.length > maxBytes ? { tooLarge: true, text: '' } : { tooLarge: false, text }; + } + const decoder = new TextDecoder(); + let text = ''; + let total = 0; + for await (const chunk of upstream.body) { + total += chunk.length; + if (total > maxBytes) { + try { await upstream.body.cancel(); } catch { /* no-op */ } + return { tooLarge: true, text: '' }; + } + text += decoder.decode(chunk, { stream: true }); + } + text += decoder.decode(); + return { tooLarge: false, text }; +} + +/** + * Per-camera HLS puller with a proxy-owned segment store (no ffmpeg). + * + * Wowza-style upstreams expire sessions fast, but their segment numbering is + * stream-global (EXT-X-MEDIA-SEQUENCE carries across sessions). So the proxy + * polls the chunklist every ~2 s (which also keeps the session warm), re-reads + * the master the moment a poll is refused, and downloads every segment it has + * not yet seen, keyed by that global sequence. Zero overlap, zero loss across + * session rotations, exact EXTINF durations from upstream, PTS continuous + * because it is the same encoder. Serves the same file layout as + * createHlsRemuxer (seg_.ts in a per-camera dir) so the media route is + * shared. Idle cameras are reaped after IDLE_MS. + */ +export function createHlsPuller() { + const OUT_ROOT = path.join(os.tmpdir(), 'gev-hls-pull'); + const IDLE_MS = 60_000; + const READY_TIMEOUT_MS = 20_000; + const POLL_MS = 2_000; + const PLAYLIST_WINDOW = 15; + const KEEP_ON_DISK = 24; + const MIN_READY_SEGMENTS = 3; + const FETCH_TIMEOUT_MS = CCTV_MEDIA_FETCH_TIMEOUT_MS; + const MAX_PLAYLIST_BYTES = 256 * 1024; + const UA = 'gods-eye-view-cctv-proxy/1.0'; + /** @type {Map} */ + const active = new Map(); + const headers = { 'User-Agent': UA }; + const fetchText = async (url) => { + const r = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; throw e; } + const { tooLarge, text } = await readCappedResponseText(r, MAX_PLAYLIST_BYTES); + if (tooLarge) throw new Error('playlist too large'); + return text; + }; + /** Follow a master playlist to its first media playlist (or return it if already one). */ + const resolveChunklist = async (masterUrl) => { + const text = await fetchText(masterUrl); + if (text.includes('#EXTINF')) return { url: masterUrl, text }; + const first = text.split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#')); + if (!first) throw new Error('master playlist has no variants'); + const url = new URL(first, masterUrl); + if (url.origin !== new URL(masterUrl).origin) throw new Error('variant escapes origin'); + return { url: url.toString(), text: await fetchText(url.toString()) }; + }; + /** Parse a media playlist into [{seq, duration, uri}] using MEDIA-SEQUENCE + index. */ + const parseMedia = (text, baseUrl) => { + let mediaSeq = 0; + let target = 0; + let dur = null; + const out = []; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line) continue; + if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) mediaSeq = Number(line.slice(22)) || 0; + else if (line.startsWith('#EXT-X-TARGETDURATION:')) target = Number(line.slice(22)) || 0; + else if (line.startsWith('#EXTINF:')) dur = parseFloat(line.slice(8)) || null; + else if (!line.startsWith('#')) { + let uri; + try { uri = new URL(line, baseUrl); } catch { continue; } + if (uri.origin !== new URL(baseUrl).origin) continue; + const m = /_(\d+)\.ts(\?|$)/i.exec(uri.pathname); + const seq = m ? Number(m[1]) : mediaSeq + out.length; + out.push({ seq, duration: dur || target || 2, uri: uri.toString() }); + dur = null; + } + } + return { segments: out, target }; + }; + const downloadSegment = async (entry, seg) => { + const r = await fetch(seg.uri, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!r.ok) throw new Error(`segment ${seg.seq} HTTP ${r.status}`); + const buf = Buffer.from(await r.arrayBuffer()); + const file = path.join(entry.dir, `seg_${seg.seq}.ts`); + await fsp.writeFile(`${file}.part`, buf); + await fsp.rename(`${file}.part`, file); + entry.segments.set(seg.seq, { duration: seg.duration }); + }; + const prune = (entry) => { + const seqs = [...entry.segments.keys()].sort((a, b) => a - b); + while (seqs.length > KEEP_ON_DISK) { + const n = seqs.shift(); + entry.segments.delete(n); + fsp.rm(path.join(entry.dir, `seg_${n}.ts`), { force: true }).catch(() => {}); + } + }; + const poll = async (cameraId, entry) => { + if (entry.stopping || entry.polling) return; + entry.polling = true; + try { + let text; + if (!entry.chunklistUrl) { + const r = await resolveChunklist(entry.masterUrl); + entry.chunklistUrl = r.url; + text = r.text; + } else { + try { + text = await fetchText(entry.chunklistUrl); + } catch (err) { + if (err.status === 403 || err.status === 404) { + // Session rotated: re-read the master for the live chunklist. + const r = await resolveChunklist(entry.masterUrl); + if (r.url !== entry.chunklistUrl) console.info(`[CCTV:pull ${cameraId}] session rotated`); + entry.chunklistUrl = r.url; + text = r.text; + } else { + throw err; + } + } + } + const { segments } = parseMedia(text, entry.chunklistUrl); + for (const seg of segments) { + if (entry.stopping) break; + if (entry.segments.has(seg.seq) || entry.inFlight.has(seg.seq)) continue; + if (entry.lastSeq !== null && seg.seq <= entry.lastSeq) continue; // older than what we hold + entry.inFlight.add(seg.seq); + try { + await downloadSegment(entry, seg); + entry.lastSeq = Math.max(entry.lastSeq ?? -1, seg.seq); + } catch (err) { + console.warn(`[CCTV:pull ${cameraId}]`, err.message); + } finally { + entry.inFlight.delete(seg.seq); + } + } + prune(entry); + entry.failures = 0; + } catch (err) { + entry.failures += 1; + if (entry.failures === 1 || entry.failures % 10 === 0) { + console.warn(`[CCTV:pull ${cameraId}] poll failed (${entry.failures}):`, err.message); + } + if (entry.failures >= 3) entry.chunklistUrl = null; // force master re-resolve + } finally { + entry.polling = false; + } + }; + const ensure = async (cameraId, masterUrl) => { + let entry = active.get(cameraId); + if (entry) { + entry.lastAccess = Date.now(); + return entry; + } + const dir = path.join(OUT_ROOT, cameraId.replace(/[^a-zA-Z0-9_-]/g, '_')); + await fsp.rm(dir, { recursive: true, force: true }).catch(() => {}); + await fsp.mkdir(dir, { recursive: true }); + entry = { + dir, + masterUrl, + chunklistUrl: null, + segments: new Map(), + inFlight: new Set(), + lastSeq: null, + lastAccess: Date.now(), + failures: 0, + polling: false, + stopping: false, + timer: null, + }; + active.set(cameraId, entry); + entry.timer = setInterval(() => { poll(cameraId, entry); }, POLL_MS); + entry.timer.unref?.(); + poll(cameraId, entry); + return entry; + }; + const waitReady = async (entry) => { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (entry.segments.size >= MIN_READY_SEGMENTS) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; + }; + const buildPlaylist = async (entry, cameraId) => { + const seqs = [...entry.segments.keys()].sort((a, b) => a - b); + if (seqs.length < 2) return null; + const window = seqs.slice(-PLAYLIST_WINDOW); + const prefix = `/api/cctv/media/${encodeURIComponent(cameraId)}/`; + const maxDur = Math.max(...window.map((n) => entry.segments.get(n).duration)); + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}`, + `#EXT-X-MEDIA-SEQUENCE:${window[0]}`, + ]; + let prev = null; + for (const n of window) { + if (prev !== null && n !== prev + 1) lines.push('#EXT-X-DISCONTINUITY'); + lines.push(`#EXTINF:${entry.segments.get(n).duration.toFixed(3)},`, `${prefix}seg_${n}.ts`); + prev = n; + } + return lines.join('\n') + '\n'; + }; + const stopEntry = (entry) => { + entry.stopping = true; + if (entry.timer) { clearInterval(entry.timer); entry.timer = null; } + fsp.rm(entry.dir, { recursive: true, force: true }).catch(() => {}); + }; + const sweep = setInterval(() => { + const now = Date.now(); + for (const [id, entry] of active) { + if (now - entry.lastAccess > IDLE_MS) { stopEntry(entry); active.delete(id); } + } + }, 30_000); + sweep.unref?.(); + const shutdown = () => { + clearInterval(sweep); + for (const [, entry] of active) stopEntry(entry); + active.clear(); + }; + return { isAvailable: () => true, ensure, waitReady, buildPlaylist, shutdown }; +} +/** + * Per-camera ffmpeg segment producer with a proxy-owned playlist. + * + * Wowza-style upstreams expire HLS sessions fast and irregularly; no single + * long-lived reader survives them (ffmpeg's HLS demuxer exits on a master + * playlist 403 above the -reconnect layer). So ffmpeg is treated as + * disposable: it only writes numbered MPEG-TS segments (-f segment) into a + * per-camera dir, and when it dies it is respawned with -segment_start_number + * continuing from the last file on disk. The playlist is generated here from + * whatever segments exist, with a monotonic media sequence, so respawns are + * invisible to hls.js. -c copy: no re-encode, no quality loss. Processes are + * reaped after IDLE_MS with no client access; dirs are pruned to a window. + */ +export function createHlsRemuxer() { + const OUT_ROOT = path.join(os.tmpdir(), 'gev-hls'); + const IDLE_MS = 60_000; + const READY_TIMEOUT_MS = 20_000; + const SEGMENT_SECONDS = 4; + const STALL_MS = 15_000; + const PLAYLIST_WINDOW = 15; // segments listed + const KEEP_ON_DISK = 24; // segments retained w/ in-flight fetches + const MIN_READY_SEGMENTS = 3; + const RESPAWN_DELAY_MS = 500; + const SEG_RE = /^seg_(\d+)\.ts$/; + /** @type {Map} */ + const active = new Map(); + let available = null; + const isAvailable = () => { + if (available !== null) return available; + try { + const probe = spawn('ffmpeg', ['-version'], { stdio: 'ignore' }); + probe.on('error', () => { available = false; }); + available = true; + } catch { + available = false; + } + return available; + }; + /** Sorted ascending list of segment numbers currently on disk */ + const listSegments = async (dir) => { + let names = []; + try { names = await fsp.readdir(dir); } catch { return []; } + return names + .map((n) => SEG_RE.exec(n)) + .filter(Boolean) + .map((m) => Number(m[1])) + .sort((a, b) => a - b); + }; + /** seg number -> duration, from every list_*.csv in the dir. */ + const readDurations = async (dir) => { + const out = new Map(); + let names = []; + try { names = await fsp.readdir(dir); } catch { return out; } + for (const n of names) { + if (!/^list_\d+\.csv$/.test(n)) continue; + const txt = await fsp.readFile(path.join(dir, n), 'utf8').catch(() => ''); + for (const line of txt.split('\n')) { + const [file, start, end] = line.split(','); + const m = file && SEG_RE.exec(file.trim()); + if (m && start && end) out.set(Number(m[1]), Math.max(0.1, Number(end) - Number(start))); + } + } + return out; + }; + const spawnProducer = (cameraId, entry) => { + if (entry.stopping) return; + entry.boundaries.push(entry.nextSeg); + const proc = spawn('ffmpeg', [ + '-nostdin', '-loglevel', 'warning', + ...(/^rtmp/i.test(entry.upstreamUrl) + ? ['-rtmp_live', 'live', '-rw_timeout', '10000000', + '-fflags', 'nobuffer', '-analyzeduration', '1000000', '-probesize', '500000'] + : ['-user_agent', 'gods-eye-view-cctv-proxy/1.0', + '-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_delay_max', '1']), + '-i', entry.upstreamUrl, + '-c', 'copy', + '-f', 'segment', + '-segment_format', 'mpegts', + '-segment_time', String(SEGMENT_SECONDS), + '-segment_start_number', String(entry.nextSeg), + '-segment_list_type', 'csv', + '-segment_list_size', '0', + '-segment_list', path.join(entry.dir, `list_${entry.nextSeg}.csv`), + '-reset_timestamps', '0', + path.join(entry.dir, 'seg_%d.ts'), + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + entry.proc = proc; + proc.stderr?.on('data', (d) => { + const line = String(d).trim(); + if (line) console.warn(`[CCTV:ffmpeg ${cameraId}]`, line); + }); + proc.on('exit', async (code) => { + if (entry.proc !== proc) return; + entry.proc = null; + const segs = await listSegments(entry.dir); + entry.nextSeg = segs.length ? segs[segs.length - 1] + 1 : entry.nextSeg; + if (entry.stopping || active.get(cameraId) !== entry) return; + const idle = Date.now() - entry.lastAccess > IDLE_MS; + console.warn(`[CCTV:ffmpeg ${cameraId}] exited (${code}); next seg ${entry.nextSeg}${idle ? ' - idle, not respawning' : ' - respawning'}`); + if (idle) return; + entry.respawnTimer = setTimeout(() => { + entry.respawnTimer = null; + spawnProducer(cameraId, entry); + }, RESPAWN_DELAY_MS); + }); + }; + const ensure = async (cameraId, upstreamUrl) => { + let entry = active.get(cameraId); + if (entry) { + entry.lastAccess = Date.now(); + if (!entry.proc && !entry.respawnTimer && !entry.stopping) spawnProducer(cameraId, entry); + return entry; + } + const dir = path.join(OUT_ROOT, cameraId.replace(/[^a-zA-Z0-9_-]/g, '_')); + await fsp.mkdir(dir, { recursive: true }); + const existing = await listSegments(dir); + entry = { + proc: null, + dir, + upstreamUrl, + lastAccess: Date.now(), + nextSeg: existing.length ? existing[existing.length - 1] + 1 : 0, + respawnTimer: null, + stopping: false, + boundaries: [], + }; + active.set(cameraId, entry); + spawnProducer(cameraId, entry); + return entry; + }; + const waitReady = async (entry) => { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + const segs = await listSegments(entry.dir); + if (segs.length >= MIN_READY_SEGMENTS) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; + }; + /** + * Build the playlist from the segments on disk and prune old files. + * Newest segment may still be mid-write so it is excluded. + * @returns {Promise} playlist text, or null if not enough segments. + */ + const buildPlaylist = async (entry, cameraId) => { + const segs = await listSegments(entry.dir); + if (segs.length < 2) return null; + const complete = segs.slice(0, -1); + const window = complete.slice(-PLAYLIST_WINDOW); + const keepFrom = segs.length > KEEP_ON_DISK ? segs[segs.length - KEEP_ON_DISK] : -1; + for (const n of segs) { + if (n < keepFrom) fsp.rm(path.join(entry.dir, `seg_${n}.ts`), { force: true }).catch(() => {}); + } + const prefix = `/api/cctv/media/${encodeURIComponent(cameraId)}/`; + const first = window[0]; + const priorBoundaries = entry.boundaries.filter((b) => b > 0 && b <= first).length; + const durations = await readDurations(entry.dir); + const maxDur = Math.max(SEGMENT_SECONDS, ...window.map((n) => durations.get(n) || SEGMENT_SECONDS)); + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}`, + `#EXT-X-MEDIA-SEQUENCE:${first}`, + `#EXT-X-DISCONTINUITY-SEQUENCE:${priorBoundaries}`, + ]; + let prev = null; + for (const n of window) { + const isBoundary = n !== first && entry.boundaries.includes(n); + const isGap = prev !== null && n !== prev + 1; + const dur = durations.get(n) || SEGMENT_SECONDS; + if (isBoundary || isGap) lines.push('#EXT-X-DISCONTINUITY'); + lines.push(`#EXTINF:${dur.toFixed(3)},`, `${prefix}seg_${n}.ts`); + prev = n; + } + return lines.join('\n') + '\n'; + }; + const stopEntry = (entry) => { + entry.stopping = true; + if (entry.respawnTimer) { clearTimeout(entry.respawnTimer); entry.respawnTimer = null; } + if (entry.proc) entry.proc.kill('SIGTERM'); + fsp.rm(entry.dir, { recursive: true, force: true }).catch(() => {}); + }; + const sweep = setInterval(async () => { + const now = Date.now(); + for (const [id, entry] of active) { + if (now - entry.lastAccess > IDLE_MS) { stopEntry(entry); active.delete(id); continue; } + if (entry.proc) { + const segs = await listSegments(entry.dir); + const newest = segs.length ? segs[segs.length - 1] : -1; + if (newest !== entry.watchSeg) { entry.watchSeg = newest; entry.watchAt = now; } + else if (now - (entry.watchAt || now) > STALL_MS) { + console.warn(`[CCTV:ffmpeg ${id}] no new segment for ${STALL_MS / 1000}s — killing producer`); + entry.watchAt = now; + entry.proc.kill('SIGKILL'); + } + } + } + }, 5_000); + sweep.unref?.(); + const shutdown = () => { + clearInterval(sweep); + for (const [, entry] of active) stopEntry(entry); + active.clear(); + }; + + return { isAvailable, ensure, waitReady, buildPlaylist, shutdown }; +} \ No newline at end of file diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index a80ab7fe1..0deba580e 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) { _setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest, createCctvProjectionOverlayEntry: parts.projection.createCctvProjectionOverlayEntry, + getActiveVideoElement: parts.projection.getActiveVideoElement, setCctvCardPresentationOptions: parts.cards.setCctvCardPresentationOptions, surfaceRegimeKey: parts.ground.surfaceRegimeKey, diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 742869877..13fa751bd 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -83,6 +83,7 @@ export function createPresentation({ mountHeightM: camera.mountHeightM, active: isActive, feedType: camera.feedType, + isVideo: parts.model.isVideoFeedType(camera.feedType), sourceKind: health?.sourceKind || camera.sourceKind || diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index 1ea937961..1d9b54fc7 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -168,6 +168,79 @@ export function createProjection({ * @returns {Object|null} Projection runtime, or null if no viewer. */ + async function attachVideoSource(runtime, url, feedType) { + const video = runtime?.video; + if (!video) return; + if (feedType !== 'hls') { + video.src = url; + return; + } + try { + const { default: Hls } = await import('hls.js'); + if (runtime.disposed || runtime.video !== video) return; + if (!Hls.isSupported()) { + video.src = url; + return; + } + const hls = new Hls({ + lowLatencyMode: false, + enableWorker: true, + liveSyncDurationCount: 5, + liveMaxLatencyDurationCount: 6, + maxBufferLength: 60, + backBufferLength: 30, + }); + hls.on(Hls.Events.ERROR, (_event, data) => { + if (!data?.fatal) return; + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + // reread master for a fresh session + console.warn( + '[Data:CCTV] hls network error, reloading source:', + data.details, + ); + setTimeout(() => { + if (runtime.hls === hls) hls.loadSource(url); + }, 2000); + return; + } + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError(); + return; + } + console.warn('[Data:CCTV] hls fatal:', data.type, data.details); + hls.destroy(); + if (runtime.hls === hls) runtime.hls = null; + }); + hls.loadSource(url); + hls.attachMedia(video); + runtime.hls = hls; + + // Rate governor: DelDOT cameras deliver ~52 s of content per 60 s wall + // time. Hold playbackRate by buffer depth so the stream never starves; + // cameras with honest clocks sit at 1.0 and never leave it. + const governor = setInterval(() => { + if (runtime.video !== video || !runtime.hls) { + clearInterval(governor); + return; + } + const b = video.buffered; + if (!b.length) return; + const ahead = b.end(b.length - 1) - video.currentTime; + let rate = 1.0; + if (ahead < 15) rate = 0.75; + else if (ahead < 20) rate = 0.85; + else if (ahead < 30) rate = 0.93; + else if (ahead > 40) rate = 1.05; + if (Math.abs(video.playbackRate - rate) > 0.01) + video.playbackRate = rate; + }, 1000); + runtime.rateGovernor = governor; + } catch (error) { + console.warn('[Data:CCTV] hls.js unavailable:', error?.message || error); + video.src = url; + } + } + function createProjectionRuntime(record) { if (!layerState._viewer) return null; const canvas = document.createElement('canvas'); @@ -207,6 +280,7 @@ export function createProjection({ // only re-uploads the plane texture when there is genuinely new content. canvasStamp: 1, lastSwappedCanvasStamp: 0, + disposed: false, }; parts.frames.paintProjectionPlaceholder(ctx, record.camera); @@ -214,16 +288,32 @@ export function createProjection({ if (mode === 'video') { const video = document.createElement('video'); video.muted = true; - video.loop = true; - video.autoplay = true; video.playsInline = true; video.crossOrigin = 'anonymous'; video.preload = 'auto'; - video.src = parts.frames.mediaUrlFor(record.camera); video.addEventListener('canplay', () => { video.play().catch(() => {}); }); + // Cesium sizes the video texture from the element's width/height + // attributes at first upload. Set them from the real stream dimensions + // and rebind on any resolution change (camera switch, adaptive source). + const bindVideoTexture = () => { + if (runtime.video !== video || !runtime.planeMaterial) return; + if (!(video.videoWidth > 0 && video.videoHeight > 0)) return; + video.width = video.videoWidth; + video.height = video.videoHeight; + runtime.planeMaterial.image = runtime.canvas; + runtime.planeMaterial.image = video; + }; + video.addEventListener('loadedmetadata', bindVideoTexture); + video.addEventListener('resize', bindVideoTexture); runtime.video = video; + runtime.hls = null; + attachVideoSource( + runtime, + parts.frames.mediaUrlFor(record.camera), + feedType, + ); } else { const img = new Image(); img.decoding = 'async'; @@ -254,7 +344,7 @@ export function createProjection({ const positions = record.frustumPositions || parts.geometry.frustumCartesians(geometry); runtime.planeMaterial = new Cesium.ImageMaterialProperty({ - image: mode === 'video' && runtime.video ? runtime.video : canvas, + image: canvas, transparent: true, color: Cesium.Color.WHITE.withAlpha(0.95), }); @@ -288,6 +378,15 @@ export function createProjection({ function destroyProjectionRuntime(runtime) { if (!runtime) return; + runtime.disposed = true; + if (runtime.hls) { + runtime.hls.destroy(); + runtime.hls = null; + } + if (runtime.rateGovernor) { + clearInterval(runtime.rateGovernor); + runtime.rateGovernor = null; + } if (runtime.video) { runtime.video.pause(); runtime.video.removeAttribute('src'); @@ -355,6 +454,15 @@ export function createProjection({ * @param {string|null} activeId - ID of the currently active camera. */ + /** + * The active camera's decoded