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 8–14 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
|
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
|
2026-05-03 10:32:46 +07:00
|
|
|
|
// 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 };
|
|
|
|
|
|
}
|