DEV Community

Max/Wang
Max/Wang

Posted on

I built a browser tool that compresses video to 8MB. The bitrate math was the easy part.

"Compress this video to 8MB" sounds like a one-liner. I thought so too. Take the target size, divide by the duration, hand the encoder a bitrate, done.

I shipped that version. Then real people started feeding it real videos, and I spent the next few weeks learning that hitting a file size and producing something watchable are two very different problems.

This post walks through how my 8MB video compressor actually decides what to do with your clip. Everything runs client-side: no upload, no server, no queue. That constraint shaped almost every decision below.

The part everyone gets right: the budget

8MB is a hard number, so the budget is pure arithmetic.

// 8 MB in bits
const targetBits = 8 * 1024 * 1024 * 8; // 67,108,864

const MUX_OVERHEAD = 0.985; // MP4 container + index
const TARGET_SAFETY = 0.94; // encoders overshoot more than they undershoot

const totalBudget = (targetBits * MUX_OVERHEAD * TARGET_SAFETY) / durationSec;
Enter fullscreen mode Exit fullscreen mode

That leaves me roughly 62 megabits for the whole clip, video and audio together. A 30-second clip gets about 2 Mbps. A 2-minute clip gets about 0.5 Mbps.

I aim about 6% under on purpose. One wasted re-encode costs the user far more time than a few percent of unused budget, and "at most 8MB" is a promise. A file that lands at 8.1MB is a file that fails to upload.

If you stop here, you get a tool that hits the size every time. You also get a tool that turns a 90-second 1080p clip into blocky soup, because 0.7 Mbps spread over two million pixels per frame is nowhere near enough.

The part that actually matters: bits per pixel

Bitrate on its own tells you nothing about how a video will look. 2 Mbps is generous at 480p and unwatchable at 4K. The number I care about is bits per pixel per frame:

const bpp = videoBitrate / (width * height * frameRate);
Enter fullscreen mode Exit fullscreen mode

Here's the same 1 Mbps budget at two resolutions:

  • 1920×1080 at 30fps → 0.016 bpp. Falling apart.
  • 960×540 at 30fps → 0.064 bpp. Soft, but clean.

So when a bitrate is forced on me, I don't keep the source resolution and let the encoder smear it. I walk down a ladder and pick the largest size the budget can actually sustain:

const SHORT_EDGE_LADDER = [2160, 1440, 1080, 720, 540, 480, 360, 270, 180, 144];
Enter fullscreen mode Exit fullscreen mode

I ladder on the short edge, not the height. Every phone video is portrait, and a 1080×1920 iPhone clip is "1080p", not "1920p". Treating it as landscape would have made every vertical video pick the wrong rung.

I also trade frame rate before I trade too much resolution. Once the budget drops a clip below 720p, I halve 60fps to 30fps and re-run the ladder. For gameplay and screen recordings, 30 sharp frames beat 60 mushy ones.

In practice this works out to roughly 20 seconds at 1080p, 30 seconds at 720p and a minute at 540p for a typical iPhone clip. Past 90 seconds it still fits in 8MB, but I show a warning before download, because it will look rough and I'd rather say so than let the user find out after they post it.

Bug #1: I turned a 1080p video into a postage stamp

My first quality floor was a fixed H.264 reference table:

bpp what it looks like
0.15+ visually transparent
0.10 good
0.075 acceptable, my floor
0.05 soft, blocky on motion
0.03 and below falling apart

Then a user sent me a 1080p clip at about 700 kbps and got back 480×270.

The table wasn't wrong. I was using it on the wrong videos. That table describes a first-generation encode, straight out of a camera. Most files people compress have already been through something: a messaging app, a download, an export. That earlier pass already stripped out the noise and fine detail, which are exactly the parts that cost bits. A re-shared clip holds up fine at a bpp that would look dreadful from a camera.

My planner read "low bpp" as "damaged" and "rescued" the video by throwing away three quarters of its pixels.

The fix: I judge the source against itself. The floor becomes whichever is lower, the absolute table value or a fraction of the bpp the source already carries:

const TARGET_BPP_FLOOR = 0.075;
const TARGET_SOURCE_FLOOR_RATIO = 0.2;

const bppFloor = Math.min(TARGET_BPP_FLOOR, srcBpp * TARGET_SOURCE_FLOOR_RATIO);
Enter fullscreen mode Exit fullscreen mode

I tried keeping a small absolute floor underneath that, too. It produced my favorite absurd result: a source at 0.022 bpp got downscaled from 1858×1660 to 806×720 just to reach 0.032 bpp. I threw away most of the pixels to exceed the quality of the original. Any floor that can sit above the source's own bpp does this, so now the floor is purely relative.

Bug #2: HEVC made good videos look bad

I always encode H.264, because it plays everywhere: Discord, iPhone, Android, every browser. But sources arrive as HEVC, VP9 and AV1, and those codecs hold the same picture in noticeably fewer bits.

