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.
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.
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.
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.
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.
Related
- Adaptive Bitrate Streaming with HLS and DASH — the parent reference covering manifests, segment duration and the ABR control loop
- Choosing ABR Rungs for Mobile Bandwidth — how to pick the rung set this command should encode
- Sharp and FFmpeg Media Build Pipelines — running this encode as a repeatable build step rather than by hand
- Understanding Video Codecs: VP9 vs H.265 vs AV1 — what changes in this command when you add a second ladder above the H.264 baseline
- Cache-Control Headers for Image and Video Assets — the caching profile the resulting
.m4sfragments and playlists need at the edge