velxio/frontend/src/hooks/useWebcamFrames.ts

195 lines
6.6 KiB
TypeScript
Raw Normal View History

feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
/**
* 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
perf(spi): batch SPI bytes per WS message — ~50× faster TFT in emulator User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min. Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and each byte was emitting a full {type:'spi_event'} JSON message over the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs in Python (json.dumps + sys.stdout.write+flush dominates) plus asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with GIL contention. Fix: buffer MOSI bytes in the worker and emit a single base64-encoded `spi_batch` message when CS goes HIGH (transaction ended) or the buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages. backend/app/services/esp32_worker.py:_on_spi_event - Add _spi_byte_buf bytearray + threading.Lock - On op==0x00 (byte): append; flush early if buf >= 4096 - On op==0x01 (CS change): flush buffer, then emit the CS event via the legacy spi_event channel (ePaper / custom chips that observe CS still get it). frontend/src/simulation/Esp32Bridge.ts - New 'spi_batch' message handler decodes b64 and replays each byte through the existing onSpiByte callback. Parts that subscribed via simulator.spi.onByte don't notice the protocol change. The 'spi_event' branch still handles CS changes plus legacy single-byte payloads for backwards compat. Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG quality 0.35 in the gallery example. Real measured speedup: ~50× on the QVGA preview demo. Real hardware was never affected — it runs SPI at 80 MHz and pushes the bitmap in ~4 ms either way. PSRAM emulation is unrelated to this bottleneck and was left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:25:06 +07:00
// 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;
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
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 };
}