What your browser knows about your camera
Ask a browser about your camera and it will give you three different answers — what you asked for, what the hardware says it can do, and what you actually got — and they routinely disagree. Nothing shows you all three at once, so here is where each one comes from, what a page can read before you press allow, and the four separate depths at which a browser will hand you the bytes coming off a sensor.
Three answers to one question
A camera in a browser is a MediaStreamTrack, and a track carries three separate dictionaries. They look interchangeable. They are not.
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1920 }, height: { ideal: 1080 } },
});
const track = stream.getVideoTracks()[0];
track.getConstraints() // what you ASKED for → { width: {ideal: 1920}, … }
track.getCapabilities() // what it CAN do → { width: {min: 1, max: 1920}, … }
track.getSettings() // what you GOT → { width: 1280, height: 720, … }
Read that last line again. The camera reports that it supports 1920 wide. You asked for 1920 wide. You got 1280. This is not a bug and not rare — it is what happens when something else already has the camera open, when the driver prefers a different mode, or when the browser decides your ideal was only a suggestion. Which it was:
| Form | Meaning | If it cannot be met |
|---|---|---|
{ ideal: 1920 } | a preference | silently ignored — you get something else |
{ exact: 1920 } | a requirement | the whole getUserMedia call rejects with OverconstrainedError |
{ min: 720, max: 1080 } | a range requirement | same — the call rejects |
1920 | shorthand for ideal | silently ignored |
The merge itself is unglamorous — a row for every key any of the three mentions, so a capability the hardware advertises but never applies is as visible as one you asked for and did not get:
const keys = new Set([
...Object.keys(settings), ...Object.keys(capabilities), ...Object.keys(constraints),
]);
for (const key of keys) {
rows.push({
key,
asked: formatConstraint(constraints[key]), // "ideally 1920"
supported: formatCapability(capabilities[key]), // "1 – 1920 (step 1)"
actual: formatSetting(key, settings[key]), // "1280"
});
}
The one subtlety is the comparison. asked and actual are different types — a dictionary against a number — so the disagreement is computed on the rendered text after stripping the ideally/exactly prefix. Comparing the raw values would flag every successful negotiation as a mismatch.
The two units nobody remembers
Most of getSettings() is self-describing. Two fields are not, and both are easy to misreport by three orders of magnitude.
exposureTimeis in hundreds of microseconds. A value of100means 10 ms, which a photographer would call 1/100 s. Printing the raw number next to a shutter fraction is the difference between a field nobody reads and a field that tells you the room is too dark.colorTemperatureis kelvin, andfocusDistanceis metres. Those two are guessable; the point is thatpanandtiltare not, so we print them raw rather than inventing a unit for them.
function formatSetting(key, value) {
if (key === 'exposureTime') {
const seconds = (value * 100) / 1e6; // 100 µs units → seconds
return `${value} (1/${Math.round(1 / seconds)} s)`;
}
if (key === 'frameRate') return trimNum(value, 3); // 29.999998 is a real reading
return String(value);
}
That frameRate line is not a joke. Cameras report fractional frame rates constantly — 29.97 for anything descended from NTSC, and long floating-point tails from drivers that compute the rate rather than declare it. Rounding it to 30 in the UI would hide the most interesting thing on the row.
What a page can learn before you press allow
Load a page with a camera on it and, before any prompt appears, it can already call this:
const devices = await navigator.mediaDevices.enumerateDevices();
// [ { kind: 'videoinput', deviceId: '…', groupId: '…', label: '' },
// { kind: 'videoinput', deviceId: '…', groupId: '…', label: '' } ]
Two cameras. No names. Device labels stay empty until permission is granted — a page you have never allowed can count your cameras but cannot tell you, or itself, what they are. The names appear the instant you press allow, which is a much easier thing to believe once you have watched it happen. It is worth being precise about what this does and does not buy you: the count, the groupId pairing that links a camera to the microphone in the same physical device, and (once granted) the full capability ranges are all real, stable, distinguishing facts.
The other half of the browser’s answer is navigator.mediaDevices.getSupportedConstraints(), which lists the constraint names this browser understands. It is a property of the software, not the hardware, so it needs no permission at all — and the gap between what a browser understands and what a given webcam implements is usually enormous. Most laptop webcams expose resolution and frame rate and nothing else. A phone will happily hand you torch, zoom, focusDistance and exposureTime.
Four depths at which you can get the bytes
“Can I get at the actual bytes?” has four different answers, and they are genuinely different bytes:
| Depth | API | What you get |
|---|---|---|
| Raw pixel planes | MediaStreamTrackProcessor → VideoFrame.copyTo() | NV12 / I420 / BGRA planes as they leave the capture pipeline, before any conversion |
| Encoded frames | VideoEncoder → EncodedVideoChunk | per-frame type: 'key'|'delta', timestamp and byte length — the GOP structure itself |
| Container bytes | MediaRecorder | a real MP4 or WebM file, muxed |
| Still bytes | ImageCapture.takePhoto() | a JPEG straight from the capture pipeline |
The first is the one people mean and the one that surprises them: a webcam does not hand the browser RGB. It hands it YUV, usually NV12 — a full-resolution luma plane followed by a half-resolution interleaved chroma plane, which is the same 4:2:0 subsampling a JPEG uses and for the same reason. If you take the easy route and draw the video to a canvas instead, you get RGBA, but those are a different set of bytes: the browser already did the conversion for you and the original planes are gone.
// The frames, as the camera actually produces them (Chromium today).
const processor = new MediaStreamTrackProcessor({ track });
for await (const frame of processor.readable) {
frame.format; // 'NV12' — not RGBA
frame.colorSpace; // { primaries, transfer, matrix, fullRange }
const buf = new Uint8Array(frame.allocationSize());
await frame.copyTo(buf);
frame.close(); // ← the important line; see below
}
OmniViewer’s /camera route ships depths two, three and four today: the TRANSFORM tab hands you every frame as an RGBA array to rewrite, and records the result as a file. The NV12 planes the camera actually produces are on the backlog. What it does with them is the interesting part.
Why nothing is buffered: 93 MB every second
This site’s recurring problem is a 20 GB file that will not fit in memory. A camera is the same problem stood on its head. The file is small; it simply never ends.
1920 × 1080 × 1.5 bytes (NV12) × 30 fps ≈ 93 MB / second
≈ 5.6 GB / minute
That is more throughput than any file this site opens, sustained, forever. So the answer is the same one the 20 GB file gets: hold as little as possible.
- The preview is not JavaScript. It is the browser’s own
<video>element withsrcObjectpointed at the MediaStream. The frames go camera → compositor and never enter the JS heap. A canvas-based preview would cost a full frame copy per frame for no benefit whatsoever. - A still is one canvas read, released immediately. At 4K a single frame of RGBA is 33 MB — more than every other allocation on the page put together — so the canvas is created, drawn, encoded and dropped inside one function rather than kept around for a later capture.
- A clip is capped and streamed.
MediaRecorderis started with a timeslice so it emits a chunk per second instead of one buffer at the end, and the recording stops itself at 30 seconds. A page that lets you walk away mid-record and come back to a gigabyte in memory has a bug, not a feature. - Every
VideoFramegetsclose()d. WebCodecs frames are backed by memory the garbage collector does not manage; a missedclose()stalls the whole pipeline within a second or two. It is the one piece of manual memory management on the modern web platform.
srcObject blanks the picture but leaves the camera open and the hardware indicator light on. Only track.stop() on every track releases it. A page that gets this wrong looks, to the person using it, exactly like a page that is spying on them — so /camera stops the tracks when you press stop, when you open a file, when you press close, and when you leave the route.
The photo with no EXIF
Take a picture with your phone’s camera app and it arrives dense with identity: make, model, lens, firmware, exposure, orientation, a maker-note blob, and very often a GPS coordinate accurate to a few metres. Most of OmniViewer’s image toolkits exist to show you that and help you remove it.
Take a picture with a browser and you get essentially none of it:
$ exiftool camera-photo-20260830-120833.jpg
File Type : JPEG
Image Width : 1280
Image Height : 720
Encoding Process : Baseline DCT, Huffman coding
Y Cb Cr Sub Sampling : YCbCr4:2:0 (2 2)
Comment : omniviewer.org
# …and that is the entire list.
No make. No model. No serial. No coordinate — the capture path has no access to location, so there is nothing to strip. This is the inverse of the usual story on this site, and the fastest way to understand what your phone is actually adding is to put the two files side by side: record one on /camera TRANSFORM, open it in /mp4 BOXES, then drop a video from your camera roll into the same tab.
Two things do survive, and naming them is the difference between a claim and a slogan. A recorded clip carries its muxer’s name in the container — the MuxingApp / WritingApp elements in WebM, or the corresponding atoms in MP4 — so the file knows it was written by Chrome or by Safari. And /camera itself writes one line on the way out: omniviewer.org, in whichever field the container already keeps for the software that made the file — a COM comment segment in a JPEG, a tEXt chunk keyed Software in a PNG, the ©too encoder tag in an MP4, an ENCODER tag in a Matroska file. No EXIF block is introduced to hold it; the point of this section would not survive that. Both are facts about the software rather than about you, but they are facts, and a tab that claimed “no metadata at all” would be wrong in a way somebody would eventually catch.
That is also why the toolkit hands the file straight to another toolkit instead of triggering a download. The bytes never touch a disk: they go from the encoder into a File and into the same load path a dropped file takes, sniffed by magic number like anything else. An MP4 lands in /mp4 with its atom tree; a browser that records WebM instead lands in the universal raw, hex and strings views.
Try it on your own camera
Related reading
- Learn maths and programming with a photo booth — the other half of the TRANSFORM tab: the arithmetic inside six of the effects, and a lesson plan built on it.
- Inside a JPEG: markers, EXIF and quantization — what a capture from this page is missing, and where it would live.
- Inside an MP4: moov, mdat and fast start — the atom tree a recorded clip lands in.
- Two hidden identifiers in iPhone HEIC metadata — the other end of the spectrum: a photo that knows far too much.
- How a Live Photo stores your location twice — why “strip the GPS” is harder than it sounds.
- How we auto-detect a file’s format — the sniff a capture goes through on its way to a toolkit.