Deep dive
How it works
In: a frame sequence with scene-change and cost estimates, keyint, B depth and reference count · Out: a frame type (I, P, B) and decode order for every frame · State: the reference buffer and the lookahead window · Cost: planning O(n); the cost is in the encoder's extra reference searches
- Frame types: I (intra only), P (predicts from earlier decoded frames), B (predicts from earlier and later frames; may or may not be a reference itself).
- A closed GOP begins with an IDR: it flushes the reference buffer, and no later frame refers to anything before it. An open GOP begins with a non-IDR I; leading B frames that precede it in display order may reference the previous GOP (HEVC: CRA with RASL pictures; H.264: recovery-point SEI). Open GOPs save bits but cannot be cut or joined without dropping or re-encoding the leading frames.
- Anchors are placed every
miniframes (the mini-GOP). The encoder codes the next anchor first, then the B frames between it and the previous anchor, so decode order differs from display order. - A hierarchical B pyramid codes the middle frame first with both anchors as references, then recurses; frames that fewer others depend on get a higher QP (x264
ipratio1.40 andpbratio1.30 are the QP factors I to P and P to B).
display: I0 b1 B2 b3 B4 b5 B6 b7 P8 mini = 8, levels: P8/I0=0, B4=1, B2 B6=2, b=3
decode: I0 P8 B4 B2 b1 b3 B6 b5 b7
refs: P8<-I0 B4<-I0,P8 B2<-I0,B4 b1<-I0,B2 (b = non-reference, droppable)
- Frame type decision runs in a lookahead: estimate intra vs inter cost per frame; a frame whose inter cost is not clearly below its intra cost is a scene cut and becomes a keyframe; B-adapt chooses how many B frames fit in each mini-GOP.
- A decoder needs
num_reorderframes of buffering: the largest number of frames decoded before a frame that are displayed after it.
Code
Plans a closed-GOP hierarchical-B structure in decode order and validates it: every reference is decoded first and never crosses an IDR.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind { Idr, P, BRef, B } // BRef = B used as a reference, B = disposable
#[derive(Clone, Debug)]
pub struct Frame {
pub poc: usize, // display index (picture order count)
pub kind: Kind,
pub refs: Vec<usize>, // POCs this frame predicts from
pub level: u8, // 0 = anchor, 1.. = depth in the B pyramid
}
/// Closed GOPs of at most `keyint` frames, anchors every `mini` frames,
/// B frames between them as a binary pyramid. Returns frames in DECODE order.
pub fn plan(n: usize, keyint: usize, mini: usize, nref: usize) -> Vec<Frame> {
assert!(keyint >= 1 && mini >= 1 && nref >= 1);
let mut out = Vec::with_capacity(n);
let mut start = 0;
while start < n {
let last = (start + keyint).min(n) - 1;
out.push(Frame { poc: start, kind: Kind::Idr, refs: vec![], level: 0 });
let mut anchors = vec![start];
let mut a = start;
while a < last {
let hi = (a + mini).min(last);
let refs = anchors.iter().rev().take(nref).copied().collect();
out.push(Frame { poc: hi, kind: Kind::P, refs, level: 0 });
fill(&mut out, a, hi, 1); // pyramid strictly between the two anchors
anchors.push(hi);
a = hi;
}
start = last + 1;
}
out
}
fn fill(out: &mut Vec<Frame>, lo: usize, hi: usize, level: u8) {
if hi - lo < 2 { return; }
let mid = (lo + hi) / 2;
let kind = if hi - lo > 2 { Kind::BRef } else { Kind::B }; // referenced only if it has children
out.push(Frame { poc: mid, kind, refs: vec![lo, hi], level });
fill(out, lo, mid, level + 1);
fill(out, mid, hi, level + 1);
}
/// Returns (reorder depth, longest reference distance in frames).
pub fn validate(dec: &[Frame], n: usize) -> (usize, usize) {
let mut decoded = vec![false; n];
let (mut cur_idr, mut max_dist) = (0, 0);
for f in dec {
if f.kind == Kind::Idr { cur_idr = f.poc; }
for &r in &f.refs {
assert!(decoded[r], "ref {r} of {} not decoded yet", f.poc);
assert!(r >= cur_idr, "ref {r} crosses IDR at {cur_idr}"); // closed GOP
max_dist = max_dist.max(r.abs_diff(f.poc));
}
assert!(!decoded[f.poc], "frame {} decoded twice", f.poc);
decoded[f.poc] = true;
}
assert!(decoded.iter().all(|&d| d), "missing frames");
let reorder = (0..dec.len())
.map(|i| dec[..i].iter().filter(|g| g.poc > dec[i].poc).count())
.max().unwrap_or(0);
(reorder, max_dist)
}
Run as tests: plan(9, 100, 4, 1) decodes as POC 0 4 2 1 3 8 6 5 7 and validates to reorder 2; plan(20, 10, 8, 2) shows IbBbBbBbPPIbBbBbBbPP with reorder 3 (log2 of 8) and two IDRs; mini = 1 gives reorder 0.
Complexity and constants
- Planning is O(n). The cost lives in the encoder: a B frame searches two reference lists, and each extra
refmultiplies motion-estimation work. - Reorder delay is
log2(mini)frames for a full pyramid (3 for mini 8), plus lookahead (x264rc-lookaheaddefault 40) held in memory as raw frames. - Decoded picture buffer: references plus reorder frames; at 4K 4:2:0 8-bit one frame is about 12 MB.
- Random access: seeking to time t decodes from the previous keyframe, about keyint/2 frames on average; scrubbing cost is proportional to keyint.
- Measured on one synthetic clip (20 s, 640x360, 30 fps noisy test pattern, x264 medium, CRF 23, scenecut off): keyframe 35 kB, P 10.7 kB, B 5.1 kB. Real footage differs; the order of the ratios is what carries over.
Efficiency
- Cost: the bound is bits per frame versus seekability. On one synthetic clip (20 s, 640x360, 30 fps, x264 medium, CRF 23, scenecut off; real footage differs) a keyframe was 35 kB, a P frame 10.7 kB and a B frame 5.1 kB. Seeking to time t decodes about keyint/2 frames on average.
- Levers:
- fewer keyframes — bframes 3: keyint 250 gave 3.99 MB, keyint 30 gave 4.71 MB (+18%), measured on the clip above — use the longest interval seek, segment and recovery needs allow
- B frames — bframes 0 gave 5.00 MB, bframes 3 gave 4.10 MB (-18%); bframes 8 with b-adapt 2 gave 3.90 MB for roughly half the encode speed (about 440 to 250 fps here) — depth beyond 3-4 rarely pays
- closed GOPs encoded as chunks — independent, so separate cores or machines, no quality loss at the seams — large files
- non-reference B frames at the bottom of the pyramid — can be dropped for cheap temporal scaling — scalable delivery
- budget scenecut keyframes — on the noisy clip they added 2 keyframes and 2.7% size; on real cuts they pay; for ABR use one forced keyframe list for all renditions
- Naive → standard → state of the art:
- all-intra · largest files, seek anywhere
- closed GOP, keyint 250, bframes 3 · 4.10 MB on the clip above
- open GOP, hierarchical B pyramid and lookahead · fewer bytes, but cutting needs care at the leading frames; zero B frames with intra refresh (low-latency) when latency beats about 18% bitrate
Implementation notes
- Streaming: segment = whole number of GOPs, each beginning with an IDR; use identical segment boundaries in every rung of the ladder so players can switch without re-sync. Common practice is 2 s or 4 s GOPs; fix them as frames (
keyint = fps * seconds,min-keyintequal, scenecut off) or force keyframe timestamps from one list for all rungs. - Container: with B frames, PTS differs from DTS; MP4 needs
cttsoffsets, and the first PTS may be non-zero (containers). - Hardware encoders expose GOP size and B-frame count, often with fixed pyramid depth (hardware acceleration).
- x264
b-pyramid normalkeeps one B level as references (not Blu-ray compliant);strictallows a pure pyramid. Withopen-gop, x264 closes GOPs via recovery points and needs B frames; x265 enables open GOP by default.
Pitfalls
- Open GOP in HLS/DASH: a segment starting on a CRA or non-IDR I can show corrupted leading frames after a switch or seek. Use IDR for segment starts.
- Scenecut keyframes shift GOP boundaries differently per rendition, breaking alignment. Disable it or use a forced-keyframe list.
- x264/x265
min-keyint: a scene cut closer than that to the last keyframe becomes a plain I or P frame, not an IDR, so segment starts can be missed. - Very long GOPs ("infinite") make seeking and error recovery slow: a lost reference corrupts frames until the next keyframe.
- Treating B depth as free: it adds latency (reorder + lookahead) and DPB memory, which matters for live and WebRTC.
- Cutting with stream copy (
-c copy) can only start at a keyframe, not at the requested frame.
Variants and alternatives
--intra-refresh).show_existing_frame.In production
- Streaming: x264 and x265 behind FFmpeg encode much of VOD and live; HTTP Live Streaming says any media segment containing H.264 video SHOULD contain an IDR (RFC 8216), so ABR packagers want segment-aligned keyframes.
- Real-time calls: senders use P-only streams and send a keyframe when the receiver reports picture loss (RFC 4585, PLI/FIR).
- Codecs: AV1 key frames and
show_existing_frameare defined in the AV1 specification (Sources).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| x264 | C | videolan/x264 | brew install x264 | — | — | — | — | --keyint --bframes --b-pyramid --open-gop --scenecut |
| x265 | C++ | multicoreware/x265_git | brew install x265 | — | — | — | — | open GOP default, --gop-lookahead |
| SVT-AV1 | C | AOMediaCodec/SVT-AV1 | brew install svt-av1 | — | — | — | — | hierarchical levels, --keyint, forward key frames |
| libaom | C | aom | brew install aom | — | — | — | — | AV1 reference encoder, altref/lag-in-frames |
| FFmpeg | C | FFmpeg/FFmpeg | brew install ffmpeg | — | — | — | — | -g, -bf, -force_key_frames; ffprobe -show_frames shows types |
Sources
- Thomas Wiegand, Gary J. Sullivan, Gisle Bjontegaard, Ajay Luthra, Overview of the H.264/AVC Video Coding Standard, IEEE TCSVT 2003. paper
- Gary J. Sullivan, Jens-Rainer Ohm, Woo-Jin Han, Thomas Wiegand, Overview of the High Efficiency Video Coding (HEVC) Standard, IEEE TCSVT 2012. paper
- ITU-T H.264 and H.265 (IDR, CRA, recovery point, POC, DPB). H.264 · H.265
- x264
encoder/slicetype.c(scenecut and B-adapt decisions) andx264 --fullhelp(defaults). code - x265 CLI documentation,
--open-gop,--keyint,--min-keyint. docs - AOMedia, AV1 Bitstream & Decoding Process Specification (show_existing_frame, key frames). spec