FFmpeg Two-Pass VP9 Encoding for the Web
Single-pass VP9 with -crf 32 -b:v 0 gives you a quality target and an unknown file size — which is fine until a product page has a 2 MB budget for its hero loop and one clip comes back at 4.8 MB because the middle three seconds are handheld. Two-pass VBR is the only libvpx-vp9 rate-control mode that takes a byte target and distributes bits across the whole clip to meet it. This page is the exact flag set and the failure modes, sitting inside the video half of Sharp and FFmpeg media build pipelines.
Be clear about what two-pass buys. On a 12-second 1080p clip, two-pass VP9 costs roughly 138 seconds against 71 for one-pass CQ and produces 2.1 MB against 2.2 MB — a rounding error on size. What you are paying 2× the CPU for is predictability and allocation, not compression. If you do not need a size guarantee, one-pass CQ is the better trade; the broader codec picture is in understanding video codecs: VP9 vs H.265 vs AV1.
Prerequisite checklist
Pick the rate-control mode deliberately
libvpx-vp9 has four usable rate-control modes and they are selected by which combination of -crf, -b:v, -minrate and -maxrate you pass. The mode is implicit, which is why so many command lines silently do something other than what their author intended:
What the first pass actually produces
Pass 1 runs the full encoder but discards the bitstream. What it keeps is a stats record per frame, written to <passlogfile>-0.log: intra and inter prediction error, motion-vector magnitude and count, a noise-energy estimate, duplicate-frame detection, and the coded-block density. Pass 2 reads the whole file up front, so it knows the complexity profile of second 9 while it is encoding second 1 — which is precisely the information a single-pass encoder can never have.
The complete two-pass command
One script. It converts a byte budget into -b:v, isolates the stats log per job so two encodes can run in parallel, and repeats the shared flags verbatim in both passes by holding them in an array.
#!/usr/bin/env bash
# encode-vp9.sh — two-pass VBR VP9 + Opus in WebM, at a byte budget.
# usage: ./encode-vp9.sh master.mov dist/hero-1080p.webm 2100
set -euo pipefail
SRC="$1"; OUT="$2"; BUDGET_KB="${3:-2100}" # target FILE size in kilobytes
AUDIO_KBPS=96 # Opus stereo; 96k is transparent for speech+music
# --- turn the byte budget into a video bitrate -------------------------------
# Duration from the container, never from a filename or a spreadsheet.
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$SRC")
# kilobits available = KB × 8, minus the audio track, spread over the duration.
VBR=$(awk -v kb="$BUDGET_KB" -v d="$DUR" -v a="$AUDIO_KBPS" \
'BEGIN { printf "%d", (kb * 8 / d) - a }')
echo "duration ${DUR}s → -b:v ${VBR}k (+ ${AUDIO_KBPS}k audio)"
# --- per-job stats prefix ----------------------------------------------------
# ffmpeg defaults to ./ffmpeg2pass-0.log. Two concurrent encodes in the same
# working directory overwrite each other's stats and pass 2 silently mis-allocates.
LOGDIR=$(mktemp -d); trap 'rm -rf "$LOGDIR"' EXIT
LOG="$LOGDIR/vp9"
# --- flags shared by BOTH passes (order matters: input opts, -i, output opts) -
COMMON=(
-nostdin # never block waiting on a TTY inside CI
-hide_banner -loglevel error
-i "$SRC"
-c:v libvpx-vp9
-b:v "${VBR}k" # VBR target. NOT `-b:v 0` — that switches
# libvpx to constant quality and ignores the budget.
-minrate "$((VBR / 2))k" # 50% floor: stops the encoder starving quiet
# scenes so badly that gradients band.
-maxrate "$((VBR * 145 / 100))k" # 145% ceiling: caps burst rate so a busy
# second cannot stall playback on a thin link.
-bufsize "$((VBR * 4))k" # buffer model window ≈ 4 s of target rate.
-g 240 # keyframe every 8 s at 30 fps. Identical in both
# passes, or the stats no longer match the frames.
-pix_fmt yuv420p # 8-bit 4:2:0 = VP9 profile 0, the only profile
# with broad hardware decode. A 10-bit master
# would otherwise silently produce profile 2.
-row-mt 1 # row-based multithreading (libvpx ≥ 1.7). Without
# it, threads are capped at one per tile column.
-tile-columns 2 # log2 → 4 tile columns. Requires width ≥ 4 × 256.
-threads 8
-frame-parallel 0 # frame-parallel decode costs 1–2% efficiency and
# buys nothing for progressive download.
-auto-alt-ref 1 # hidden alternate-reference frames: the single
# biggest quality lever in libvpx-vp9.
-lag-in-frames 25 # lookahead alt-ref needs; 25 is the practical max.
-deadline good # `best` is ~3× slower for <1%; `realtime` is live-only.
)
# --- pass 1: analysis only ---------------------------------------------------
# -cpu-used 4 : fast motion search. The first-order stats are barely affected,
# and this is where roughly a third of the total time is saved.
# -an : pass 1 throws its output away, so encoding audio is pure waste.
# -f null : the null muxer — nothing is written, nothing is interleaved.
ffmpeg "${COMMON[@]}" -pass 1 -passlogfile "$LOG" -cpu-used 4 -an -f null /dev/null
# --- pass 2: the real encode -------------------------------------------------
# -cpu-used 1 : slow search where it actually lands in the bitstream. 0 is ~40%
# slower again for well under 1% BD-rate on typical web clips.
ffmpeg "${COMMON[@]}" -pass 2 -passlogfile "$LOG" -cpu-used 1 \
-c:a libopus -b:a "${AUDIO_KBPS}k" -ac 2 \
-y "$OUT"
ls -l "$OUT"
Bitrate targets when you do not have a byte budget
If the constraint is a delivery tier rather than a page budget, start from these and let the script’s -minrate/-maxrate bracket do the rest. The tile-column and thread columns are geometry, not taste — see the next section.
| Resolution | fps | -b:v |
-tile-columns |
-threads |
|---|---|---|---|---|
| 426×240 | 30 | 150k | 0 | 2 |
| 640×360 | 30 | 276k | 1 | 4 |
| 854×480 | 30 | 750k | 1 | 4 |
| 1280×720 | 30 | 1024k | 2 | 8 |
| 1280×720 | 60 | 1800k | 2 | 8 |
| 1920×1080 | 30 | 1800k | 2 | 8 |
| 1920×1080 | 60 | 3000k | 2 | 8 |
| 2560×1440 | 30 | 6000k | 3 | 16 |
| 3840×2160 | 30 | 12000k | 3 | 16 |
These are per-rung targets, so they are also the ladder you would feed into adaptive bitrate streaming with HLS and DASH if the clip is long enough to warrant segmenting rather than progressive download.
Tile columns are a width calculation
-tile-columns is a log2 value: 2 means four tile columns. VP9’s bitstream requires a tile column to be at least 256 pixels wide, so the maximum usable value is fixed by frame width, and asking for more does not error — libvpx clamps, and your -threads budget sits idle.
For a portrait mobile master at 720 px wide the correct pair is -tile-columns 1 -threads 2 plus -row-mt 1 to recover parallelism across rows instead of columns. -row-mt is what makes narrow video encode at a reasonable speed at all; without it a 720-wide clip is effectively single-threaded.
Where the bits actually go
The reason two-pass matters is visible in the allocation. Take a 12-second clip: a static title card for three seconds, five seconds of handheld pan, four seconds of a medium-motion resolve. Single-pass CQ prices each moment on its own merits and overshoots; two-pass knows the whole shape and borrows from the cheap seconds to pay for the expensive ones while holding the average.
Note what two-pass does not do: it does not make the pan look as good as CQ made it. It makes the whole clip fit, and it spends the savings from the title card where they help most. If the pan is the shot the page is selling, raise the budget rather than expecting the encoder to conjure bits.
Verification
1. Did it hit the budget?
# Actual size, duration and overall bitrate in one call.
ffprobe -v error -show_entries format=size,duration,bit_rate \
-of default=noprint_wrappers=1 dist/hero-1080p.webm
# size=2234880 duration=12.000000 bit_rate=1489920
# 2234880 / 1024 = 2183 KB against a 2100 KB budget — 4% over, within VBR tolerance.
# Per-stream split, so you can see whether video or audio overshot.
ffprobe -v error -show_entries stream=index,codec_type,bit_rate \
-of csv=p=0 dist/hero-1080p.webm
If you are consistently 10%+ over, lower -maxrate toward 120% of target and re-run; if consistently under, the clip is simpler than the budget and you can raise -b:v for free quality.
2. Is it profile 0, 8-bit, 4:2:0?
This is the check that catches the silent disaster: a 10-bit HDR master encoded to VP9 profile 2, which plays fine on your desktop and falls back to software decode (or nothing) on most phones and TVs.
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,pix_fmt,width,height,r_frame_rate \
-of default=noprint_wrappers=1 dist/hero-1080p.webm
# codec_name=vp9 profile=Profile 0 pix_fmt=yuv420p ← required
Then confirm the browser agrees, in a DevTools console on the page that will serve it:
// vp09.<profile>.<level>.<bitDepth> — 00 / 40 (level 4.0, 1080p30) / 08.
MediaSource.isTypeSupported('video/webm; codecs="vp09.00.40.08, opus"'); // true
MediaSource.isTypeSupported('video/webm; codecs="vp09.02.40.10, opus"'); // profile 2
Device coverage for the profile-2 and AV1 cases is mapped in hardware AV1 decode support by device class.
3. Are the keyframes where you asked?
# Print the timestamp of every keyframe. With -g 240 at 30 fps these should be
# 8 s apart. Wildly irregular spacing means -g was applied in only one pass.
ffprobe -v error -select_streams v:0 -skip_frame nokey \
-show_entries frame=pts_time -of csv=p=0 dist/hero-1080p.webm
4. Score it against the master
# VMAF, scaled to the master's geometry. Below ~93 on a hero loop is visible.
ffmpeg -v error -i dist/hero-1080p.webm -i master.mov \
-lavfi "[0:v]scale=1920:1080:flags=bicubic[dist];[dist][1:v]libvmaf=n_threads=4" \
-f null -
Common mistakes
1. -b:v 0 in a two-pass command
The most common VP9 command line on the internet is the CQ one, and people bolt -pass 1/-pass 2 onto it. Two-pass runs, the stats file is written and read, and the output size is still whatever quality demands — because there is no target for the allocator to hit.
# WRONG — two-pass constant quality. Costs 2x the CPU, still overshoots the budget.
ffmpeg -i in.mov -c:v libvpx-vp9 -crf 32 -b:v 0 -pass 2 out.webm
# CORRECT — VBR: a non-zero -b:v and no -crf at all.
ffmpeg -i in.mov -c:v libvpx-vp9 -b:v 1400k -pass 2 out.webm
2. Sharing one ffmpeg2pass-0.log across parallel jobs
FFmpeg writes its stats to ffmpeg2pass-0.log in the working directory by default. Run two encodes concurrently from the same directory — exactly what a batch pipeline does — and the second pass-1 truncates the first one’s log while its pass 2 is reading it. The failure is not always loud: sometimes you get Error: invalid stats, and sometimes you get a valid file encoded against another clip’s complexity profile.
# WRONG — both jobs write ./ffmpeg2pass-0.log.
ffmpeg -i a.mov ... -pass 1 -f null /dev/null &
ffmpeg -i b.mov ... -pass 1 -f null /dev/null &
# CORRECT — one prefix per job, in a per-job temp directory.
ffmpeg -i a.mov ... -pass 1 -passlogfile "$(mktemp -d)/a" -f null /dev/null &
ffmpeg -i b.mov ... -pass 1 -passlogfile "$(mktemp -d)/b" -f null /dev/null &
3. Different flags in the two passes
The stats records are indexed by frame, so anything that changes which frames exist or how large they are invalidates the correspondence. A -vf scale or -r present in pass 2 but not pass 1 is the classic case: pass 2 is allocating bits for 1080p frames using statistics gathered from 4K ones.
# WRONG — pass 1 analyses the 4K master, pass 2 encodes a 1080p downscale.
ffmpeg -i 4k.mov -c:v libvpx-vp9 -b:v 1800k -pass 1 -an -f null /dev/null
ffmpeg -i 4k.mov -c:v libvpx-vp9 -b:v 1800k -vf scale=1920:-2 -pass 2 out.webm
# CORRECT — identical filter chain, GOP and rate control in both passes.
FILTERS="scale=1920:-2"
ffmpeg -i 4k.mov -c:v libvpx-vp9 -b:v 1800k -g 240 -vf "$FILTERS" -pass 1 -an -f null /dev/null
ffmpeg -i 4k.mov -c:v libvpx-vp9 -b:v 1800k -g 240 -vf "$FILTERS" -pass 2 out.webm
Holding the shared flags in a shell array, as the solution script does, makes this class of bug structurally impossible.
4. -threads without -row-mt 1
Before row multithreading, libvpx-vp9’s only parallelism was one thread per tile column. -threads 8 -tile-columns 2 therefore uses four threads at most, and on a 640-wide clip (-tile-columns 1, two columns) it uses two — regardless of what you asked for.
# WRONG — 8 threads requested, at most 4 used, ~2.5x slower than it needs to be.
ffmpeg -i in.mov -c:v libvpx-vp9 -b:v 1024k -tile-columns 2 -threads 8 out.webm
# CORRECT — row-mt lets threads work within a tile column too.
ffmpeg -i in.mov -c:v libvpx-vp9 -b:v 1024k -tile-columns 2 -threads 8 -row-mt 1 out.webm
Tradeoff: -row-mt 1 costs a fraction of a percent in compression efficiency because some row-level context is approximated. On a build pipeline where encode time is the binding constraint, take the speed every time.
5. Trusting the master’s pixel format
A ProRes or HEVC master shot on a modern camera is frequently 10-bit, and libvpx-vp9 will happily follow it into profile 2. Nothing warns you; ffplay on a desktop plays it back correctly.
# WRONG — a 10-bit master produces yuv420p10le, i.e. VP9 profile 2.
ffmpeg -i master_prores.mov -c:v libvpx-vp9 -b:v 1800k out.webm
# CORRECT — pin the output format, and pin the colour conversion with it.
ffmpeg -i master_prores.mov -c:v libvpx-vp9 -b:v 1800k \
-vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p" \
out.webm
The zscale chain matters for HDR masters specifically: a naive -pix_fmt yuv420p on a PQ/BT.2020 source produces an 8-bit file that is technically profile 0 and visually washed out, because the transfer characteristics were never converted. For SDR masters the plain -pix_fmt yuv420p in the solution script is sufficient.
Related
- Sharp and FFmpeg Media Build Pipelines — the pipeline this encode runs inside: keying, encoder salting, and pool limits
- Batch AVIF Conversion with Sharp in CI — the image half of the same build stage, including poster-frame derivatives
- AV1 vs VP9 Encode Time on AWS Lambda — the same encode-cost question moved off a CI runner and onto serverless
- How to Implement Lazy Loading for WebM Backgrounds — deferring the file this produces until it is worth the bytes