✳ OMNIVIEWER Camera capabilities Camera toolkit ← back

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.

On this page Three answers The units nobody remembers Before you press allow Four depths of bytes 93 MB every second The photo with no EXIF Try it

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:

FormMeaningIf it cannot be met
{ ideal: 1920 }a preferencesilently ignored — you get something else
{ exact: 1920 }a requirementthe whole getUserMedia call rejects with OverconstrainedError
{ min: 720, max: 1080 }a range requirementsame — the call rejects
1920shorthand for idealsilently ignored
Why the CAPABILITIES tab exists. Every browser has all three of these, and no browser UI puts them next to each other. OmniViewer’s /camera CAPABILITIES tab renders one row per setting with all three columns filled in, and flags the rows where the ask and the result disagree. That is the entire feature, and it took the whole rest of this page to explain why it is worth a tab.

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.

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.

Counted, not dramatised. The CAPABILITIES tab reports the number of distinguishing facts it can read, with the list behind it — not an entropy figure in bits. A real entropy estimate needs a population distribution nobody has, and a made-up one would be exactly the sort of scare-copy that makes people stop believing privacy claims. A count you can check yourself is a smaller claim and a better one.

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:

DepthAPIWhat you get
Raw pixel planesMediaStreamTrackProcessorVideoFrame.copyTo()NV12 / I420 / BGRA planes as they leave the capture pipeline, before any conversion
Encoded framesVideoEncoderEncodedVideoChunkper-frame type: 'key'|'delta', timestamp and byte length — the GOP structure itself
Container bytesMediaRecordera real MP4 or WebM file, muxed
Still bytesImageCapture.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.

Stopping means stopping the tracks. Clearing 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

See asked / supported / got → Every setting your camera reports, in three columns, with the disagreements flagged. Nothing is uploaded. Open the preview → Pick a camera, ask for a resolution, and watch what you actually get. Record a clip with no EXIF → Arrow through twenty-one live effects, then record one and open it in the MP4 toolkit without it ever touching a disk.

Related reading