How I make ffmpeg hit an exact file size (the bitrate math nobody explains)

How I make ffmpeg hit an exact file size (the bitrate math nobody explains)

Every few weeks I hit the same wall: I have a 300 MB screen recording, and something on the other end wants it under 8 MB. Discord, an email attachment, a bug tracker, a form that silently rejects anything bigger. The usual advice is "just use HandBrake" or "run ffmpeg with a lower CRF." But CRF doesn't take a target size — it takes a quality knob. So you export, check the size, it's 11 MB, nudge the knob, export again, now it's 5 MB and looks like a potato, nudge back… It's a binary search you run by hand, one full encode per guess. The thing is, hitting an exact size isn't a guessing game at all. It's arithmetic you can do before you encode. I ended up wrapping that arithmetic into a little Rust CLI (DeepShrink), but the math is the interesting part, and almost nobody writes it down. So here it is. The one insight everything rests on File size is (roughly) bitrate × duration. A bitrate is bits per second. A duration is seconds. Multiply them and the seconds cancel, leaving bits — the size of the file. That's it. That's the whole trick. Normally you treat bitrate as the input and size as whatever falls out. Flip it around: fix the size, measure the duration, and solve for the bitrate. You know the duration (ffprobe will tell you), and you know the size you want (the platform's limit). The only unknown is the bitrate — and now it's a single division away. bitrate = size_in_bits / duration_in_seconds Enter fullscreen mode Exit fullscreen mode Everything below is just this equation with the real-world messiness added back in. Building the budget, step by step Say I want a 60-second clip to fit Discord's 8 MB limit. 1. Turn the target size into bits. Sizes are in bytes, bitrate is in bits, so multiply by 8. (I'll use 1 MB = 1,000,000 bytes here to keep the mental math clean; if your platform means mebibytes, same method, different constant.) target_bits = 8_000_000 bytes × 8 = 64_000_000 bits (64 Mbit) Enter fullscreen mode Exit fullscreen mode 2. Reserve a little for container overhead. An .mp4 isn't pure video and audio — there's a container, a moov atom, stream headers, muxing slack. If you budget every last bit to the media, you'll overshoot. I hold back ~2%: usable_bits = 64 Mbit × (1 − 0.02) = 62.72 Mbit Enter fullscreen mode Exit fullscreen mode 3. Pay for audio first. Audio is usually a fixed, modest bitrate, so I carve it out before touching video. At 96 kbps for 60 seconds: audio_bits = 96_000 bps × 60 s = 5.76 Mbit Enter fullscreen mode Exit fullscreen mode 4. Whatever's left is the video budget. Divide it by the duration to get the video bitrate: video_bits = 62.72 − 5.76 = 56.96 Mbit video_bitrate = 56.96 Mbit / 60 s ≈ 949 kbps Enter fullscreen mode Exit fullscreen mode Done. Encode that 60-second clip at ~949 kbps of video plus 96 kbps of audio, and it lands right around 8 MB — no guessing, no re-exports. As a pure function it's about ten lines. This is essentially the core of DeepShrink's budget module (simplified): fn video_bitrate_bps(target_bytes: u64, duration_s: f64, audio_bps: u64) -> u64 { const OVERHEAD: f64 = 0.02; let target_bits = target_bytes as f64 * 8.0; let usable_bits = target_bits * (1.0 - OVERHEAD); let audio_bits = audio_bps as f64 * duration_s; let video_bits = (usable_bits - audio_bits).max(0.0); (video_bits / duration_s) as u64 } Enter fullscreen mode Exit fullscreen mode Ten lines of arithmetic, no I/O, trivially unit-testable. That last part matters: because it's pure, I can test the budgeting logic without ever spawning ffmpeg. Why you need two-pass to actually hit it Knowing the target bitrate isn't enough on its own. If you hand ffmpeg a single-pass -b:v 949k, the encoder doesn't know what's coming — a calm talking-head scene and a chaotic explosion get bits allocated blindly, and the average drifts off your target. Two-pass fixes this. The first pass analyzes the whole video and writes a stats log (it produces no output file). The second pass uses that map of "where the hard parts are" to distribute bits so the average actually lands on your number: # Pass 1 — analyze, no output ffmpeg -y -i input.mp4 -c:v libx264 -b:v 949k -pass 1 -an -f null /dev/null # Pass 2 — encode for real, using the stats from pass 1 ffmpeg -i input.mp4 -c:v libx264 -b:v 949k -pass 2 -c:a aac -b:a 96k output.mp4 Enter fullscreen mode Exit fullscreen mode Two passes means roughly double the encode time, but it's the difference between "about 8 MB" and "reliably under 8 MB." When the whole point is to fit under a hard limit, reliable wins. The guardrails that make it shippable If I stopped at the formula, it'd work maybe 80% of the time and produce garbage the other 20%. The interesting engineering is in the edges. Don't paint 1080p with a bitrate meant for a thumbnail. A one-hour lecture squeezed into 8 MB gives you a video budget so low that full HD turns to mush — the encoder simply doesn't have the bits to describe that many pixels. So before committing, I sanity-check the bitrate against the resolution. If it's too low to look acceptable at the source resolution, I step down a resolution ladder (1080p → 720p → 480p…) and pick the highest rung the budget can actually support. Fewer pixels, each with enough bits, beats many pixels all starved. Verify, then correct once. Muxing overhead isn't perfectly predictable, so the output can miss the target by a hair. After encoding I check the real file size, and if it overshot, I re-run with a slightly corrected bitrate. One correction, not an infinite loop — the first pass already gets you within a couple percent. Never make a file bigger. If the source is already under the target, there's nothing to solve. Re-encoding would just waste time and lose quality for no reason. In that case I skip straight to a remux (or leave it alone). "Shrink" should never accidentally inflate. Fail loudly when it's impossible. Sometimes the target genuinely can't be met — a two-hour movie into 2 MB isn't happening at any watchable quality. Rather than emit a slideshow and pretend it worked, DeepShrink bails with a distinct exit code (4, "can't fit even at minimum reasonable quality"). A clear failure beats a technically-succeeded garbage file. Audio-only is the same idea, minus a pass Podcasts, voice memos, and lectures hit size limits too, and they're actually simpler: no resolution ladder, and no two-pass (audio codecs hit an average bitrate directly). The budget collapses to one line: audio_bitrate = target_bits / duration_s Enter fullscreen mode Exit fullscreen mode The wrinkle is that you can't pick any arbitrary number — codecs work in sensible steps, so I snap down to the nearest one (snapping up would blow the target). And there's a floor: below a certain bitrate speech stops being intelligible, so I clamp there and, if even that won't fit, fail rather than ship noise. For speech specifically, downmixing to mono and using Opus buys a lot of headroom — a mono Opus voice track at ~24 kbps is still perfectly clear and a fraction of the size. $ deepshrink lecture.wav --target 10MB lecture.wav stereo 48kHz 58m02s 638.1 MB (PCM) target 10 MB plan Opus · ~22 kbps · mono (speech) ✓ lecture.shrink.opus 9.4 MB (−98.5%) Enter fullscreen mode Exit fullscreen mode The takeaway "Compress this to 8 MB" sounds like it needs a fancy heuristic or an ML model. It doesn't. It's size = bitrate × duration rearranged, plus a handful of guardrails so the arithmetic survives contact with real files: reserve overhead, pay audio first, drop resolution before the picture falls apart, verify and correct once, and refuse the impossible instead of faking it. The formula is ten lines. The guardrails are what make it feel like magic instead of a footgun. If you'd rather just type one command than wire this up yourself, that's exactly what I packaged DeepShrink for — it's open source (Rust, driving ffmpeg as a subprocess), MIT-licensed, and lives here: github.com/deeplabua/deepshrink. But now you know what it's doing under the hood, and you could write the core of it in an afternoon. What's your go-to trick for wrangling ffmpeg? I'm always looking for edges I haven't hit yet.

📰 Original Source

Read full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.