Tracker-agnostic camera capture and processing-frame geometry for the WebARKit ecosystem.
@webarkit/camera-view extracts CameraViewRenderer and getProcessGeometry() out of ARnft (webarkit/ARnft#370) so that ARnft, aframe-nft, webarkit-studio, jsartoolkitNFT and future WebARKit trackers share one implementation of:
- opening the camera:
getUserMedia, selecting the device bycameraLabel, selecting the rear camera on multi-camera phones, and falling back to the default camera when a requested device cannot be opened; - fitting the video into the processing frame: the 320 px 4:3 letterbox, the opt-in
rotatePortrait90° clockwise rotation, and recomputation on video resize; - producing frames:
ImageData(getImage()) and transferableImageBitmap(getImageBitmap()) atframeWidth × frameHeight, throttled totargetFrameRate; - exposing
geometry: FrameGeometry: a frozen, structured-cloneable plain object that can be posted directly to a worker or passed totracker.projectionFor(viewport, view.geometry)(webarkit/jsartoolkitNFT#720).
npm install @webarkit/camera-viewOr with Yarn:
yarn add @webarkit/camera-viewimport { CameraView } from "@webarkit/camera-view";
const video = document.querySelector("video")!;
const view = new CameraView(video);
await view.initialize({
facingMode: "environment",
cameraLabel: "Logitech",
width: { min: 640, max: 1280 },
targetFrameRate: 30,
rotatePortrait: true,
});
// Plain frozen object — safe to structuredClone / postMessage to a worker
console.log(view.geometry);
// Read a throttled ImageData (frameWidth x frameHeight, typically 320 x 240)
const imageData = view.getImage();
// Or get a transferable ImageBitmap for zero-copy worker messaging
const bitmap = await view.getImageBitmap();
worker.postMessage({ type: "frame", bitmap, geometry: view.geometry }, [bitmap]);
view.addEventListener("geometrychange", (event) => {
console.log("New frame geometry:", event.detail);
});
view.addEventListener("ended", () => {
console.warn("Camera stream ended (unplugged or permission revoked)");
});
// When done: stops all media tracks immediately and detaches video.srcObject
view.destroy();The build also outputs ./dist/camera-view.umd.cjs, which exposes the CameraView global for non-module consumers (such as legacy example pages):
<script src="dist/camera-view.umd.cjs"></script>
<script>
const view = new CameraView.CameraView(document.querySelector("video"));
</script> frameWidth (pw, 320)
+--------------------------------+
| oy (y) |
| +------------------------+ |
| | | |
|ox | video footprint | | frameHeight (ph, 240)
|(x)| width x height | |
| | (w x h) | |
| +------------------------+ |
| |
+--------------------------------+
interface FrameGeometry {
readonly frameWidth: number; // pw (<= 320)
readonly frameHeight: number; // ph (<= 240)
readonly videoWidth: number; // vw (camera stream width)
readonly videoHeight: number; // vh (camera stream height)
readonly x: number; // ox (horizontal letterbox offset)
readonly y: number; // oy (vertical letterbox offset)
readonly width: number; // w (drawn footprint width)
readonly height: number; // h (drawn footprint height)
readonly rotated: boolean; // rot (true when portrait video is rotated 90 deg CW)
}When rotatePortrait: true is passed to initialize() and the camera stream is portrait (videoHeight > videoWidth), drawFrame() rotates the video 90° clockwise onto the landscape process canvas so the long axis fills the 320 px width instead of being pillarboxed to 135 px (webarkit/ARnft#344).
Creates a new instance bound to an HTMLVideoElement. Make sure the <video> element has autoplay, muted and playsinline set so mobile browsers allow playback.
video:HTMLVideoElementdeps(optional):Partial<CameraViewDeps>— injectablemediaDevices,now,createCanvas,isMobile,createImageBitmap,warnfor testing or custom environments.
Opens the camera stream, attaches it to video.srcObject, waits for a non-zero video resolution, and prepares the process canvas.
| Option | Type | Default | Description |
|---|---|---|---|
facingMode |
string |
"environment" |
Passed to getUserMedia video constraints. |
cameraLabel |
string |
undefined |
Case-insensitive substring matched against MediaDeviceInfo.label. |
width |
{ min: number, max: number } |
undefined |
Passed to getUserMedia video constraints. |
height |
{ min: number, max: number } |
undefined |
Validated for ARnft config compatibility; not sent to getUserMedia. |
targetFrameRate |
number |
60 |
Maximum number of new frames drawn per second. |
rotatePortrait |
boolean |
false |
Rotate portrait streams (vh > vw) 90° clockwise onto the process canvas. |
view.state:"idle" | "initializing" | "ready" | "ended" | "destroyed"view.geometry:FrameGeometry— throwsCameraViewError("NOT_INITIALIZED")beforeinitialize()resolves, or"DESTROYED"afterdestroy().view.getImage():ImageData— returns a freshImageDatacopy atframeWidth × frameHeight. Draws a new frame from the video at mosttargetFrameRatetimes per second.view.getImageBitmap():Promise<ImageBitmap>— returns a transferableImageBitmapof the process canvas, sharing the same throttle and frame counter asgetImage().view.destroy():void— idempotent; stops all tracks immediately, removes listeners, and clearsvideo.srcObject.- ARnft-compatible accessors:
view.image,view.frame,view.getFrame(),view.width,view.height,view.facing,view.rotated,view.video,view.canvasProcess.
getProcessGeometry(vw, vh, rotatePortrait = false, floor = false): ProcessGeometry— bit-for-bit identical to ARnft'sgetProcessGeometry().toFrameGeometry(vw, vh, processGeometry): FrameGeometry— converts aProcessGeometryinto a frozenFrameGeometry.
"geometrychange"(CustomEvent<FrameGeometry>): dispatched on theCameraViewinstance when the video stream changes resolution (e.g. on device orientation change)."ended"(Event): dispatched when the active video track ends externally (camera unplugged or permission revoked).view.statebecomes"ended", andview.initialize()can be called again to reopen the camera.
Package errors are instances of CameraViewError with a code property:
"NOT_SUPPORTED"—navigator.mediaDevices.getUserMedia(orcreateImageBitmap) is unavailable."NOT_INITIALIZED"—geometry,getImage(),getImageBitmap()orcanvasProcessaccessed beforeready."ALREADY_INITIALIZED"—initialize()called while alreadyinitializingorready."STREAM_ENDED"—getImage()orgetImageBitmap()called while inended."DESTROYED"— any method called afterdestroy(), ordestroy()called whileinitialize()was pending."INVALID_OPTIONS"— invalidCameraViewOptions(e.g.targetFrameRate <= 0orwidth.min > width.max).
Browser getUserMedia errors (NotAllowedError, NotFoundError, NotReadableError, OverconstrainedError) are re-thrown unchanged so existing error handling by error.name keeps working.
The geometry math (getProcessGeometry) and the canvas drawing (drawFrame) are preserved bit for bit (verified against a frozen copy of ARnft's function in test/unit/geometry.oracle.test.ts). The following lifecycle and safety bugs from ARnft's CameraViewRenderer are intentionally fixed:
destroy()stops the camera immediately and does not touch the DOM. ARnft'sdestroy()only registered a listener for a globalstopVideoStreamingevent and removed#appfrom the document, leaving the camera light on if that event was never dispatched.- Events are dispatched on the
CameraViewinstance, notwindow.view.addEventListener("geometrychange", ...)replaces the globalwindow"videoResize"event so multiple instances do not collide. - Calling
getImage()beforeinitialize()throwsCameraViewError("NOT_INITIALIZED")instead of crashing withTypeError: Cannot read properties of undefined (reading 'slice'). - Frame-size changes on resize (e.g.
320×240→318×239on iPhone1170×2532portrait streams) reallocate the pixel cache instead of throwingRangeError: offset is out of boundsinUint8ClampedArray.prototype.set(). loadedmetadatausesaddEventListenerand waits for a non-zero resolution instead of overwritingvideo.onloadedmetadataand computingNaNgeometry when an Android WebView firesloadedmetadatawith0×0.- Calling
destroy()whileinitialize()is awaiting permission or metadata stops the late stream and rejects withCameraViewError("DESTROYED")instead of leaking an active camera stream. - Throttling uses
performance.now()(injectable) withlastCapture = -Infinityso the first frame afterinitialize()or after a geometry change is always drawn. FrameGeometryexposes the floored values that are actually drawn on the process canvas. ARnft'sNFTWorkerhistorically used unfloored values viagetWindowSize(), which can differ by 0.5–1 px on some portrait resolutions (documented intest/unit/geometry.oracle.test.ts).
npm install
npm run verify # format:check + check:headers + typecheck + unit tests w/ coverage + build
npm run test:browser # real-pixel tests in headless Chromium via Vitest Browser Mode + Playwright
npm run dev # interactive demo at http://localhost:5173See CONTRIBUTING.md for the branch and pull request workflow (dev integration branch, Conventional Commits) and docs/testing.md for the testing strategy.
LGPL-3.0-or-later with the WebARKit linking exception.