Home › Audio & video › Streaming & packaging
algorithm medium

Video transcoding ladder

Problem A service that accepts uploaded or recorded video must play it on phones, laptops and TVs over slow and fast links. It needs to turn one source file into several versions that players can switch between.

Turn one source into resolution x bitrate x codec renditions chosen from measured rate-quality points, cut on aligned keyframes so players can switch mid-stream.

hull O(n log n) in n probe encodes; encode work about the sum of rung pixels x frames; parallel across chunks and rungs
transcodingbitrate-ladderper-titleconvex-hullhlsdashcmafthumbnails
Overview

Links range from 300 kbit/s to 100 Mbit/s and screens from phones to TVs, so one file serves nobody well. A ladder is a set of renditions, each a (resolution, bitrate, codec) rung; the player picks per segment. The clever part is choosing rungs: encode probes at many resolutions and bitrates, score them, and keep only the upper convex hull of the rate-quality plot, because under it some other resolution is better at the same cost. Easy content gets a cheaper ladder than action.

Key facts

Use when

  • Social uploads, VOD catalogues, lecture libraries, camera and telemedicine archives, recorded game streams.

Avoid when

  • Live calls and interactive streams: encode once per layer on the sender (simulcast), no probing time.
  • Tiny audiences: one 720p H.264 file suffices.

Deep dive

How it works

Parts

  • Analyzer — probes the source (size, fps, HDR, audio, shot boundaries).
  • Probe encoder — encodes a grid of (height, bitrate) points.
  • Quality scorer — VMAF or PSNR per probe against the scaled source.
  • Ladder selector — hull plus spacing rules (code below).
  • Encoder workers — chunked parallel encode with closed GOPs.
  • Packager — fMP4 segments and HLS/DASH manifests.
  • Thumbnailer — poster, sprite sheet, image variants.

Operations

  1. Decode and analyze; normalize odd input (interlaced, variable frame rate, rotation).
  2. Probe a grid, for example 5 heights x 3-4 bitrates, fast presets; measure kbps and score each.
  3. Compute the upper convex hull of (kbps, quality) over all probes. A rendition is only worth shipping if it is on it.
  4. Pick rungs along the hull: minimum quality step (a few VMAF points, tuned by viewing tests), minimum bitrate ratio between neighbours (a design choice; 1.5x here), a cap at the quality where more bits are invisible, and a maximum rung count.
  5. Final encode of each rung with identical forced keyframes (below) and a maxrate cap so declared BANDWIDTH is honest.
  6. Package: 2-6 s segments, an init segment per rendition, a manifest with BANDWIDTH, RESOLUTION, CODECS.
  7. Audio: one or two renditions (for example AAC 64 and 128 kbit/s); cheap, not on the hull.
  8. Images: a poster frame, a scrub sprite sheet, resized stills in WebP or AVIF.
source --> analyze --> probe grid (h x kbps) --> score --> hull --> rungs
                                                                      |
          +--------------- chunks x rungs on N workers <--------------+
          v
   fMP4 segments (IDR-aligned) --> HLS/DASH manifest --> object store --> CDN
   poster / sprite / image variants -----------------------^

