videoskilletdecisions Open the app ↗

0008 — Record H.264 High, pick the level from the frame, and know where the chroma went

Status: accepted, 2026-08-24.

Context#

ui/record.ts asked for avc1.42002a — H.264 Baseline, level 4.2 — as a constant, with a comment calling it "the profile every editor and phone decodes". Both halves of that string turned out to be wrong, and the second one had been quietly costing picture since the module was written.

The level is not a label, it is a budget, and Chrome enforces it. A level caps the coded picture area. Level 4.2 allows 8704 macroblocks — 2228224 samples. A 2560x1592 retina window codes as 2560x1600, which is 16000 macroblocks, and configure refuses it outright:

The provided resolution (2560x1592) has a coded area (2560*1600=4096000)
which exceeds the maximum coded area (2228224) supported by the AVC level
(4.2) indicated by the codec string (0x2A).

So recording did not degrade on a large display, it failed to start. Firefox on a smaller window never hit it, which is why it survived this long.

Baseline was an argument from 2010. It forbids CABAC and the 8x8 transform, and both are worth most on exactly this content — grain, dot crawl, a new noise field every frame. Measured with scripts/enccheck.mjs on Chrome 151.0.7922.174 / macOS 15.7.6, 16 frames of grain and one-pixel structure at 2560x1600, handed over as I420 and decoded back to score:

armaskedwrittenluma PSNR
baseline 5.060M175 Mbps24.52 dB
main 5.060M142.9 Mbps24.63 dB
high 5.060M143 Mbps24.63 dB

The same picture for 18% fewer bits. Nothing that has shipped this decade fails to decode High.

Three other things fell out of the same run, and they are the reason this record exists rather than a commit message.

bitrate is close to advisory. VideoToolbox overshot every target — 60M asked, 143 written; 400M asked, 751 written. The MIN_BITRATE/MAX_BITRATE clamp in record.ts is therefore not the control its comment implies, and raising the ceiling is not a reliable way to buy quality. bitrateMode: 'quantizer' is honoured (QP 24 → 35.85 dB, QP 20 → 39.71 dB, QP 16 → 43.85 dB) and is the only knob here that does what it says.

hardwareAcceleration: 'prefer-software' is a downgrade, not an upgrade. Chrome's software H.264 encoder is OpenH264, which is Baseline-only: 186.6 Mbps for 24.26 dB, worse than hardware on both axes. The instinct that software encoders are the higher-quality option is correct for x264 and wrong here.

The remaining ceiling is chroma, and it is structural. H.264 as offered here is 4:2:0 — High 4:2:2, High 4:4:4 and High 10 are all declined by isConfigSupported on this machine. Against a source carrying one-pixel alternating chroma, which is what dot crawl is (1280x800, 12 frames, 80M asked, decoded chroma upsampled to full resolution before scoring, so each arm is judged on what a viewer sees rather than on its own sample grid):

armwrittenluma PSNRchroma PSNR
H.264 High 4:2:0178.7 Mbps32.32 dB15.54 dB
VP9 p0 4:2:082.3 Mbps27.18 dB12.54 dB
VP9 p1 4:4:4146.7 Mbps23.47 dB23.13 dB
AV1 high 4:4:4114.8 Mbps28.29 dB43.38 dB

28 dB of chroma, for fewer bits. No bitrate recovers the 4:2:0 arms: the samples are not there to spend bits on. Chrome will encode VP9 4:4:4 and AV1 4:4:4 here today; what is missing is muxing, since ui/mp4.ts writes avc1/avcC sample entries and nothing else.

Decision#

The codec string is computed per recording, not declared. record.ts builds a candidate list — profiles best-first (6400, 4d00, 4200), and within each the levels whose maxFS covers the frame, smallest first — and takes the first one VideoEncoder.isConfigSupported admits. Profile is the outer loop because a lesser profile costs picture on every frame, where a level larger than the frame needs costs nothing at all.

The probe is not decoration: isConfigSupported discriminates on this machine, declining High 10 and both higher chroma formats, so a platform without High falls back to Main or Baseline rather than failing the take.

4:2:0 is accepted as the current ceiling. The AV1 4:4:4 route is not taken.

Consequences#

scripts/enccheck.mjs re-derives every number above against a new browser build. Its source is deliberately harder to compress than the app's picture, so the Mbps figures are an upper bound and the dB figures a lower one; what transfers between runs is the ordering between arms.