Live ops wall + pluggable camera providers with licence gating (server-26#198)

Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
  catalog cache; one adapter per source in design-canvas/camera-sources.md.
  Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
  endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
  TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
  511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
  TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
  (agreement required / unverified / non-commercial / paid) need an explicit
  acknowledgement stored with who/when and written to audit_log; the audit
  write is a precondition. Stubs and prohibited sources cannot be enabled.
  Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
  + NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
  ("may not alter the substance of a report").

Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
  worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
  bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
  (ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
  nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
  last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
  provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 17:56:06 -04:00
co-authored by Claude Opus 5.5
parent d57738a8f6
commit aa5b71d853
28 changed files with 3342 additions and 132 deletions
+238 -24
View File
@@ -1,41 +1,217 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import type HlsType from "hls.js";
import type { Ny511Camera } from "@/lib/types";
import type { Camera, CameraCredit } from "@/lib/types";
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
// ── Feed hygiene (design-canvas/gods-eye-view-notes.md §2, top-5 #2) ─────────
// A 24/7 wall must not burn bandwidth on feeds nobody can see:
// * hidden tab or off-screen tile → no fetches, live video torn down;
// * at most MAX_LIVE_HLS live decoders page-wide (GEV uses 2); extra tiles
// show their still with a "live cap" badge (the drill-down gets one extra);
// * a failed refresh keeps the last good frame with a "stale Nm" badge;
// * a 64×36 downsample signature detects a frozen camera (same frame for
// FROZEN_AFTER_MS) without repainting it; a stalled HLS stream falls back.
// Video is played straight from the agency's host (no DRB egress).
// Most stills are cached ~60 s upstream, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000;
// A failed stream is not given up on for good: 511NY feeds drop out briefly,
// and a permanent fallback left pinned tiles showing a stale still image.
const VIDEO_RETRY_MS = 30_000;
// In-place recoveries hls.js gets per session before we fall back to the snapshot.
const MAX_HLS_RECOVERIES = 3;
const VIDEO_STALL_MS = 20_000;
const FROZEN_AFTER_MS = 20 * 60_000; // > the slowest provider cadence (Fintraffic ~10 min)
export const MAX_LIVE_HLS = 2;
// ── Page-wide live-stream leases ─────────────────────────────────────────────
const holders = new Set<number>();
const leaseListeners = new Set<() => void>();
let nextLeaseId = 1;
function tryAcquire(id: number, high: boolean): boolean {
if (holders.has(id)) return true;
if (holders.size < MAX_LIVE_HLS + (high ? 1 : 0)) {
holders.add(id);
return true;
}
return false;
}
function release(id: number) {
if (holders.delete(id)) leaseListeners.forEach((l) => l());
}
function useLiveLease(want: boolean, high: boolean): boolean {
const idRef = useRef(0);
if (!idRef.current) idRef.current = nextLeaseId++;
const [granted, setGranted] = useState(false);
useEffect(() => {
const id = idRef.current;
if (!want) { release(id); setGranted(false); return; }
const attempt = () => setGranted(tryAcquire(id, high));
attempt();
leaseListeners.add(attempt);
return () => { leaseListeners.delete(attempt); release(id); };
}, [want, high]);
return granted;
}
// ── Visibility: tab shown AND element on screen ──────────────────────────────
function useOnScreen(ref: RefObject<HTMLElement | null>): boolean {
const [tabVisible, setTabVisible] = useState(true);
const [inView, setInView] = useState(true);
useEffect(() => {
const onVis = () => setTabVisible(!document.hidden);
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { rootMargin: "100px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return tabVisible && inView;
}
// Origins whose stills refuse CORS: shown without crossOrigin, no signature.
const noCorsOrigins = new Set<string>();
function originOf(url: string): string {
try { return new URL(url).origin; } catch { return url; }
}
function withBust(url: string, t: number): string {
return `${url}${url.includes("?") ? "&" : "?"}t=${t}`;
}
function signature(img: HTMLImageElement): number[] | null {
try {
const c = document.createElement("canvas");
c.width = 64;
c.height = 36;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, 64, 36);
const d = ctx.getImageData(0, 0, 64, 36).data;
const out: number[] = [];
for (let i = 0; i < d.length; i += 4) out.push((d[i] + d[i + 1] + d[i + 2]) / 3);
return out;
} catch {
return null; // tainted canvas
}
}
function sameFrame(a: number[] | null, b: number[] | null): boolean {
if (!a || !b || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / a.length < 1.5;
}
function ageLabel(ms: number): string {
const m = Math.floor(ms / 60_000);
return m < 1 ? "<1m" : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`;
}
function clock(t: number): string {
return new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
interface Props {
camera: Camera;
className?: string;
/** "still" never starts video (popups, thumbnails). */
mode?: "auto" | "still";
/** The drill-down camera may take one stream past the page-wide cap. */
priority?: "normal" | "high";
/** Required attribution for this provider — rendered on the imagery itself. */
credit?: CameraCredit | null;
}
/**
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
* a timer. A stream that fails falls back to the snapshot rather than a black box,
* and video is retried every VIDEO_RETRY_MS while it stays down.
* One public camera: live HLS when the camera has a stream and a lease is
* free, otherwise its still on a timer — with honest state badges and the
* provider's attribution on the frame. A stream that fails or stalls falls
* back to the still and is retried every VIDEO_RETRY_MS.
*/
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
export function CameraFeed({ camera, className, mode = "auto", priority = "normal", credit }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const onScreen = useOnScreen(rootRef);
const [videoFailed, setVideoFailed] = useState(false);
const [tick, setTick] = useState(() => Date.now());
const useVideo = !!camera.video_url && !videoFailed;
const wantsVideo = mode === "auto" && !!camera.video_url && !videoFailed && onScreen;
const leased = useLiveLease(wantsVideo, priority === "high");
const useVideo = wantsVideo && leased;
const capped = wantsVideo && !leased;
// Still state
const [shownSrc, setShownSrc] = useState<string | null>(null);
const [corsOk, setCorsOk] = useState(true);
const [lastGoodAt, setLastGoodAt] = useState<number | null>(null);
const [frameSince, setFrameSince] = useState<number | null>(null); // when the shown frame first appeared
const [stale, setStale] = useState(false);
const [now, setNow] = useState(() => Date.now());
const sigRef = useRef<number[] | null>(null);
// Reset when the camera changes
useEffect(() => {
setShownSrc(null); setLastGoodAt(null); setFrameSince(null); setStale(false); setVideoFailed(false);
sigRef.current = null;
}, [camera.id]);
useEffect(() => {
if (useVideo) return;
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
const id = setInterval(() => { if (!document.hidden) setNow(Date.now()); }, 15_000);
return () => clearInterval(id);
}, [useVideo]);
}, []);
// Still polling — only while on screen and not playing video
useEffect(() => {
const url = camera.image_url;
if (useVideo || !onScreen || !url) return;
let cancelled = false;
let probe: HTMLImageElement | null = null;
const load = (allowCors: boolean) => {
const origin = originOf(url);
const cors = allowCors && !noCorsOrigins.has(origin);
const src = withBust(url, Date.now());
probe = new Image();
if (cors) probe.crossOrigin = "anonymous";
probe.onload = () => {
if (cancelled || !probe) return;
const t = Date.now();
const sig = cors ? signature(probe) : null;
const unchanged = sameFrame(sig, sigRef.current);
sigRef.current = sig;
setStale(false);
setLastGoodAt(t);
setCorsOk(cors);
if (!unchanged) {
setShownSrc(src);
setFrameSince(t);
}
};
probe.onerror = () => {
if (cancelled) return;
if (cors) { noCorsOrigins.add(origin); load(false); return; }
setStale(true); // keep the last good frame on screen
};
probe.src = src;
};
load(true);
const id = setInterval(() => load(true), SNAPSHOT_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
if (probe) { probe.onload = null; probe.onerror = null; probe.src = ""; } // abort in-flight
};
}, [camera.image_url, useVideo, onScreen]);
// Retry a failed stream later
useEffect(() => {
if (!videoFailed || !camera.video_url) return;
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
return () => clearTimeout(id);
}, [videoFailed, camera.video_url]);
// HLS player + stall watchdog
useEffect(() => {
const video = videoRef.current;
const src = camera.video_url;
@@ -65,20 +241,58 @@ export function CameraFeed({ camera, className }: { camera: Ny511Camera; classNa
})
.catch(() => setVideoFailed(true));
}
let lastT = -1;
let lastMove = Date.now();
const watchdog = setInterval(() => {
if (video.currentTime !== lastT) { lastT = video.currentTime; lastMove = Date.now(); return; }
if (Date.now() - lastMove > VIDEO_STALL_MS) setVideoFailed(true); // frozen stream → still
}, 5_000);
return () => {
cancelled = true;
clearInterval(watchdog);
hls?.destroy();
video.removeAttribute("src");
video.load(); // stop the download, not just the playback
};
}, [useVideo, camera.video_url]);
if (useVideo) {
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
}
if (!camera.image_url) {
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
// ── Badge ──
let badge: { text: string; tone: "live" | "ok" | "warn" | "err" };
if (useVideo) badge = { text: "LIVE", tone: "live" };
else if (!camera.image_url) badge = { text: capped ? "LIVE CAP · STREAM PAUSED" : camera.video_url ? "STREAM DOWN" : "NO IMAGE", tone: "err" };
else if (!shownSrc) badge = { text: stale ? "FRAME · UNAVAILABLE" : "FRAME · LOADING", tone: stale ? "err" : "warn" };
else if (stale && lastGoodAt) badge = { text: `STALE · ${ageLabel(now - lastGoodAt)}`, tone: "warn" };
else if (frameSince && now - frameSince > FROZEN_AFTER_MS) badge = { text: `FROZEN? · unchanged ${ageLabel(now - frameSince)}`, tone: "warn" };
else badge = { text: `STILL · ${frameSince ? clock(frameSince) : "—"}${capped ? " · LIVE CAP" : ""}`, tone: "ok" };
const toneClass = {
live: "bg-red-600/90 text-white",
ok: "bg-black/65 text-white",
warn: "bg-amber-500/90 text-black",
err: "bg-black/75 text-red-300",
}[badge.tone];
return (
<div ref={rootRef} className={`relative overflow-hidden ${className ?? ""}`}>
{useVideo ? (
<video ref={videoRef} muted autoPlay playsInline className="w-full h-full object-cover" onError={() => setVideoFailed(true)} />
) : shownSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={shownSrc} crossOrigin={corsOk ? "anonymous" : undefined} alt={`Camera: ${camera.name}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[10px] text-ink-muted bg-black/60">
{camera.image_url ? "…" : "No still for this camera"}
</div>
)}
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
{badge.text}
</span>
{credit?.attribution && (
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
{credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution}
</span>
)}
</div>
);
}