DEV Community

hao jia
hao jia

Posted on

My WebCodecs encoder ladder for H.265: hardware hint first, a timed trial, H.264 as the floor

Our internal ticket page lets people attach screen recordings, and since a data audit last year those recordings get re-encoded in the browser before they leave the laptop. Someone asked for H.265 to cut the file sizes. My first draft of the encoder pick was one call: ask VideoEncoder.isConfigSupported() for HEVC, use it if supported is true, otherwise fall back to H.264. Before shipping it I ran the probe in six browser builds on an Apple M4 with macOS 26.5, and one boolean turned out to be too little to pick an encoder with. What I cared about most in the end was that the pick always lands on something that works, both for the person exporting and for whoever opens the file later.

A chain of hardware asks can end with nothing

For every codec I asked three times, once per hardwareAcceleration value, at 1920×1080, 30 fps and 4 Mbps. These are the two hints that ended up mattering:

Build HEVC Main hw / no-pref H.264 High hw / no-pref VP9 and AV1 hw / no-pref
Chromium 149, Chrome 155 true / true true / true false / true
WebKit 26.5 (macOS) true / true true / true true / true
Firefox 151 false / false true / true false / true
Chromium 149, HW encode disabled false / false false / true false / true
Stripped-down Chromium 149 false / false false / true false / true

Two things in that table changed my code. The first was speed. In Chromium and Chrome, prefer-software also said true for HEVC, and 30 frames then took about 6 seconds instead of under 200 ms, so for HEVC I only trust the hardware hint. The second surprise went the other way. With hardware video encoding turned off, and in the stripped-down build, prefer-hardware came back false even for H.264, and VP9 and AV1 were false under that hint on every build except WebKit. A chain made only of hardware asks ends with nothing on two of these six builds. WebKit was its own case: all three hints gave byte-identical output in identical time, so its true under prefer-hardware is not evidence of hardware at all.

I ask in a fixed order and time a short trial

HEVC is only taken from the hardware hint, and the ladder ends on a no-preference ask so it always lands somewhere:

const ladder = [
  ['hvc1.1.6.L120.B0', 'prefer-hardware'],
  ['avc1.640028', 'prefer-hardware'],
  ['avc1.640028', 'no-preference'],
  ['vp09.00.40.08', 'no-preference'],
];
async function chooseEncoder(base) {
  for (const [codec, hardwareAcceleration] of ladder) {
    const cfg = { ...base, codec, hardwareAcceleration };
    if ((await VideoEncoder.isConfigSupported(cfg)).supported && await trialOk(cfg)) return cfg;
  }
  return null; // keep the original upload untouched
}
Enter fullscreen mode Exit fullscreen mode

trialOk encodes 15 near-blank frames and checks the clock. It also catches the case where configure passes and the encoder errors out later, because flush() rejects when that happens:

async function trialOk(cfg, budgetMs = 1500) {
  let broken = false;
  const enc = new VideoEncoder({ output() {}, error() { broken = true; } });
  enc.configure(cfg);
  const pad = new OffscreenCanvas(cfg.width, cfg.height);
  pad.getContext('2d').fillRect(0, 0, 64, 64);
  const t0 = performance.now();
  for (let k = 0; k < 15; k++) {
    const vf = new VideoFrame(pad, { timestamp: k * 33333 }); enc.encode(vf); vf.close();
  }
  await enc.flush().catch(() => { broken = true; });
  enc.close();
  return !broken && performance.now() - t0 < budgetMs;
}
Enter fullscreen mode Exit fullscreen mode

I picked 1.5 seconds for 15 frames because it sits far from both measured paths. I haven't tuned it on a slower laptop yet. Applied to the probe results above and assuming each trial passes, the ladder returns HEVC on Chromium, Chrome and WebKit, H.264 at the second step on Firefox, and H.264 at the third step on the two reduced Chromium builds. That result is the ladder walked over the recorded answers. I haven't run this exact function in all six builds.

A WASM encoder only makes sense for H.265 sources

The open question was whether the ladder should end in a WebAssembly HEVC encoder instead of H.264. To see what a shipped tool does, I used ImgIng (https://imging.ai/), a video compressor that runs in the tab, on Chromium 149 with its hardware video encoder disabled, same M4, one 60-second 1080p synthetic screen recording. When the source was already H.265, it loaded x265 in WebAssembly: about 24 MB downloaded once and then served from cache, and 55.5 seconds against 8.5 on hardware for a result of 5.73 MB against 6.01 MB. Those MB are the interface's, 1024×1024 bytes each. When the source was H.264, the capability panel said this device couldn't do H.265 output, and H.264 or WebM were the ways forward.

That split made sense to me once I wrote it down. Re-encoding an H.265 file to H.265 keeps the codec the uploader chose, and that is worth a one-time 24 MB download. For an H.264 recording, the same tool's H.265 output was 19 to 30% smaller than its H.264 output on my two test clips at matching presets, and I'm not making someone wait six times longer on a corporate network for that. So my ladder still ends at H.264.

The pick still has to play on the viewer side

The stripped-down Chromium played my H.265 files without any error. The timeline moved, videoWidth stayed 0, and no frames were decoded. canPlayType gave back an empty string for HEVC there, so the check would have caught it. On WebKit the two detectors disagreed: canPlayType answered "probably" for hvc1 and nothing for hev1, while MediaSource.isTypeSupported said true to both. I now tag output as hvc1 and keep the H.264 copy whenever the viewer's browser is unknown.

Every number here is from one M4 on macOS 26.5. Windows, Linux and Android weren't tested, and "no hardware" meant turning off hardware video encoding in the browser, not a real machine without an HEVC encoder. I still don't know why Chromium's software HEVC path disappears along with the hardware one when hardware encoding is off. If you are adding a codec menu, run the ladder once on page load and cache the result for the session before you show H.265.

Top comments (0)