Building an HLS Bitrate Ladder with FFmpeg

FFmpeg will cheerfully produce five renditions that each play perfectly on their own and still form a ladder that freezes on every quality switch. The constraint that governs this page is narrow and mechanical: an HLS ladder is switchable only if every rendition places an IDR frame at exactly the same presentation timestamps, and only if the muxer cuts segments on those same timestamps in every rendition. Encode quality is irrelevant to that property; GOP configuration and muxer flags are everything. This page is the flag-by-flag build of the ladder that adaptive bitrate streaming with HLS and DASH describes at the architecture level — one command, then the four checks that prove it worked.

Prerequisite checklist

How the single-pass graph works

Decoding is the expensive part of a ladder encode, and there is no reason to pay for it five times. A filter_complex graph decodes once, normalises frame rate and pixel format once, then split duplicates the frame stream into five branches that are scaled independently. Each branch is fed to its own libx264 instance through a -map of the labelled filter output.

This ordering matters. Normalising before the split guarantees that all five encoders see the same frame count with the same timestamps, so their GOP counters advance in lockstep. Normalising after the split — a common refactor when someone adds a per-rung fps — allows rounding to land differently on different branches and silently desynchronises the grid.

Single-Pass Ladder Filter Graph A left to right data flow diagram. The source file is decoded once, passed through an fps and pixel format normalisation stage, then a split=5 node fans out to five scale filters at 416 by 234, 640 by 360, 854 by 480, 1280 by 720 and 1920 by 1080. Each scale output feeds a separate libx264 encoder instance with its own bitrate and level. A footer line notes that all five encoders share one GOP configuration. source.mov decoded once fps=30 format=yuv420p split=5 scale=416:234 scale=640:360 scale=854:480 scale=1280:720 scale=1920:1080 libx264 · 200k · level 3.0 libx264 · 600k · level 3.0 libx264 · 1200k · level 3.1 libx264 · 2400k · level 3.1 libx264 · 4800k · level 4.0 All five encoders inherit one GOP configuration — g=60, keyint_min=60, sc_threshold=0 — so their IDR positions cannot diverge.

The complete encode

Everything below runs as one command. Copy it into a file, set SRC, and run it; the only per-project edits are the ladder numbers and the frame rate.

#!/usr/bin/env bash
set -euo pipefail

SRC="source.mov"
OUT="build/vod"
FPS=30                       # must match the source rate family (30 vs 30000/1001)
GOP=$(( FPS * 2 ))           # 60 frames = a 2 s closed GOP, which divides 4 s exactly
mkdir -p "$OUT"

ffmpeg -hide_banner -y -i "$SRC" \
  `# ---- one decode, one normalisation, five scaled branches ---------------- ` \
  -filter_complex "\
[0:v]fps=${FPS},format=yuv420p,split=5[b0][b1][b2][b3][b4];\
[b0]scale=w=416:h=234:force_original_aspect_ratio=decrease,setsar=1[v0];\
[b1]scale=w=640:h=360:force_original_aspect_ratio=decrease,setsar=1[v1];\
[b2]scale=w=854:h=480:force_original_aspect_ratio=decrease,setsar=1[v2];\
[b3]scale=w=1280:h=720:force_original_aspect_ratio=decrease,setsar=1[v3];\
[b4]scale=w=1920:h=1080:force_original_aspect_ratio=decrease,setsar=1[v4]" \
  `# force_original_aspect_ratio=decrease never stretches a non-16:9 source;  ` \
  `# setsar=1 rewrites the sample aspect ratio so players do not re-stretch it.` \
  -map "[v0]" -map "[v1]" -map "[v2]" -map "[v3]" -map "[v4]" \
  -map a:0 \
  `# ---- GOP structure: identical for every rung, no exceptions ------------- ` \
  -c:v libx264 -preset slow -pix_fmt yuv420p \
  -g "$GOP" -keyint_min "$GOP" -sc_threshold 0 \
  `# -g sets the maximum IDR interval, -keyint_min the minimum. Equal values   ` \
  `# plus -sc_threshold 0 forbid x264 from adding a scene-change IDR, which    ` \
  `# would reset the counter on one rung and not the others.                   ` \
  -bf 3 -refs 3 \
  `# B-frames and reference frames are free to differ per rung in principle,   ` \
  `# but keeping them equal makes the decoder reset on switch cheaper.         ` \
  `# ---- per-rung rate control: average, VBV ceiling, VBV buffer ------------ ` \
  -b:v:0 200k  -maxrate:v:0 214k  -bufsize:v:0 400k  -profile:v:0 main -level:v:0 3.0 \
  -b:v:1 600k  -maxrate:v:1 642k  -bufsize:v:1 1200k -profile:v:1 main -level:v:1 3.0 \
  -b:v:2 1200k -maxrate:v:2 1284k -bufsize:v:2 2400k -profile:v:2 main -level:v:2 3.1 \
  -b:v:3 2400k -maxrate:v:3 2568k -bufsize:v:3 4800k -profile:v:3 high -level:v:3 3.1 \
  -b:v:4 4800k -maxrate:v:4 5136k -bufsize:v:4 9600k -profile:v:4 high -level:v:4 4.0 \
  `# maxrate at 1.07x average and bufsize at 2x average bound the VBV window   ` \
  `# so a high-motion segment cannot triple the bitrate the manifest declares. ` \
  `# ---- one shared audio rendition, not five muxed copies ------------------ ` \
  -c:a aac -ac 2 -ar 48000 -b:a 128k \
  `# ---- CMAF fMP4 packaging ------------------------------------------------ ` \
  -f hls -hls_time 4 -hls_playlist_type vod -hls_list_size 0 \
  -hls_segment_type fmp4 -hls_fmp4_init_filename "init.mp4" \
  -hls_flags independent_segments \
  `# independent_segments writes EXT-X-INDEPENDENT-SEGMENTS, the assertion     ` \
  `# that every segment opens on an IDR. ffmpeg does not verify it for you.    ` \
  -hls_segment_filename "${OUT}/%v/seg_%d.m4s" \
  -master_pl_name "master.m3u8" \
  -var_stream_map "v:0,agroup:aud v:1,agroup:aud v:2,agroup:aud v:3,agroup:aud \
v:4,agroup:aud a:0,agroup:aud,default:yes,name:audio_en,language:en" \
  "${OUT}/%v/index.m3u8"

