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:

libvpx-vp9 rate-control modes and the flags that select them A four-row matrix. Constant quality is selected by crf with b:v zero and gives unpredictable size, best for one-off hero clips. Constrained quality adds a non-zero b:v ceiling and gives a bounded size. Two-pass VBR sets b:v with no crf and is the only mode that targets a byte budget. Constant bitrate pins minrate, b:v and maxrate to the same value and is for live streaming only. mode selecting flags size predictable use it for Constant quality (CQ) -crf 32 -b:v 0 no one-off clips where quality is the constraint Constrained quality (CQ + cap) -crf 32 -b:v 2M b:v acts as a ceiling bounded above mixed libraries with a worst-case limit Two-pass VBR (this page) -b:v 1400k, no -crf -pass 1 then -pass 2 yes, within ~5% byte budgets, ladder rungs, anything with a page budget Constant bitrate (CBR) -minrate 1M -b:v 1M -maxrate 1M exact live ingest only — wastes bits on static scenes The trap: -b:v 0 anywhere in a two-pass command silently reverts you to constant quality. The stats file is still written and still read — it just cannot constrain a target that was never set. Two-pass and constant quality are orthogonal; only the presence of a non-zero -b:v creates a budget.

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.

Two-pass VP9: what crosses between the passes The master file feeds pass one, which runs the encoder with fast search, no audio and the null muxer so the bitstream is discarded. Pass one writes a stats log containing per-frame intra error, inter error, motion vector magnitude, noise energy and duplicate frame flags. Pass two reads the whole log before encoding, performs global bit allocation against the b:v target, and muxes VP9 video with an Opus audio track into WebM. master.mov 1920×1080 30 fps, 12 s pass 1 — analysis -cpu-used 4 -an -f null /dev/null bitstream discarded write vp9-0.log — one record per frame • intra / inter prediction error • motion-vector count + magnitude • noise energy estimate • duplicate / static frame flag • coded block density read in full before frame 1 is coded pass 2 — encode -cpu-used 1 global allocation vs -b:v + libopus audio hero-1080p.webm 2.13 MB of a 2.10 budget Every rate-control and GOP flag must be byte-identical across both passes, or the records no longer describe the frames being coded.

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.

Tile-column geometry at 1920 and 720 pixels wide The top frame is 1920 pixels wide split into four tile columns of 480 pixels each, all above the 256 pixel minimum, so tile-columns 2 is valid and four threads can work in parallel. The bottom frame is 720 pixels wide; four columns would be 180 pixels each, below the minimum, so libvpx clamps to two columns of 360 pixels and half the requested threads go unused. 1920 × 1080 landscape — -tile-columns 2 → 4 columns × 480 px 480 px 480 px 480 px 480 px all ≥ 256 px minimum 4 threads genuinely parallel 720 × 1280 portrait — -tile-columns 2 requested, 1 delivered 360 px 360 px 180 px splits rejected clamped to 2 columns -threads 8 leaves 6 idle — and every extra tile boundary resets entropy context, so over- tiling costs roughly 0.5–1% efficiency per split Rule: max tile columns = floor(width ÷ 256), rounded down to a power of two. Set -threads to that number, not higher.

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.

Bit allocation across a 12-second clip: one-pass CQ vs two-pass VBR Bitrate over time for a twelve second clip with three segments. In the static title segment one-pass CQ spends 600 kbps and two-pass spends 520. In the handheld pan segment one-pass spends 3100 kbps while two-pass spends 2150. In the resolve segment one-pass spends 1700 and two-pass 1180. The one-pass average is 2008 kbps, giving a 3.0 megabyte file; the two-pass average is 1419 kbps, giving 2.13 megabytes against a 2.10 megabyte budget. Instantaneous bitrate, 1080p30 clip, 2.10 MB budget static title card handheld pan medium-motion resolve 3200 2400 1600 800 0 kbps 0s 3s 6s 9s 12s -b:v 1400k target 3100 kbps 2150 kbps one-pass CQ (-crf 32 -b:v 0) — mean 2008 kbps → 3.01 MB, 43% over budget two-pass VBR (-b:v 1400k) — mean 1419 kbps → 2.13 MB, 1.4% over budget

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.