Fixed vs per-title vs per-shot. A fixed ladder (for example Apple's example table: 416x234 at 145 kbit/s up to 1080p at 7.8 Mbit/s H.264) is a safe default and needs no probes. Per-title (Netflix, 2015) runs the hull per title, so an animated title can reach 1080p at a bitrate where a sports match is still at 540p. Per-shot (Netflix Dynamic Optimizer, 2018) builds a hull per shot and picks, for each target, the per-shot points of equal slope (a Lagrangian trade-off), so bits go where quality per bit is highest; shots are stitched at keyframes. Typical use: fixed ladder first, per-title for popular items.

Keyframes and segments. Every segment begins with an IDR from a closed GOP, at identical timestamps in all rungs, so the player can switch at any segment. Apple's spec: IDRs every 2 s, segments nominally 6 s, each starting with an IDR. Disable per-rung scene-cut keyframes (or force the same cuts everywhere): they move boundaries.

Examples across fields. Social video: cheap ladder at upload, upgrade after views accumulate. VOD: per-title or per-shot, several codecs. E-learning: slides and talking heads reach good quality at very low rates, so ladders are short. Video calls: sender-side layers (for example 180p, 360p, 720p) are a ladder without probes or segments. Surveillance and telemedicine archives: a low-rate proxy for browsing, the original kept untouched. Game-stream recordings: 60 fps, high motion, bitrate-hungry rungs.

Code

Rust, std only. The table is synthetic, shaped like real curves (not measured on any title). It computes the hull, picks rungs, and asserts their properties.

/// One probe encode: (height, average kbps, VMAF).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pt { pub height: u32, pub kbps: f64, pub vmaf: f64 }

fn cross(o: &Pt, a: &Pt, b: &Pt) -> f64 {
    (a.kbps - o.kbps) * (b.vmaf - o.vmaf) - (a.vmaf - o.vmaf) * (b.kbps - o.kbps)
}

/// Upper convex hull of rate-quality: best quality per rate, whichever resolution gets it.
pub fn upper_hull(pts: &[Pt]) -> Vec<Pt> {
    let mut p = pts.to_vec();
    p.sort_by(|a, b| a.kbps.total_cmp(&b.kbps).then(b.vmaf.total_cmp(&a.vmaf)));
    // 1. Pareto front: drop points that cost more and look no better.
    let mut front: Vec<Pt> = Vec::new();
    for q in p {
        if front.last().map_or(true, |l| q.vmaf > l.vmaf && q.kbps > l.kbps) { front.push(q); }
    }
    // 2. Monotone chain: pop while the last turn is not clockwise.
    let mut h: Vec<Pt> = Vec::new();
    for q in front {
        while h.len() >= 2 && cross(&h[h.len() - 2], &h[h.len() - 1], &q) >= 0.0 { h.pop(); }
        h.push(q);
    }
    h
}

/// Greedy rungs on the hull: >= `min_ratio` x bitrate and >= `min_dq` VMAF over the
/// previous rung; stop at `q_cap`; thin to `max_rungs`.
pub fn pick(hull: &[Pt], min_ratio: f64, min_dq: f64, q_cap: f64, max_rungs: usize) -> Vec<Pt> {
    let mut r = vec![hull[0]];
    for p in &hull[1..] {
        let l = r.last().unwrap();
        if p.kbps >= l.kbps * min_ratio && p.vmaf >= l.vmaf + min_dq { r.push(*p); }
        if p.vmaf >= q_cap { break; }
    }
    while r.len() > max_rungs { // drop the rung with the smallest quality gap to its neighbours
        let i = (1..r.len() - 1).min_by(|&a, &b|
            (r[a + 1].vmaf - r[a - 1].vmaf).total_cmp(&(r[b + 1].vmaf - r[b - 1].vmaf))).unwrap();
        r.remove(i);
    }
    r
}

fn main() {
    // SYNTHETIC numbers: diminishing returns, higher resolutions win only at higher rates.
    let t: [(u32, f64, f64); 18] = [
        (270, 150., 50.), (270, 300., 63.), (270, 600., 68.),
        (360, 300., 60.), (360, 600., 73.), (360, 1000., 77.),
        (540, 600., 68.), (540, 1000., 80.), (540, 1600., 85.),
        (720, 1200., 78.), (720, 2000., 87.), (720, 3000., 90.),
        (1080, 2500., 84.), (1080, 4000., 92.), (1080, 6000., 95.), (1080, 8000., 96.5),
        (720, 1500., 83.), (540, 800., 75.),
    ];
    let pts: Vec<Pt> = t.iter().map(|&(height, kbps, vmaf)| Pt { height, kbps, vmaf }).collect();
    let hull = upper_hull(&pts);
    let rungs = pick(&hull, 1.5, 3.0, 95.0, 6);

    // convex, and nothing beats the hull
    let slope = |a: &Pt, b: &Pt| (b.vmaf - a.vmaf) / (b.kbps - a.kbps);
    assert!(hull.windows(3).all(|w| slope(&w[0], &w[1]) > slope(&w[1], &w[2])));
    assert!(hull.iter().all(|h| !pts.iter().any(|p| p.kbps <= h.kbps && p.vmaf > h.vmaf)));
    // rungs: on the hull, monotone, spaced, bounded
    assert!(!rungs.is_empty() && rungs.len() <= 6 && rungs.iter().all(|r| hull.contains(r)));
    assert!(rungs.windows(2).all(|w| w[1].kbps >= 1.5 * w[0].kbps && w[1].vmaf >= w[0].vmaf + 3.0));
    assert_eq!(pick(&hull, 1.5, 3.0, 95.0, 3).len(), 3);
}

Compiled and run, asserts pass: the 18 probes give a 10-point hull and six rungs, from 270p at 150 kbit/s (VMAF 50) to 1080p at 6000 kbit/s (95); dominated probes such as 360p at 300 and 720p at 1200 drop out.

Complexity and constants

  • Hull: O(n log n) for a few dozen probes; microseconds, not timed. The cost is the encoder.
  • Encode work per rung is roughly pixels x frames at a fixed preset; 16:9 rungs of 1080/720/540/360/270 lines sum to about 1.9x one 1080p encode (arithmetic, assuming linear scaling).
  • Probes add work: a 5 x 4 grid is 20 extra encodes of the title (faster preset or short clips cut this); per-shot repeats it per shot.
  • Declared BANDWIDTH must match the measured peak (Apple: within 10 % for VOD, peak at most 200 % of average).

Efficiency

  • Cost: per title, one decode plus probe and final encodes, bound by CPU (or encoder ASIC) time; output is duration x sum of rung bitrates (2000 s with the six rungs above: about 2.8 GB, arithmetic). Viewer egress scales with what is played, not stored.
  • Levers:
    • Per-title or per-shot ladder instead of fixed — fewer bits at equal quality (Netflix; magnitude not quoted here) — when content varies widely.
    • HEVC instead of H.264 — Apple's tables give 4.5 vs 6 Mbit/s at 1080p and 2.4 vs 3 at 720p, 20-25 % less (Apple HLS spec) — where devices allow; costs encode time and licensing; AV1 saves more at far higher encode cost (no figure quoted).
    • Cheap ladder first, upgrade by views — encode spend only where views earn it — long-tail catalogues.
    • Chunked parallel encode — wall time about the longest chunk — long sources.
    • One decode shared by all rungs — saves repeated decode and scale work — every ladder.
  • Naive → standard → state of the art:
    • One H.264 file · one rung, wrong for most viewers
    • Fixed 6-9 rung H.264 ladder, constrained VBR · about N times the 1080p encode
    • Per-shot hull + multi-codec, hot titles re-encoded harder · several times that, repaid by views

Implementation notes

  • FFmpeg: -g 60 -keyint_min 60 -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*2)" for 30 fps with 2 s keyframes in every rung (-sc_threshold 0 disables libx264 scene cuts; other encoders spell this differently). This produced identical keyframe times (0.066, 2.066, 4.066 s, ...) in two rungs on this machine (FFmpeg 7.1.1).
  • HLS: -f hls -hls_time 6 -hls_segment_type fmp4 -hls_flags independent_segments -var_stream_map "v:0,agroup:aud v:1,agroup:aud a:0,agroup:aud,name:audio" -master_pl_name master.m3u8. Audio goes in its own group; sharing one audio stream between variants fails with "Same elementary stream found more than once".
  • DASH muxer: -seg_duration, -adaptation_sets "id=0,streams=v id=1,streams=a". One set of CMAF fMP4 segments can serve HLS and DASH; only manifests differ.
  • Poster: -vf "thumbnail=150,scale=640:-2" -frames:v 1 poster.webp (most representative of 150 frames). Sprite: -vf "fps=1/2,scale=160:-2,tile=6x2" -frames:v 1 sprite.jpg, one request instead of many. Both ran here.
  • Keep the VMAF model and display size fixed across probes.
  • Rate control: constrained VBR, for example -maxrate 1.5x and -bufsize 2x the target (a common choice, not a standard); keep at least some H.264 rungs for compatibility, as Apple advises.