-var_stream_map is the flag that does the most work and gets the least attention. Each space-separated token defines one output variant. v:N and a:N refer to the mapped output streams in -map order, not to input streams. agroup:aud puts every video variant and the single audio variant into one EXT-X-MEDIA rendition group, so the audio track is stored once and referenced five times instead of being muxed into each variant. On a five-rung ladder that removes roughly 15% of the stored bytes and is a hard prerequisite for ever adding a second language.

The %v in -hls_segment_filename and in the output pattern expands to the variant index — 0 through 4 — except for variants that carry a name: key, where it expands to that name. That is why the audio ends up in audio_en/ while the video rungs are numbered.

What -var_stream_map Writes to Disk A tree with master.m3u8 on the left and six output directories on the right. Five numbered directories hold the video variant playlists with their init segment and media segments, and one directory named audio_en holds the shared audio rendition. Annotations on the right give the resolution of each video rung and the audio codec. master.m3u8 -master_pl_name 0/index.m3u8 init.mp4 · seg_1.m4s … seg_N.m4s 1/index.m3u8 init.mp4 · seg_1.m4s … seg_N.m4s 2/index.m3u8 init.mp4 · seg_1.m4s … seg_N.m4s 3/index.m3u8 init.mp4 · seg_1.m4s … seg_N.m4s 4/index.m3u8 init.mp4 · seg_1.m4s … seg_N.m4s audio_en/index.m3u8 stored once, referenced five times 416×234 640×360 854×480 1280×720 1920×1080 AAC-LC 128k EXT-X-STREAM-INF carries AUDIO="aud" on every variant.

Three flags in that command are per-stream and three are global, and mixing them up is the most common source of “it encoded but the ladder is wrong”. The table below separates them.

Flag Scope Value used What breaks if you get it wrong
-g / -keyint_min global 60 / 60 Unequal values let x264 shorten a GOP; the grid stops being periodic.
-sc_threshold global 0 Left at the default, scene cuts insert IDRs at different frames per rung.
-b:v:N / -maxrate:v:N / -bufsize:v:N per output stream see ladder Dropping the :N applies rung 4’s bitrate to all five outputs.
-profile:v:N / -level:v:N per output stream main/high An over-tight level makes x264 silently re-clamp the bitrate.
-hls_time muxer 4 Not a multiple of the GOP duration means irregular segment lengths.
-hls_segment_type muxer fmp4 mpegts forces hls.js to transmux every segment in JavaScript.
-var_stream_map muxer see above Wrong stream indices silently produce variants with no audio.

Tradeoff: single pass versus two pass. The command above is one-pass VBV-constrained, which is the right default because a bounded VBV window already delivers the predictable per-segment bitrate the manifest needs. Two-pass (-pass 1 with -f null - then -pass 2) buys roughly 3–6% better quality at the same bitrate on mixed content, at the cost of doubling encode wall time and requiring one log file per output stream — five extra files whose names you must keep distinct with -passlogfile. Reach for it on a catalogue you encode once and serve millions of times; skip it on user-generated uploads where throughput matters more than the last few percent.

Verification

1. Prove the IDR grid is identical across rungs

