Spaces:
Sleeping
Sleeping
| /** | |
| * useWebSocket β live video and metrics, with automatic HTTP polling fallback. | |
| * | |
| * Strategy: | |
| * 1. Attempt a WebSocket connection. | |
| * 2. If it fails within WS_PROBE_MS (e.g. HF Spaces blocks wss://), switch | |
| * permanently to HTTP polling for this page load. | |
| * 3. Once in polling mode, video frames come from GET /api/pipeline/frame | |
| * and metrics from GET /api/pipeline/metrics. | |
| */ | |
| import { useEffect, useRef, useState, useCallback } from 'react' | |
| const _proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' | |
| const BASE_WS = import.meta.env.VITE_API_WS_URL | |
| || `${_proto}//${window.location.host}/api` | |
| const WS_PROBE_MS = 4_000 // give WS this long to open before falling back | |
| const VIDEO_POLL_MS = 200 // ~5 FPS polling | |
| const METRICS_POLL_MS = 1_000 | |
| const WS_RECONNECT_MAX = 30_000 | |
| // ββ WS reconnect helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function useExponentialBackoff(connectFn, enabled) { | |
| const delayRef = useRef(1_000) | |
| const timerRef = useRef(null) | |
| const socketRef = useRef(null) | |
| const enabledRef = useRef(enabled) | |
| useEffect(() => { enabledRef.current = enabled }, [enabled]) | |
| const connect = useCallback(() => { | |
| if (!enabledRef.current) return | |
| const ws = connectFn({ | |
| onClose: () => { | |
| if (!enabledRef.current) return | |
| timerRef.current = setTimeout(() => { | |
| delayRef.current = Math.min(delayRef.current * 2, WS_RECONNECT_MAX) | |
| connect() | |
| }, delayRef.current) | |
| }, | |
| onOpen: () => { delayRef.current = 1_000 }, | |
| }) | |
| socketRef.current = ws | |
| }, [connectFn]) | |
| useEffect(() => { | |
| if (enabled) connect() | |
| return () => { | |
| enabledRef.current = false | |
| clearTimeout(timerRef.current) | |
| socketRef.current?.close() | |
| } | |
| }, [enabled, connect]) | |
| } | |
| // ββ Video stream ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function useVideoStream(canvasRef, enabled = true) { | |
| const [connected, setConnected] = useState(false) | |
| // null = probing, true = ws, false = polling | |
| const [mode, setMode] = useState(null) | |
| const pollRef = useRef(null) | |
| const probeRef = useRef(null) | |
| // --- Polling path --- | |
| const startPolling = useCallback(() => { | |
| if (pollRef.current) return | |
| setMode(false) | |
| const tick = async () => { | |
| if (!enabled) return | |
| try { | |
| const res = await fetch('/api/pipeline/frame') | |
| if (res.status === 200) { | |
| const blob = await res.blob() | |
| const url = URL.createObjectURL(blob) | |
| const img = new Image() | |
| img.onload = () => { | |
| const canvas = canvasRef.current | |
| if (!canvas) return | |
| canvas.width = img.width | |
| canvas.height = img.height | |
| canvas.getContext('2d').drawImage(img, 0, 0) | |
| URL.revokeObjectURL(url) | |
| } | |
| img.src = url | |
| setConnected(true) | |
| } else { | |
| setConnected(false) | |
| } | |
| } catch { | |
| setConnected(false) | |
| } | |
| } | |
| pollRef.current = setInterval(tick, VIDEO_POLL_MS) | |
| }, [canvasRef, enabled]) | |
| // --- WS path --- | |
| const connectFn = useCallback(({ onClose, onOpen }) => { | |
| const ws = new WebSocket(`${BASE_WS}/ws/video`) | |
| ws.binaryType = 'blob' | |
| // Start a probe timer β if WS doesn't open in time, fall back | |
| probeRef.current = setTimeout(() => { | |
| if (ws.readyState !== WebSocket.OPEN) { | |
| ws.close() | |
| startPolling() | |
| } | |
| }, WS_PROBE_MS) | |
| ws.onopen = () => { | |
| clearTimeout(probeRef.current) | |
| setMode(true) | |
| setConnected(true) | |
| onOpen() | |
| } | |
| ws.onclose = (e) => { | |
| clearTimeout(probeRef.current) | |
| setConnected(false) | |
| // If we never opened successfully, switch to polling instead of retrying | |
| if (mode !== true) { | |
| startPolling() | |
| } else { | |
| onClose(e.wasClean) | |
| } | |
| } | |
| ws.onerror = () => ws.close() | |
| ws.onmessage = (e) => { | |
| const canvas = canvasRef.current | |
| if (!canvas || !(e.data instanceof Blob)) return | |
| const url = URL.createObjectURL(e.data) | |
| const img = new Image() | |
| img.onload = () => { | |
| const ctx = canvas.getContext('2d') | |
| canvas.width = img.width | |
| canvas.height = img.height | |
| ctx.drawImage(img, 0, 0) | |
| URL.revokeObjectURL(url) | |
| } | |
| img.src = url | |
| } | |
| return ws | |
| }, [canvasRef, mode, startPolling]) | |
| useExponentialBackoff(connectFn, enabled && mode !== false) | |
| // Start polling directly if WS mode already failed | |
| useEffect(() => { | |
| if (enabled && mode === false) startPolling() | |
| if (!enabled) { | |
| clearInterval(pollRef.current) | |
| pollRef.current = null | |
| setConnected(false) | |
| } | |
| return () => { | |
| if (!enabled) { | |
| clearInterval(pollRef.current) | |
| pollRef.current = null | |
| } | |
| } | |
| }, [enabled, mode, startPolling]) | |
| return connected | |
| } | |
| // ββ Metrics stream ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const METRICS_HISTORY = 60 | |
| const DEFAULT_METRICS = { | |
| latest: null, | |
| history: [], | |
| alerts: [], | |
| } | |
| export function useMetricsStream(enabled = true) { | |
| const [connected, setConnected] = useState(false) | |
| const [metrics, setMetrics] = useState(DEFAULT_METRICS) | |
| const [mode, setMode] = useState(null) // null/true/false | |
| const pollRef = useRef(null) | |
| const probeRef = useRef(null) | |
| const applyMsg = useCallback((msg) => { | |
| setMetrics(prev => { | |
| const history = [...prev.history, msg].slice(-METRICS_HISTORY) | |
| const newAlerts = msg.alerts?.length | |
| ? [...prev.alerts, ...msg.alerts].slice(-50) | |
| : prev.alerts | |
| return { latest: msg, history, alerts: newAlerts } | |
| }) | |
| }, []) | |
| // --- Polling path --- | |
| const startPolling = useCallback(() => { | |
| if (pollRef.current) return | |
| setMode(false) | |
| const tick = async () => { | |
| if (!enabled) return | |
| try { | |
| const res = await fetch('/api/pipeline/metrics') | |
| if (res.status === 200) { | |
| const msg = await res.json() | |
| applyMsg(msg) | |
| setConnected(true) | |
| } else { | |
| setConnected(false) | |
| } | |
| } catch { | |
| setConnected(false) | |
| } | |
| } | |
| pollRef.current = setInterval(tick, METRICS_POLL_MS) | |
| }, [enabled, applyMsg]) | |
| // --- WS path --- | |
| const connectFn = useCallback(({ onClose, onOpen }) => { | |
| const ws = new WebSocket(`${BASE_WS}/ws/metrics`) | |
| probeRef.current = setTimeout(() => { | |
| if (ws.readyState !== WebSocket.OPEN) { | |
| ws.close() | |
| startPolling() | |
| } | |
| }, WS_PROBE_MS) | |
| ws.onopen = () => { | |
| clearTimeout(probeRef.current) | |
| setMode(true) | |
| setConnected(true) | |
| onOpen() | |
| } | |
| ws.onclose = (e) => { | |
| clearTimeout(probeRef.current) | |
| setConnected(false) | |
| if (mode !== true) { | |
| startPolling() | |
| } else { | |
| onClose(e.wasClean) | |
| } | |
| } | |
| ws.onerror = () => ws.close() | |
| ws.onmessage = (e) => { | |
| try { applyMsg(JSON.parse(e.data)) } catch { /* ignore malformed */ } | |
| } | |
| return ws | |
| }, [mode, startPolling, applyMsg]) | |
| useExponentialBackoff(connectFn, enabled && mode !== false) | |
| useEffect(() => { | |
| if (enabled && mode === false) startPolling() | |
| if (!enabled) { | |
| clearInterval(pollRef.current) | |
| pollRef.current = null | |
| setConnected(false) | |
| } | |
| return () => { | |
| if (!enabled) { | |
| clearInterval(pollRef.current) | |
| pollRef.current = null | |
| } | |
| } | |
| }, [enabled, mode, startPolling]) | |
| return { connected, metrics } | |
| } | |