Pitfalls

  • Per-rung scene-cut keyframes: boundaries differ, switching hitches. Disable or force.
  • Open GOPs at segment starts: the segment cannot be decoded alone.
  • Hull from too few probes: kinks look like crossovers; probe more densely near the knees.
  • Mean-VMAF targets hide short bad scenes; check a low percentile.
  • Declared BANDWIDTH below real peaks: players overestimate and rebuffer.
  • Encoding rungs above the source's own quality wastes storage; cap at the source.

Variants and alternatives

Fixed ladderhuge volume, homogeneous content, no time to probe.
Per-title / per-shotpopular, long or varied content.
Just-in-time packagingkeep one mezzanine, package on demand; saves storage, costs latency.
Simulcast / scalable codingreal-time use, see simulcast-svc.

In production

  • Netflix runs per-title and per-shot ladders on VMAF; Apple publishes a fixed example ladder; FFmpeg, Shaka Packager and Bento4 implement the encode and packaging stages.
  • Output sits in object storage behind a CDN.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
FFmpegCFFmpeg/FFmpegbrew install ffmpeg————encode, scale, HLS/DASH muxers, thumbnail/tile filters, libvmaf filter
x264Cvideolan/x264brew install x264————H.264 baseline-compatible rung; --keyint, --scenecut, --open-gop
x265C++videolan/x265brew install x265————HEVC rungs
SVT-AV1CAOMediaCodec/SVT-AV1brew install svt-av1————AV1 rungs with parallel encoder
Shaka PackagerC++shaka-project/shaka-packagerprebuilt binaries on GitHub releases————HLS/DASH packaging, CMAF, DRM
Bento4C++axiomatic-systems/Bento4brew install bento4————fMP4 fragmenting, HLS/DASH tools
libvmafCNetflix/vmafbrew install libvmaf————quality scoring for the probes

