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:
co-authored by
Claude Opus 5.5
parent
d57738a8f6
commit
aa5b71d853
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user