Force keyframe-aligned GOPs across an ABR ladder with FFmpeg
TL;DR
If your stream hitches on quality switches but shows zero rebuffering, your ABR renditions probably have keyframes in different places. We'll encode a ladder with identical, forced, closed GOPs in FFmpeg, then verify alignment with ffprobe and MP4Box so it can't regress.
๐ฆ Code: github.com/USER/abr-keyframe-align (replace before publishing)
The bug
A clean ABR down-switch needs both renditions to have a keyframe (IDR) at the same media time. If 1080p has a keyframe at 4.0s and 720p's nearest is at 4.3s, the player can't splice cleanly and you get a micro-freeze. Your QoE dashboard says zero rebuffers because, technically, nothing rebuffered. The seam just collided.
Versions: ffmpeg 7.x, MP4Box (GPAC), tested with x264 and NVENC.
1. Pick one cadence for the whole ladder
Keyframe interval should equal framerate ร segment_seconds. Lock it once and apply it to every rung.
# 30 fps source, 2-second segments => keyint = 60
FPS=30
SEG=2
KEYINT=$(( FPS * SEG )) # 60
echo "keyint=$KEYINT"
The rungs vary in resolution and bitrate. The keyframe cadence does not vary. That's the whole trick.
2. The naive command that causes the bug
This looks fine and ships broken:
# DON'T do this for an ABR ladder
ffmpeg -i source.mov -c:v libx264 -b:v 5000k -hls_time 2 out.m3u8
Two problems:
- Scene-cut detection inserts keyframes at content-driven moments, not your grid, and different rungs make different decisions.
min-keyintdefaults low, so the encoder can drop in early keyframes whenever it likes.
3. Force fixed, closed GOPs (x264)
# encode-1080p.sh
ffmpeg -i source.mov \
-c:v libx264 -profile:v high -b:v 5000k -maxrate 5350k -bufsize 7500k \
-x264opts "keyint=60:min-keyint=60:no-scenecut" \
-force_key_frames "expr:gte(t,n_forced*2)" \
-c:a aac -b:a 128k \
-hls_time 2 -hls_playlist_type vod -hls_segment_type fmp4 \
-hls_segment_filename "1080p_%03d.m4s" 1080p.m3u8
What each part does:
| Flag | Job |
|---|---|
keyint=60 |
GOP size = 60 frames (2s at 30fps) |
min-keyint=60 |
min == max => closed, fixed GOPs, no early keyframes |
no-scenecut |
stop the encoder adding content-driven keyframes that break alignment |
-force_key_frames expr:gte(t,n_forced*2) |
belt-and-suspenders: a keyframe every 2s by time, robust to VFR |
-hls_time 2 |
segment duration matches the GOP boundary |
Run the same keyframe flags for every rung, changing only resolution/bitrate:
# encode-720p.sh: identical keyframe settings, different bitrate/scale
ffmpeg -i source.mov \
-vf "scale=-2:720" \
-c:v libx264 -profile:v high -b:v 2800k -maxrate 3000k -bufsize 4200k \
-x264opts "keyint=60:min-keyint=60:no-scenecut" \
-force_key_frames "expr:gte(t,n_forced*2)" \
-c:a aac -b:a 128k \
-hls_time 2 -hls_playlist_type vod -hls_segment_type fmp4 \
-hls_segment_filename "720p_%03d.m4s" 720p.m3u8
4. NVENC is different
The x264opts string doesn't apply to hardware encoders. Use -g and keep the force_key_frames expression:
# GPU path
ffmpeg -hwaccel cuda -i source.mov \
-c:v h264_nvenc -preset p5 -b:v 5000k \
-g 60 -force_key_frames "expr:gte(t,n_forced*2)" \
-hls_time 2 -hls_segment_type fmp4 1080p_gpu.m3u8
๐ก Tip: NVENC honors -g for GOP size and -force_key_frames for exact placement. There's no no-scenecut knob the same way, so the forced-keyframe expression does the heavy lifting.
5. Verify with ffprobe
Don't trust it, check it. List keyframe timestamps for each rendition:
# print PTS of every keyframe in a rendition
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time,flags \
-of csv=print_section=0 1080p.m3u8 \
| awk -F',' '$2 ~ /K/ { print $1 }'
Realistic output for both 1080p and 720p should match:
0.000000
2.000000
4.000000
6.000000
8.000000
If 720p shows 0, 2, 3.4, 5.4, ... instead, scene-cut detection leaked in. Re-check your flags.
6. Verify with MP4Box and wire it into CI
GPAC's MP4Box has a keyframe-alignment check across renditions. Wrap it in a script that fails the build on misalignment:
# ci/check-alignment.sh
set -euo pipefail
# extract keyframe times for two rungs, diff them
kf () {
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time,flags -of csv=print_section=0 "$1" \
| awk -F',' '$2 ~ /K/ { printf "%.3f\n", $1 }'
}
diff <(kf 1080p.m3u8) <(kf 720p.m3u8) && echo "Keyframes aligned โ
" \
|| { echo "Keyframes MISALIGNED โ"; exit 1; }
$ ./ci/check-alignment.sh
Keyframes aligned โ
โ ๏ธ Note: an encoder upgrade can silently change a default. A CI check is the only thing that stops a future ladder change from quietly reintroducing the hitch.
7. Audio is the other half of alignment
Video keyframes are only half the story. If your segments are muxed (audio plus video together), the audio frame boundaries also have to fall on segment edges, or you reintroduce the same splice problem on the audio track.
Two things bite here. First, AAC has encoder priming (a few hundred samples of silence at the start) that shifts the audio timeline relative to video. Second, audio frames are a fixed sample count (1024 samples for AAC-LC), so a segment duration that isn't a clean multiple of the audio frame duration will never line up perfectly.
The robust fix is to keep audio in a separate rendition (demuxed) so the player can align it independently, which is also what most HLS ladders do anyway:
# audio-only rendition, separate from video
ffmpeg -i source.mov -vn \
-c:a aac -b:a 128k -ar 48000 \
-hls_time 2 -hls_playlist_type vod -hls_segment_type fmp4 \
-hls_segment_filename "audio_%03d.m4s" audio.m3u8
# confirm the audio segment count matches the video segment count
$ grep -c '\.m4s' audio.m3u8 1080p.m3u8
audio.m3u8:31
1080p.m3u8:31
๐ก Tip: matching segment counts across audio and every video rung is a fast sanity check. A mismatch means a duration or framerate assumption is off somewhere.
The cost (be honest)
Forcing keyframes and disabling scene-cut detection sacrifices a little compression efficiency, since you're inserting IDR frames the encoder would rather skip. For ABR delivery that trade is almost always worth it: a marginally bigger file that switches cleanly beats a smaller one that stutters every time the network dips.
What's next
- Align audio segment duration with video so muxed segments don't drift; watch encoder/priming delay.
- If you run per-title / content-aware encoding, keep the forced keyframe cadence even when bitrates vary per title.
- Add the alignment check to your transcoding pipeline's test suite, not just local runs.
Next time a switch hitches and the dashboard says all-clear, dump the keyframe times for two rungs before you touch the CDN.
Comments
No comments yet. Start the discussion.