Every iPhone records HEVC by default. So a clean iPhone clip at 700 kbps of HEVC looked, to my planner, like a starved H.264 file. I was punishing sources for being efficient.

Now I convert the source bitrate into "the H.264 bitrate that would look the same" before comparing it with anything:

const CODEC_EFFICIENCY = {
  avc: 1,
  hevc: 1.45,
  vp9: 1.4,
  av1: 1.6,
  vp8: 0.95,
  prores: 0.25, // intra-only, spends huge bitrates on a picture H.264 carries cheaply
};
Enter fullscreen mode Exit fullscreen mode

These are conservative mid-points from the usual codec comparisons, not gospel. When I can't detect the codec, I assume H.264. Guessing something more efficient would inflate my estimate of the source and over-compress it.

Bug #3: the encoder refused to spend my budget

This one surprised me. A user asked for 60MB and got 28MB. My retry logic noticed the undershoot, "corrected" the target to 58.2MB, and the next pass produced 28MB again.

The cause: in most WebCodecs implementations, a constant-bitrate request is a ceiling, not a promise. Easy content simply costs less than the budget allows, and the encoder won't pad it.

My correction step clamped the retry to just under the target, which meant it asked for less than the pass that had already undershot. Useless. Now, on an undershoot, I ask for more than the target:

if (actualBytes > desiredBytes) {
  // Overshoot: mandatory fix. The promise is "at most N MB".
  return previousTarget * (desiredBytes / actualBytes) * 0.96;
}

if (attempt === 1 && !cappedBySource && actualBytes < desiredBytes * 0.8) {
  // Undershoot: raise the ask once.
  const scale = Math.min(1.9, desiredBytes / actualBytes);
  return previousTarget * scale * 0.95;
}
Enter fullscreen mode Exit fullscreen mode

That's safe because the overshoot branch runs first and is mandatory. If the raised ask lands over 8MB, the next pass pulls it back down. I cap it at three passes total, and I only raise once. If the content is cheap, it will undershoot at any budget, and every extra pass is a full re-encode the user sits through for nothing.

Bug #4: "compress" made a file bigger

A 684 kbps clip came back 10% larger. The culprit was audio. The source had a 48 kbps audio track, and I re-encoded it at 128 kbps.

Audio gets re-encoded, not copied, so giving it more bits than the original had invents nothing. It just makes the track bigger. On a thin video, that alone pushes the file past the original.

Audio now follows three rules:

  • It never gets more than the source had.
  • It never gets more than 18% of the total budget.
  • It snaps to a standard ladder (128, 96, 64, 48, 32, 24, 16 kbps) instead of arbitrary values, because some browsers' AAC encoders throw OperationError: Encoding error on odd low bitrates instead of just sounding worse.

At 64 kbps and below I switch to mono, since stereo at that rate sounds worse than mono. I only drop audio entirely when the budget can't carry even 16 kbps.

Running it in the browser

I use two engines behind the same plan:

  • Mediabunny on top of WebCodecs for modern containers. It uses the hardware encoder, so it's fast.
  • ffmpeg.wasm for everything else, and as a fallback when a hardware encode fails with a recoverable error. It's pure software, slower, but it doesn't depend on whatever GPU driver the user has.

Both engines consume the exact same CompressionPlan object (resolution, frame rate, bitrate, audio settings, keyframe interval), so they behave identically. The planner is a pure module with no browser APIs in it, which also means I can unit-test every one of the bugs above without touching a video file.

The client-side constraint cost me some speed, but it bought two things I care about. Nobody's video touches my server, and the source file can be as large as the user's device can handle.

One gotcha that isn't mine: what "8MB" means

I count 1 MB as 1,048,576 bytes, like Windows Explorer does. macOS Finder counts 1 MB as 1,000,000 bytes, so the same file looks bigger on a Mac: 7.6MB on Windows shows as 8.0MB in Finder.

My first pass aims at roughly 7.9 million bytes, which clears both definitions. If some upload form still rejects the file, the honest answer is to type 7 instead of 8.

What I'd tell anyone building this

  1. The size is arithmetic. The quality is judgment. Spend your time on resolution and frame-rate decisions, not on the division.
  2. Judge a source against itself. Absolute quality tables assume camera-fresh footage, and almost nobody compresses camera-fresh footage.
  3. Know which codec spent the bits. An HEVC bitrate and an H.264 bitrate aren't the same number.
  4. Treat encoder bitrate as a ceiling. Verify the output size and correct in both directions.
  5. Tell the user the truth. If 8MB can't hold their 3-minute clip at a decent resolution, warn them before they download.

If you want to see how it behaves on your own clips, the tool is here: compress video to 8MB. It's free, there's no watermark or sign-up, and the video never leaves your browser.

I'm curious how other people handle the undershoot problem with WebCodecs. If you've found a cleaner fix than "ask for more and correct on the next pass", tell me in the comments.

Top comments (0)