velxio/frontend/src/hooks/useWebcamFrames.ts

195 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Stream frames from the user's webcam to the simulator's ESP32-CAM
* peripheral over WebSocket.
*
* Lifecycle:
* - start(boardId): asks for camera permission, starts capture loop.
* - stop(): turns the webcam off, tells backend to detach.
* - status: 'idle' | 'requesting' | 'streaming' | 'denied' | 'error'.
*
* The frame transport goes through Esp32Bridge.sendCameraFrame(), which
* is also what the test suite uses. The ctypes binding in the worker
* pushes the bytes into the QEMU OV2640+I²S peripheral.
*
* Implementation notes:
* - QVGA (320×240) at 10 fps. Larger sizes work but bandwidth
* scales linearly and the firmware's DMA buffer is fixed-size.
* - JPEG quality 0.6 keeps each frame in the 814 KB range.
* - We use OffscreenCanvas when available (Chrome/Edge); fall back
* to a hidden DOM canvas for Safari < 17.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { getEsp32Bridge } from '../store/useSimulatorStore';
export type WebcamStatus =
| 'idle'
| 'requesting'
| 'streaming'
| 'denied'
| 'error';
export interface UseWebcamFramesResult {
status: WebcamStatus;
errorMessage: string | null;
/** Frames sent since the last start(). Useful for live counter UI. */
framesSent: number;
/** Last frame payload size (bytes). */
lastFrameBytes: number;
start: (boardId: string) => Promise<void>;
stop: () => void;
/** A `<video>` element ref the caller can render for a self-preview. */
videoRef: React.RefObject<HTMLVideoElement | null>;
}
const FRAME_WIDTH = 320;
const FRAME_HEIGHT = 240;
const FRAME_INTERVAL_MS = 100; // 10 fps
// JPEG must fit in the QEMU emulator's 8 KiB-per-frame deliverable
// budget (8 EOFs × 1024 bytes from the cam_hal default 16-descriptor
// ring). Anything bigger gets truncated and jpg2rgb565() rejects it
// with "Data format error" — observed intermittently at 0.35 because
// complex scenes encode larger than the average. 0.28 keeps the worst
// case well under 8 KiB while staying noticeably sharper than the
// 0.25 fallback we used before SPI batching landed.
const JPEG_QUALITY = 0.28;
export function useWebcamFrames(): UseWebcamFramesResult {
const [status, setStatus] = useState<WebcamStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [framesSent, setFramesSent] = useState(0);
const [lastFrameBytes, setLastFrameBytes] = useState(0);
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const boardIdRef = useRef<string | null>(null);
const canvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(null);
const stop = useCallback(() => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
if (boardIdRef.current) {
const bridge = getEsp32Bridge(boardIdRef.current);
bridge?.sendCameraDetach();
boardIdRef.current = null;
}
setStatus('idle');
setFramesSent(0);
}, []);
const start = useCallback(async (boardId: string) => {
setStatus('requesting');
setErrorMessage(null);
setFramesSent(0);
boardIdRef.current = boardId;
// 1. Request camera permission + media stream.
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: FRAME_WIDTH,
height: FRAME_HEIGHT,
frameRate: { ideal: 10, max: 15 },
},
audio: false,
});
} catch (err: unknown) {
const e = err as { name?: string; message?: string };
if (e.name === 'NotAllowedError') {
setStatus('denied');
setErrorMessage('Camera permission denied');
} else if (e.name === 'NotFoundError') {
setStatus('error');
setErrorMessage('No camera detected');
} else {
setStatus('error');
setErrorMessage(e.message ?? 'getUserMedia failed');
}
return;
}
streamRef.current = stream;
// 2. Wire stream into a hidden <video> for the canvas to draw from.
if (!videoRef.current) {
videoRef.current = document.createElement('video');
videoRef.current.muted = true;
videoRef.current.autoplay = true;
videoRef.current.playsInline = true;
}
videoRef.current.srcObject = stream;
try {
await videoRef.current.play();
} catch {
// Some browsers reject .play() until user gesture; harmless if it
// throws — the next animation tick will proceed anyway.
}
// 3. Prepare canvas for JPEG encode.
if (!canvasRef.current) {
if (typeof OffscreenCanvas !== 'undefined') {
canvasRef.current = new OffscreenCanvas(FRAME_WIDTH, FRAME_HEIGHT);
} else {
const c = document.createElement('canvas');
c.width = FRAME_WIDTH;
c.height = FRAME_HEIGHT;
canvasRef.current = c;
}
}
// 4. Tell the backend a frame source is on its way.
const bridge = getEsp32Bridge(boardId);
if (!bridge) {
setStatus('error');
setErrorMessage(`No ESP32 bridge for board ${boardId}`);
stop();
return;
}
bridge.sendCameraAttach();
// 5. Start the capture loop.
setStatus('streaming');
timerRef.current = window.setInterval(async () => {
const v = videoRef.current;
const c = canvasRef.current;
if (!v || !c || v.readyState < 2) return;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.drawImage(v, 0, 0, FRAME_WIDTH, FRAME_HEIGHT);
let blob: Blob | null;
if (c instanceof OffscreenCanvas) {
blob = await c.convertToBlob({ type: 'image/jpeg', quality: JPEG_QUALITY });
} else {
blob = await new Promise<Blob | null>((resolve) =>
(c as HTMLCanvasElement).toBlob(resolve, 'image/jpeg', JPEG_QUALITY),
);
}
if (!blob) return;
const buf = await blob.arrayBuffer();
const id = boardIdRef.current;
if (!id) return;
const b = getEsp32Bridge(id);
if (!b) return;
b.sendCameraFrame(buf, FRAME_WIDTH, FRAME_HEIGHT);
setFramesSent((n) => n + 1);
setLastFrameBytes(buf.byteLength);
}, FRAME_INTERVAL_MS);
}, [stop]);
// Stop on unmount.
useEffect(() => () => stop(), [stop]);
return { status, errorMessage, framesSent, lastFrameBytes, start, stop, videoRef };
}