Sources

  • Apple, HTTP Live Streaming (HLS) authoring specification for Apple devices. spec
  • R. Pantos, W. May, HTTP Live Streaming, RFC 8216, 2017. RFC
  • A. Aaron, Z. Li, M. Manohara, J. De Cock, D. Ronca, Per-Title Encode Optimization, Netflix TechBlog, 2015 (blog; not fetchable from this environment).
  • I. Katsavounidis, Dynamic optimizer: a perceptual video encoding optimization framework, Netflix TechBlog, 2018 (blog; not fetchable from this environment).
  • ISO/IEC 23009-1 (DASH); ISO/IEC 23000-19 (CMAF).
  • FFmpeg formats documentation (hls, dash muxers). docs

Builds on

Used by

Systems (9):

Alternatives and related

Adaptive bitrate streamingA player on a link whose speed changes unpredictably must keep media playing without stalls while showing the best quality it can. For each next piece it must decide which of several pre-encoded versions to fetch.
adaptive streaming manifests · plannedHLS playlists and DASH MPDs: renditions, segment timelines, codecs strings
rate distortion optimization · plannedChoose modes minimizing D + lambda*R for coding decisions
video rate control · plannedCBR/VBR/CRF rate control with VBV buffer model and lookahead
media containers · plannedMP4/ISO BMFF, fragmented MP4/CMAF, Matroska and MPEG-TS mux/demux
simulcast svc · plannedSimulcast and scalable video coding layers for per-receiver quality
image codecs · plannedJPEG (DCT, Huffman), PNG, HEIF/AVIF decoding and encoding fundamentals
Object storageBackups, media, logs, data lakes and model weights need cheap, durable storage for large files. This guide answers when object storage is the right home and when a database, block storage or cache is needed instead.CDN and edgeUsers far from your servers see slow responses, and traffic spikes or attacks hit the origin directly. This guide answers whether and how to put a CDN or edge compute in front of a service.