I carried one assumption for far too long: if the new format is more efficient, the output must be smaller. VP9 really is more efficient than H.264. But codec efficiency promises nothing about file size unless you tell the encoder how generous it is allowed to be.
I learned that while cleaning up the media pipeline on my own portfolio site. The project demo videos were still stored as mp4, and I wanted them as VP9 webm so the project pages would load lighter. One command, one pass, done. That was the plan.
The symptom: the webm outweighed its own mp4 source
I used the number that shows up in almost every VP9 for web recommendation:
ffmpeg -y -i in.mp4 -an \
-c:v libvpx-vp9 -crf 33 -b:v 0 \
-row-mt 1 -cpu-used 4 -pix_fmt yuv420p \
out.webmThe encode succeeded. No suspicious warnings. But when I lined the input and output sizes up side by side, several webm files were larger than the mp4 they came from. Not by a rounding error either, but by enough that the entire reason for doing the conversion had evaporated.
My first instinct pointed the wrong way. I suspected -b:v 0 was letting the bitrate run wild, then suspected -cpu-used 4 was trading compression for speed. Neither was the culprit.
The root cause: CRF answers a different question
In libvpx-vp9, -crf is a constant quality target, not a size target. Paired with -b:v 0 you are explicitly removing the bitrate ceiling and telling the encoder: hit this quality level, spend whatever you need.
That is where it fell apart. My source videos had already been compressed hard in an earlier step. The fine detail was long gone, and part of what remained was compression artifacts: blocking, banding, and synthetic noise that is genuinely hard for an encoder to predict.
VP9 has no way of knowing which pixels are real detail and which are scar tissue from a previous encode. To it, all of that is frame content that must be reproduced faithfully until the crf 33 target is met. So the encoder worked hard, and in doing so spent more bits than the source itself had used.
crf 33 was never the wrong number. It was simply far too generous for a source with that little quality left. My actual mistake was treating CRF as one global constant when it has to be set relative to the quality of each source.
The fix: two numbers instead of one
Once the framing was right, the fix got boring. I split the videos into two groups.
New high quality clips that had never been re-compressed kept crf 33:
ffmpeg -y -i in.mp4 -an \
-c:v libvpx-vp9 -crf 33 -b:v 0 \
-row-mt 1 -cpu-used 4 -pix_fmt yuv420p \
out.webmThe older, already compressed videos moved up to 38:
ffmpeg -y -i in.mp4 -an \
-c:v libvpx-vp9 -crf 38 -b:v 0 \
-row-mt 1 -cpu-used 4 -pix_fmt yuv420p \
out.webmThe numbers immediately started making sense. The existing set went from 107 MB down to 76 MB, with every single file finally smaller than its source. The new clips, previously 17 to 20 MB each, dropped to roughly 2 MB at crf 33, because those genuinely had surplus quality worth throwing away.
Note that exactly one value changed. Everything else stayed put:
-anstrips the audio track. These demo videos are never played with sound, so audio is pure weight.- Native resolution and 60fps were kept. I did not downscale or drop frames to chase size, because the expensive part here was not the dimensions, it was how hard the encoder was told to chase quality.
-row-mt 1and-cpu-used 4govern encode speed.-cpu-useddoes trade a little compression efficiency for that speed, but it never moves the quality target CRF is chasing.-pix_fmt yuv420pkeeps playback safe everywhere.
Thumbnails while I was in there
Since I was already reorganizing media, the static thumbnails went to WebP with a sensible width cap:
ffmpeg -i in.png -vf "scale='min(1600,iw)':-2" -c:v libwebp -q:v 82 out.webpmin(1600,iw) prevents small images from being upscaled, and -2 keeps the resulting height even so the encoder does not complain.
A quick check before committing
The uncomfortable lesson: I never compared input and output sizes until the whole batch was already converted. Now that comparison is part of the process rather than a surprise at the end:
for f in *.mp4; do
printf "%s %s -> %s\n" "$f" \
"$(du -h "$f" | cut -f1)" \
"$(du -h "${f%.mp4}.webm" | cut -f1)"
doneIf any row points at a bigger number, raise the CRF for that file and re-encode. No clever tooling needed, just one honest table.
What I took away
- CRF is a quality target, not a size target. With
-b:v 0, the encoder is free to spend as many bits as it takes to reach it. - A heavily compressed source is not a low quality source in the encoder's eyes. Compression artifacts still count as detail that must be reproduced, and that costs bits.
- There is no single correct CRF for every video. Tune it per source quality, not per project.
- If the point of the conversion is size, then measure size. A successful encode is not the same thing as a useful one.