Skip to content

parabun:camera

import camera from "parabun:camera";

A small module wrapping the kernel’s V4L2 capture API. Linux only today; the JS surface is platform-agnostic so AVFoundation + Media Foundation backends slot in without callsite changes.

Returns an array of capture-capable video devices. Reads /sys/class/video4linux/ and runs VIDIOC_QUERYCAP on each entry to filter to actual capture devices (skips encoders, M2M endpoints, etc.).

const devs = await camera.devices();
// [{ path: "/dev/video0", name: "OBSBOT Tail Air", driver: "uvcvideo" }, ...]

Enumerates the device’s supported (format, width, height, fps) tuples via VIDIOC_ENUM_FMT + VIDIOC_ENUM_FRAMESIZES + VIDIOC_ENUM_FRAMEINTERVALS.

const fmts = await camera.formats("/dev/video0");
// [{ format: "mjpg", width: 1920, height: 1080, fps: 30 }, ...]

Opens the device, mmaps the kernel ring buffer, queues capture buffers, and starts streaming. Returns a Camera instance.

await using cam = await camera.open({
device: "/dev/video0",
width: 1280,
height: 720,
fps: 30,
format: "mjpg", // or "yuyv" / "nv12" / "rgb24"
buffers: 4, // ring depth — 4 is usually enough
});

Camera is AsyncDisposableawait using triggers VIDIOC_STREAMOFF + munmap + close() on scope exit.

Async iterator of raw frames. Each RawFrame is { format, width, height, data: Uint8Array, timestampMs: number }. The data view points directly at the kernel-mapped buffer — copy if you need to retain the frame past the next iteration.

for await (const frame of cam.frames()) {
// process frame.data — but don't hold past the next loop iteration
}

To compose with parabun:image / parabun:vision, pass the iterator through vision.frames(...) to convert to packed-RGBA8.

Manual close. Equivalent to using scope exit. Idempotent.

Three para:signals Signals on the camera handle — wire them into a UI without polling.

SignalTypeWhen it changes
cam.activebooleanTrue after the first frame is emitted, false again on close().
cam.fpsnumberRolling-window estimate over the last ~32 frames. Throttled to ~10 Hz so a 60 fps source doesn’t fire effects 60×/sec.
cam.cameraFormat{ width, height, pixelFormat }Initialized from open(). Future renegotiation on V4L2 format-change events updates this in place; today it stays static for the lifetime of the camera.
import { effect } from "para:signals";
await using cam = await camera.open("/dev/video0", { format: "yuyv", width: 640, height: 480 });
effect(() => console.log(`camera ${cam.active.get() ? "live" : "idle"} @ ${cam.fps.get().toFixed(1)} fps`));
for await (const frame of cam.frames()) {
// frame processing — fps signal updates as you pull
}

Single-frame converter — useful when you have a RawFrame from somewhere else (file, network) and want RGBA8 without spinning up vision.frames. Same pixel format coverage as vision.frames (yuyv, nv12, rgb24, rgba; mjpg requires image.decode).

The end-to-end shape pairs with parabun:vision:

import camera from "parabun:camera";
import image from "parabun:image";
import vision from "parabun:vision";
await using cam = await camera.open({ device: "/dev/video0", width: 1280, height: 720, fps: 30 });
for await (const { frame, motion } of vision.detectMotion(
vision.frames(cam.frames(), { decodeMjpg: image.decode }),
)) {
if (motion > 0.05) console.log("motion!");
}
  • Linux only today.
  • No control over UVC parameters (focus, exposure, white balance) yet — those would surface as cam.set("focus", value) style calls. Open an issue if you need this.
  • mjpg decoding goes through image.decode (libjpeg-turbo) — fine for 30 fps at 1080p, but a streaming JPEG decoder (e.g. mjpeg-stream-style row-by-row) would be faster.
  • One reader per device; V4L2 doesn’t multiplex.