A CMAF media segment is not independently decodable — ffprobe needs the initialization segment prepended before it can parse anything. The check below builds a probe file per rung and reduces its keyframe timestamps to a single hash, so any divergence shows up as a mismatched digest rather than as two columns of numbers you have to read.

# Hash the IDR presentation timestamps of the first six segments of each rung.
# Identical hashes across all five rungs = a switchable ladder.
for r in 0 1 2 3 4; do
  cat "build/vod/$r/init.mp4" build/vod/$r/seg_{1..6}.m4s > /tmp/probe_$r.mp4
  h=$(ffprobe -v error -select_streams v:0 -skip_frame nokey \
        -show_entries frame=pts_time -of csv=p=0 /tmp/probe_$r.mp4 \
      | md5sum | cut -c1-12)
  printf 'rung %s  idr-hash %s\n' "$r" "$h"
done

# Second half of the check: EXTINF durations must match line for line.
diff <(grep -E '^#EXTINF' build/vod/0/index.m3u8) \
     <(grep -E '^#EXTINF' build/vod/4/index.m3u8) && echo "durations aligned"

The failure this catches is drawn below. With -sc_threshold 0 the IDR grid is frame-exact on every rung and the muxer cuts at 4.000 s everywhere. Leave the default scene-cut threshold in place and each rung detects the cut a frame or two apart — because the picture it sees has been scaled differently — so the segment boundaries diverge and stay diverged for the rest of the file.

Fixed GOP Grid vs Scene-Cut Drift Two groups of three timeline lanes over a shared twelve second axis. In the upper group every rung places IDR frames every two seconds and the segment boundary falls at four seconds on all three rungs. In the lower group a scene change inserts an extra IDR at slightly different times per rung, so the first segment ends at 4.0, 5.4 and 5.5 seconds respectively. IDR positions over the first 12 seconds — three of the five rungs -g 60 -keyint_min 60 -sc_threshold 0 rung 0 rung 2 rung 4 Segment 1 ends at 4.000 s on every rung — the player appends across the join without flushing the decoder. default scene-cut threshold, no keyint_min rung 0 rung 2 rung 4 0 s 2 4 6 8 10 12 Segment 1 ends at 4.000 s, 5.400 s and 5.500 s. Every switch now drops or repeats up to 1.5 s of media.

2. Correct the declared BANDWIDTH

ffmpeg writes BANDWIDTH into the multivariant playlist from the declared stream bitrates, not from the segments it actually produced. A rung whose real peak exceeds its declared value will be selected by players that cannot sustain it, and the result is a rebuffer roughly one segment later.

# Peak observed bitrate per rung, in bits per second, over 4 s segments.
for r in 0 1 2 3 4; do
  peak=$(for f in build/vod/$r/seg_*.m4s; do
           echo $(( $(stat -c%s "$f") * 8 / 4 ))    # bytes -> bits -> bits/s
         done | sort -n | tail -1)
  declared=$(grep -oP 'BANDWIDTH=\K[0-9]+' build/vod/master.m3u8 | sed -n "$((r+1))p")
  printf 'rung %s  declared %-9s peak %-9s %s\n' "$r" "$declared" "$peak" \
    "$( [ "$peak" -gt "$declared" ] && echo UNDERSTATED || echo ok )"
done

Any rung marked UNDERSTATED needs its BANDWIDTH raised to the measured peak plus the audio rendition’s bitrate. The chart below is a real run of that script against the ladder above, before the VBV settings were tightened.

Declared BANDWIDTH vs Measured Peak Segment Bitrate Five pairs of horizontal bars. For each rung the upper bar is the BANDWIDTH ffmpeg wrote into the playlist and the lower bar is the highest bitrate actually observed across the rung's segments. Rungs two, three and four overshoot their declared value; rungs zero and one do not. Declared BANDWIDTH (outline) vs measured peak segment bitrate (filled) — 4 s segments rung 0 · 234p 328k declared · 291k peak rung 1 · 360p 728k declared · 706k peak rung 2 · 480p 1328k declared · 1402k peak rung 3 · 720p 2528k declared · 2733k peak rung 4 · 1080p 4928k declared · 5390k peak 0 1 Mbps 2 Mbps 3 Mbps 4 Mbps 5 Mbps The three upper rungs overshoot because a single high-motion segment consumed its whole VBV allowance. Raise BANDWIDTH or tighten bufsize.

3. Run the conformance validator

# Apple's validator parses the ladder, downloads sample segments and reports
# BANDWIDTH accuracy, CODECS correctness and IDR placement per variant.
mediastreamvalidator --hls-version 7 build/vod/master.m3u8 -o /tmp/val.json
hlsreport.py /tmp/val.json -o /tmp/val.html

# Confirm the CODECS strings ffmpeg wrote match the profile and level you set.
# [email protected] is avc1.4d401e, [email protected] is avc1.4d401f, [email protected] is avc1.640028.
grep -oP 'CODECS="\K[^"]+' build/vod/master.m3u8

