Someone handed my compressor a 1080p clip. It gave back a 480×270 postage stamp.
Nothing crashed. Every line did what I wrote it to do. I had written code that looked at that video, decided it was too damaged to deserve its own resolution, and rescued it by throwing away three quarters of its pixels.
I build this thing on my own, so there's nobody to hide behind here. Every bug below is mine, and I shipped six of them in about two weeks.
Quick context, then I'll get to the interesting part. The tool compresses video entirely client-side — nothing uploads. I run two engines: WebCodecs through Mediabunny as the fast path, using the browser's own hardware encoders where they exist, and single-threaded ffmpeg.wasm (~32 MB, fetched on first use, then parked in the Cache API) for containers WebCodecs can't demux — AVI, WMV, FLV, MPEG program streams, older 3GP. I picked the single-threaded core deliberately: the multi-threaded one needs SharedArrayBuffer, which needs COOP/COEP headers, which would break static hosting for the sake of files that are rare and usually small.
None of that matters much for what follows. I barely had a single bug in the encoders. All of them lived in the ~700 lines that decide what to ask an encoder for.
Bitrate is the wrong number to look at
A compressor has to answer one question: given this source and this goal, what resolution, frame rate and bitrate do I encode at? And the quantity that decides how a video looks isn't bitrate. It's bits per pixel per frame.
bpp = videoBitrate / (width * height * frameRate)
2 Mbps is generous at 480p and unwatchable at 4K. Rough H.264 landmarks:
| bpp | looks like |
|---|---|
| 0.15+ | visually transparent |
| 0.10 | good (YouTube-ish) |
| 0.075 | acceptable |
| 0.05 | soft, blocky on motion |
| 0.03− | falling apart |
So when a bitrate gets forced on me — "fit this in 25 MB" — I don't keep the source resolution and let the encoder smear it into mush. I ladder down: take the largest resolution whose bpp still clears a floor. I set that floor at 0.075, straight off the table above.
That floor is the bug.
The floor ate the picture
Here's the clip that broke it. 1080p, 30 fps, 700 kbps. The level's retention cut takes the budget to ~455 kbps first, then my ladder runs:
| short edge | dimensions | bpp at 455 kbps | clears 0.075? |
|---|---|---|---|
| 1080 | 1920×1080 | 0.0073 | no |
| 720 | 1280×720 | 0.0165 | no |
| 540 | 960×540 | 0.029 | no |
| 480 | 852×480 | 0.037 | no |
| 360 | 640×360 | 0.066 | no |
| 270 | 480×270 | 0.117 | yes |
Every step is correct. The conclusion is insane.
My table lies in two ways it doesn't warn you about.
It describes a first-generation encode. It tells you what a fresh capture out of a camera needs. But most files people compress have already been through a pass — a messaging app, a download, an export. That pass already stripped the noise and fine detail, which are the parts that cost bits. A second-generation clip holds up fine at a bpp that would look dreadful straight off a sensor. I read a re-shared clip's low bpp as damage, and that's the whole bug.
I double-charged. I cut the source bitrate to a fraction first, and then ran the resolution ladder against the already-cut number. My cut caused my downscale, and both landed on the same clip.
Target-size mode produced the stupidest version of this. A 77.5 MB source carrying 0.022 bpp, asked to fit in 25 MB, went from 1858×1660 down to 806×720 — a downscale whose entire purpose was to reach 0.032 bpp. Half again the density the source itself had. I threw away three quarters of someone's pixels in order to beat the quality of their original.
Here's the one thing in this post I'd actually put on a wall:
Any absolute quality floor that can sit above the source's own value will destroy the source in order to meet it.
So I made the floor purely relative. Now it can never get up there:
const bppFloor = srcBpp === null
? TARGET_BPP_FLOOR
: Math.min(TARGET_BPP_FLOOR, srcBpp * 0.2);
A fifth of whatever the source itself carried. I landed on 0.2 because bpp is really standing in for how hard the content is to encode, and a source's own bpp is the best measure of that I have: footage that already survives at a low bpp is cheap footage, and it stays cheap at a fifth of that.
That downscale was also worse than it looked, for a reason I didn't see coming. In target-size mode it defeats itself — a smaller frame is cheaper to encode, so the encoder stops spending the budget I gave it and lands far under target. The user pays in resolution and gets a file half the size they asked for. One 91 MB file asked to fit in 60 MB — a budget covering two thirds of its own bitrate — came back at 720p for exactly this reason.
I was dividing a number I never measured
Underneath all of that sat something worse. The number I kept dividing wasn't a video bitrate at all.
When my packet-index read failed or looked unrepresentative, I fell back to this:
videoBitrate = (file.size * 8) / durationSec;
That's the wrong quantity, not a rougher version of the right one. It folds in the audio track and every byte of container overhead, so it reports a video bitrate the video track never had. Then my planner divides it by the pixel count and decides, from a number nobody measured, whether the source keeps its resolution.
I had a real reason for that fallback. I used to sample only the first 120 packets (~4 s) to get the frame rate, and a prefix that short genuinely misleads whenever the opening seconds are simpler than the rest — so I threw away the bitrate that came with it.
Then I stopped being scared of the full pass:
const stats = await videoTrack.computePacketStats(); // metadataOnly
if (stats.averagePacketRate > 0) frameRate = stats.averagePacketRate;
if (stats.averageBitrate > 0) videoBitrate = stats.averageBitrate;
Scanning every packet costs almost nothing, despite how it sounds. With metadataOnly it reads each sample's size and timestamp straight out of the container index — stbl/stsz for MP4, Cues for MKV/WebM — and never touches a frame of pixel data, let alone decodes one. The file is already a local Blob, so nothing goes over the network. You get both numbers exactly: averagePacketRate is the real frame rate, and averageBitrate is the video track's own bitrate, measured from actual packet sizes.
I punished files for being efficient
I always write H.264. But sources show up as HEVC, VP9 and AV1 — every iPhone since iOS 11 records HEVC by default, and the messaging apps most videos arrive through re-encode to HEVC or VP9 too.
700 kbps of HEVC carries roughly what 1 Mbps of H.264 carries. Compare a source bitrate against an H.264 table without converting it first and you'll conclude that a lean phone clip is a bad one, then compress it accordingly. I was penalising files for being efficient.
const CODEC_EFFICIENCY = {
avc: 1, vp8: 0.95, vp9: 1.4, hevc: 1.45, av1: 1.6, prores: 0.25
};
ProRes sits below 1 because it's intra-only: it spends enormous bitrates on a picture H.264 holds for a fraction. An unknown codec assumes 1.0 — if I assumed anything more efficient I'd inflate my estimate of the source's quality and over-compress it.
But that conversion can ask for a bigger file than the source. Holding an HEVC or AV1 picture in H.264 really does cost 45–60% more bits, so the conversion times a high retention can land above what the source actually spent. A button labelled "compress" handing back a larger file is indefensible no matter how good my image-quality argument is, so I cap it hard at 0.95 × source. When those two goals collide, compressing wins.
Audio had the identical bug, and this is the one I'm least proud of: a 684 kbps clip with a 48 kbps audio track came back 10% larger on my lightest setting, because I re-encoded the audio at 128 kbps. I re-encode audio rather than copy it, so asking for more bits than the original carried invents nothing — it just makes the track bigger than the one it replaces. On a thin video track, that alone flips the whole file. Now I treat the source's audio bitrate as a ceiling, not a target.
Browsers lie to you as well
Three of these, quickly.
Codec support answers are optimistic. I asked Chrome whether AAC could handle 16–24 kbps mono. It said yes — an isConfigSupported-style answer, not a real encode attempt. Then it threw OperationError: Encoding error on the very first sample. I reproduced it at 16000, 24000 and 25969 bps alike, on a freshly created worker's first encode, so it isn't a warm-up artifact either. I stopped asking and hardcoded the split: Opus below 64 kbps, AAC at or above. Opus is the better codec down there anyway.
Firefox ships an AVC encoder and no AAC encoder. The tempting read is that Firefox needs my ffmpeg path. It doesn't — MP4 carries Opus perfectly well, and pulling 32 MB of wasm over someone's connection because of an audio track would be ridiculous. So my engine decision checks exactly one thing, on purpose: canEncodeVideo('avc').
Hardware encoder sessions are a shared, limited resource, and closing one doesn't always free it right away. Compress twice back to back in the same tab and the second can fail with that same generic OperationError — and so can every attempt after it, until you reload the page. I now treat that specific DOMException as recoverable and drop to ffmpeg.wasm for the rest of the run, because software encoding never touches the session I couldn't get.
A constant bitrate request is a ceiling, not a promise
In target-size mode I check the output and re-encode when I miss. Correcting an overshoot is mandatory — "at most 25 MB" is a promise I made.
Undershoot is the interesting direction. It's almost never because I asked for too few bytes; I asked for the target. It's because the encoder declined to spend them. Most WebCodecs implementations treat a constant-bitrate request as a ceiling, and easy content simply costs less than the budget allows.
Which means the right answer to an undershoot is to ask for more than the target. I used to clamp the corrected ask to desiredBytes * 0.97, so correcting a 28 MB result against a 60 MB target handed back an ask of 58.2 MB — smaller than the ask that had just undershot. My next pass faithfully reproduced 28 MB and gave up.
I raise it once, though, not repeatedly. An encoder undershooting because the content is cheap will undershoot at any budget, and every extra pass is a full re-encode someone sits and waits through.
The line that looked like a no-op
Passing frameRate into the conversion when it already equals the source's rate is not a no-op. Mediabunny quantises every sample onto a rigid 1/frameRate grid and drops or duplicates whichever sample lands in a bucket that's already full. Real footage is rarely perfectly constant-frame-rate — auto-exposure and capture jitter alone push samples off any fixed grid, and a VFR source has no single rate to quantise to at all. I forced that grid when I didn't need to, and it produced real judder. Now I pass it only when I deliberately changed the rate, and the same goes for ffmpeg's -r.
One more, free: round output dimensions down to even, never to nearest. H.264 wants even dimensions for 4:2:0 chroma, and real captures and crops do produce odd heights. Rounding 1081 up to 1082 means encoding a frame larger than the source — interpolating pixels that were never there, for a softer picture at more bits.
What they all had in common
Every one of these came from taking a number at face value:
- a bitrate I computed from file size and called a video bitrate
- a quality table describing first-generation encodes, which I applied to footage that had been through Messenger
- a codec-support API answering about a configuration it had never tried
- a bitrate request the encoder treated as optional
The encoders were fine. WebCodecs and ffmpeg.wasm both did exactly what I asked. Every bug was in my asking.
If you're building something in this shape, here's the rule that would have saved me most of it: judge the source against itself, not against a table. An absolute standard, applied to a file that never met it, will always conclude the file has to be destroyed for its own good.
The compressor all this came out of is at squishyfile.com — runs in your browser, nothing uploaded. Ask me anything in the comments, I'll go as deep as you want.
Top comments (0)