A MUST error for “playlist bandwidth does not match measured” is the same finding as step 2 and confirms the fix landed. CODECS errors are worth chasing immediately: Media Source Extensions rejects a SourceBuffer outright when the string does not describe the bitstream, so the rung simply never plays in Chrome or Firefox.

4. Force a switch in the browser

// Load the ladder, jump straight to the top rung, then drop to the floor.
// A clean ladder switches with no gap in the currentTime progression and no
// entry in the ERROR event log.
const hls = new Hls({ debug: false, startLevel: 0 });
hls.loadSource('/vod/master.m3u8');
hls.attachMedia(document.querySelector('video'));

hls.on(Hls.Events.MANIFEST_PARSED, () => {
  document.querySelector('video').play();
  setTimeout(() => { hls.nextLevel = hls.levels.length - 1; }, 4000);
  setTimeout(() => { hls.nextLevel = 0; }, 12000);
});

// BUFFER_APPENDED firing continuously across both switches means the decoder
// accepted the spliced segments. A MEDIA_ERROR here means the grid is broken.
hls.on(Hls.Events.ERROR, (_, d) => console.warn(d.type, d.details, d.fatal));

Common mistakes

1. Setting -g without -keyint_min and -sc_threshold 0

-g 60 alone only sets the maximum IDR interval. x264 remains free to insert an IDR at a detected scene change, and the encoder’s detector fires on slightly different frames once the picture has been scaled to a different resolution.

# WRONG: the grid drifts as soon as the content has a hard cut.
-c:v libx264 -g 60

# CORRECT: fixed interval, no scene-change insertion.
-c:v libx264 -g 60 -keyint_min 60 -sc_threshold 0
# Equivalent, if you prefer speaking to x264 directly:
-c:v libx264 -x264-params "keyint=60:min-keyint=60:scenecut=0:open-gop=0"

2. Declaring an average bitrate with no VBV bound

-b:v 2400k without -maxrate and -bufsize lets libx264 spend three times that on a complex second and repay it later. The average is honoured over the whole file; the manifest’s BANDWIDTH is a per-segment promise.

# WRONG: one high-motion segment can hit 7 Mbps on a "2400k" rung.
-b:v:3 2400k

# CORRECT: ceiling at 1.07x average, VBV buffer at 2x average.
-b:v:3 2400k -maxrate:v:3 2568k -bufsize:v:3 4800k

3. Scaling with -1 and producing an odd dimension

scale=-1:360 computes the width from the source aspect ratio and can return an odd number, which libx264 rejects for 4:2:0 chroma with width not divisible by 2.

# WRONG: a 1919-wide source yields an odd scaled width and the encode aborts.
scale=-1:360

# CORRECT: -2 rounds to the nearest even number; or state both dimensions and
# use force_original_aspect_ratio so a non-16:9 source is letterboxed, not stretched.
scale=-2:360
scale=w=640:h=360:force_original_aspect_ratio=decrease,setsar=1

4. Muxing audio into every variant

Omitting agroup from -var_stream_map (or omitting the map entirely) makes ffmpeg mux a copy of the audio track into each of the five variants. You pay for it five times in storage and in CDN egress, and adding a second language later means re-encoding the whole ladder.

# WRONG: five variants, five copies of the same AAC stream.
-map "[v0]" -map "[v1]" -map a:0 -map a:0 \
-var_stream_map "v:0,a:0 v:1,a:1"

# CORRECT: one audio rendition in a group every video variant references.
-map "[v0]" -map "[v1]" -map a:0 \
-var_stream_map "v:0,agroup:aud v:1,agroup:aud a:0,agroup:aud,default:yes,name:audio_en"

5. Choosing a segment duration the GOP does not divide

-hls_time 5 against a 2-second GOP forces the muxer to cut at the next IDR after 5 seconds — which is 6 seconds. Every segment is 6 s long, EXT-X-TARGETDURATION is wrong relative to your intent, and the startup delay you budgeted for is 20% higher than planned.

# WRONG: 5 is not a multiple of the 2 s GOP.
-g 60 -hls_time 5

# CORRECT: either match the segment to the GOP grid…
-g 60 -hls_time 4
# …or move the GOP so it divides the segment you want.
-g 75 -keyint_min 75 -sc_threshold 0 -hls_time 5

Tradeoff: shorter GOPs give the packager more places to cut and the player finer switch granularity, but every IDR costs 3–8× an inter frame. Halving the GOP from 2 s to 1 s adds roughly 4–8% to the bitrate at fixed quality — real money at the top rung, negligible at the floor. Once the ladder encodes cleanly, the next decision is which rungs to keep for the audience you actually have; choosing ABR rungs for mobile bandwidth works that out from measured throughput rather than from a resolution list.