Skip to main content

viser_ladder/
lib.rs

1//! Bitrate ladder selection with crossover enforcement.
2//!
3//! Picks the best N rungs from a convex hull (Pareto frontier) using greedy
4//! VMAF-target selection, while enforcing resolution crossovers and bitrate/quality
5//! constraints. Also provides pre-built fixed ladders (Netflix, Apple HLS) for baseline
6//! comparison.
7//!
8//! Part of the `viser` video-encoding-optimizer workspace.
9
10mod fixed;
11
12pub use fixed::*;
13
14use serde::{Deserialize, Serialize};
15use viser_ffmpeg::Resolution;
16use viser_hull::{Hull, Point};
17
18/// One level in a bitrate ladder.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Rung {
21    /// The hull point selected for this rung.
22    #[serde(flatten)]
23    pub point: Point,
24    /// Rung number, with 0 being the lowest quality.
25    pub index: i32, // rung number (0 = lowest quality)
26}
27
28/// Ordered set of rungs from lowest to highest quality.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Ladder {
31    /// Rungs ordered by ascending bitrate.
32    pub rungs: Vec<Rung>,
33}
34
35/// Constraints and target count controlling ladder selection.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Opts {
38    /// Target number of rungs to select (e.g. 6).
39    pub num_rungs: i32, // target number of rungs (e.g., 6)
40    /// Minimum bitrate in kbps; candidates below this are dropped.
41    pub min_bitrate: f64, // minimum bitrate in kbps
42    /// Maximum bitrate in kbps; candidates above this (minus audio) are dropped.
43    pub max_bitrate: f64, // maximum bitrate in kbps
44    /// Minimum acceptable VMAF quality; candidates below this are dropped.
45    pub min_vmaf: f64, // minimum acceptable quality
46    /// Maximum VMAF quality target, capping the top of the target range.
47    pub max_vmaf: f64, // maximum quality target
48    /// Audio bitrate overhead (kbps) reserved within the delivery budget.
49    pub audio_bitrate_kbps: f64, // audio overhead in delivery budget
50}
51
52impl Default for Opts {
53    fn default() -> Self {
54        Self {
55            num_rungs: 6,
56            min_bitrate: 200.0,
57            max_bitrate: 8000.0,
58            min_vmaf: 40.0,
59            max_vmaf: 97.0,
60            audio_bitrate_kbps: 0.0,
61        }
62    }
63}
64
65/// Picks the best N rungs from the convex hull to form a bitrate ladder.
66pub fn select(h: &Hull, opts: &Opts) -> Ladder {
67    if h.points.is_empty() || opts.num_rungs <= 0 {
68        return Ladder { rungs: vec![] };
69    }
70
71    // Build crossover map
72    let crossover_min = build_crossover_map(h);
73
74    // Filter hull points by constraints + crossover enforcement
75    let mut candidates: Vec<Point> = Vec::new();
76    for p in &h.points {
77        if p.bitrate < opts.min_bitrate || p.bitrate > opts.max_bitrate - opts.audio_bitrate_kbps {
78            continue;
79        }
80        if p.vmaf < opts.min_vmaf {
81            continue;
82        }
83        if let Some(&min_br) = crossover_min.get(&p.resolution)
84            && p.bitrate < min_br
85        {
86            continue;
87        }
88        candidates.push(p.clone());
89    }
90
91    if candidates.is_empty() {
92        return Ladder { rungs: vec![] };
93    }
94
95    if candidates.len() <= opts.num_rungs as usize {
96        return to_ladder(candidates);
97    }
98
99    // Determine VMAF range from candidates
100    let min_q = candidates.first().map(|p| p.vmaf).unwrap_or(0.0);
101    let mut max_q = candidates.last().map(|p| p.vmaf).unwrap_or(100.0);
102    if opts.max_vmaf > 0.0 && max_q > opts.max_vmaf {
103        max_q = opts.max_vmaf;
104    }
105    let min_q = min_q.min(max_q);
106
107    // Generate evenly-spaced quality targets
108    let num = opts.num_rungs as usize;
109    let targets: Vec<f64> = if num == 1 {
110        vec![(min_q + max_q) / 2.0]
111    } else {
112        let step = (max_q - min_q) / (num - 1) as f64;
113        (0..num).map(|i| min_q + step * i as f64).collect()
114    };
115
116    // Greedy selection: for each target, find closest unused candidate
117    let mut used = vec![false; candidates.len()];
118    let mut selected = Vec::new();
119
120    for target in &targets {
121        let mut best_idx = None;
122        let mut best_dist = f64::MAX;
123
124        for (i, p) in candidates.iter().enumerate() {
125            if used[i] {
126                continue;
127            }
128            let dist = (p.vmaf - target).abs();
129            if dist < best_dist {
130                best_dist = dist;
131                best_idx = Some(i);
132            }
133        }
134
135        if let Some(idx) = best_idx {
136            used[idx] = true;
137            selected.push(candidates[idx].clone());
138        }
139    }
140
141    to_ladder(selected)
142}
143
144fn build_crossover_map(h: &Hull) -> std::collections::HashMap<Resolution, f64> {
145    let mut crossovers = std::collections::HashMap::new();
146    for co in h.crossovers() {
147        crossovers.insert(co.to, co.bitrate);
148    }
149    crossovers
150}
151
152fn to_ladder(mut points: Vec<Point>) -> Ladder {
153    points.sort_by(|a, b| a.bitrate.partial_cmp(&b.bitrate).unwrap());
154    let rungs =
155        points.into_iter().enumerate().map(|(i, p)| Rung { point: p, index: i as i32 }).collect();
156    Ladder { rungs }
157}
158
159impl Ladder {
160    /// Returns the (lowest, highest) bitrate in kbps, or `(0.0, 0.0)` if empty.
161    pub fn bitrate_range(&self) -> (f64, f64) {
162        if self.rungs.is_empty() {
163            return (0.0, 0.0);
164        }
165        (self.rungs.first().unwrap().point.bitrate, self.rungs.last().unwrap().point.bitrate)
166    }
167
168    /// Returns the (lowest, highest) VMAF quality, or `(0.0, 0.0)` if empty.
169    pub fn quality_range(&self) -> (f64, f64) {
170        if self.rungs.is_empty() {
171            return (0.0, 0.0);
172        }
173        (self.rungs.first().unwrap().point.vmaf, self.rungs.last().unwrap().point.vmaf)
174    }
175
176    /// Percent bitrate savings of the top rung versus a fixed top-rung bitrate (kbps).
177    ///
178    /// Returns 0.0 if the ladder is empty or the top rung is not cheaper.
179    pub fn savings(&self, fixed_bitrate: f64) -> f64 {
180        if self.rungs.is_empty() || fixed_bitrate <= 0.0 {
181            return 0.0;
182        }
183        let top = &self.rungs.last().unwrap().point;
184        if top.bitrate >= fixed_bitrate {
185            return 0.0;
186        }
187        (1.0 - top.bitrate / fixed_bitrate) * 100.0
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use viser_ffmpeg::{Codec, Resolution};
195    use viser_hull::{Hull, Point};
196
197    fn point(bitrate: f64, vmaf: f64) -> Point {
198        Point {
199            resolution: Resolution::new(1920, 1080),
200            codec: Codec::X264,
201            crf: 23,
202            bitrate,
203            vmaf,
204            psnr: 0.0,
205            ssim: 0.0,
206        }
207    }
208
209    fn hull_for(points: Vec<Point>) -> Hull {
210        viser_hull::compute_upper(&points)
211    }
212
213    // ── Property-based tests ──
214    #[cfg(test)]
215    mod proptests {
216        use super::*;
217        use proptest::prelude::*;
218
219        fn arb_point() -> impl Strategy<Value = Point> {
220            let res = prop_oneof![
221                Just(Resolution::new(640, 360)),
222                Just(Resolution::new(1280, 720)),
223                Just(Resolution::new(1920, 1080)),
224            ];
225            (0.0f64..10000.0f64, 0.0f64..100.0f64, res, 0i32..51i32).prop_map(
226                |(bitrate, vmaf, res, crf)| Point {
227                    resolution: res,
228                    codec: Codec::X264,
229                    crf,
230                    bitrate,
231                    vmaf,
232                    psnr: 0.0,
233                    ssim: 0.0,
234                },
235            )
236        }
237
238        fn arb_opts() -> impl Strategy<Value = Opts> {
239            (
240                1i32..12i32,
241                0.0f64..2000.0f64,
242                2000.0f64..20000.0f64,
243                0.0f64..60.0f64,
244                70.0f64..100.0f64,
245                0.0f64..500.0f64,
246            )
247                .prop_map(|(num_rungs, min_br, max_br, min_q, max_q, audio)| Opts {
248                    num_rungs,
249                    min_bitrate: min_br,
250                    max_bitrate: max_br.max(min_br + 100.0),
251                    min_vmaf: min_q,
252                    max_vmaf: max_q.max(min_q + 1.0),
253                    audio_bitrate_kbps: audio,
254                })
255        }
256
257        proptest! {
258            /// Invariant: ladder rungs are sorted by bitrate ascending.
259            #[test]
260            fn ladder_rungs_sorted_by_bitrate(
261                points in proptest::collection::vec(arb_point(), 0..60),
262                opts in arb_opts(),
263            ) {
264                let hull = viser_hull::compute_upper(&points);
265                let ladder = select(&hull, &opts);
266                for w in ladder.rungs.windows(2) {
267                    assert!(w[0].point.bitrate <= w[1].point.bitrate,
268                        "ladder not sorted: {} kbps before {} kbps",
269                        w[0].point.bitrate, w[1].point.bitrate);
270                }
271            }
272
273            /// Invariant: ladder never has more rungs than requested.
274            #[test]
275            fn ladder_rung_count_within_limit(
276                points in proptest::collection::vec(arb_point(), 0..60),
277                opts in arb_opts(),
278            ) {
279                let hull = viser_hull::compute_upper(&points);
280                let ladder = select(&hull, &opts);
281                assert!(ladder.rungs.len() <= opts.num_rungs as usize,
282                    "got {} rungs, asked for {}", ladder.rungs.len(), opts.num_rungs);
283            }
284
285            /// Invariant: all rungs are within the specified bitrate bounds.
286            #[test]
287            fn ladder_rungs_within_bitrate_bounds(
288                points in proptest::collection::vec(arb_point(), 0..60),
289                opts in arb_opts(),
290            ) {
291                let hull = viser_hull::compute_upper(&points);
292                let ladder = select(&hull, &opts);
293                let effective_max = opts.max_bitrate - opts.audio_bitrate_kbps;
294                for rung in &ladder.rungs {
295                    assert!(rung.point.bitrate >= opts.min_bitrate - 1e-9,
296                        "rung bitrate {:.1} below min {:.1}",
297                        rung.point.bitrate, opts.min_bitrate);
298                    assert!(rung.point.bitrate <= effective_max + 1e-9,
299                        "rung bitrate {:.1} above max {:.1} (effective after audio {:.1})",
300                        rung.point.bitrate, effective_max, opts.max_bitrate);
301                }
302            }
303
304            /// Invariant: all rungs are within VMAF bounds.
305            #[test]
306            fn ladder_rungs_within_vmaf_bounds(
307                points in proptest::collection::vec(arb_point(), 0..60),
308                opts in arb_opts(),
309            ) {
310                let hull = viser_hull::compute_upper(&points);
311                let ladder = select(&hull, &opts);
312                for rung in &ladder.rungs {
313                    assert!(rung.point.vmaf >= opts.min_vmaf - 1e-9,
314                        "rung vmaf {:.1} below min {:.1}", rung.point.vmaf, opts.min_vmaf);
315                }
316            }
317
318            /// Invariant: rung indices form a contiguous sequence 0..N-1.
319            #[test]
320            fn ladder_rung_indices_contiguous(
321                points in proptest::collection::vec(arb_point(), 0..60),
322                opts in arb_opts(),
323            ) {
324                let hull = viser_hull::compute_upper(&points);
325                let ladder = select(&hull, &opts);
326                let indices: Vec<i32> = ladder.rungs.iter().map(|r| r.index).collect();
327                let expected: Vec<i32> = (0..ladder.rungs.len() as i32).collect();
328                assert_eq!(indices, expected, "rung indices not contiguous");
329            }
330
331            /// Invariant: bitrate_range first <= last (after sorting the rungs).
332            #[test]
333            fn ladder_bitrate_range_ordered(
334                points in proptest::collection::vec(arb_point(), 1..60),
335            ) {
336                let mut sorted = points;
337                sorted.sort_by(|a, b| a.bitrate.partial_cmp(&b.bitrate).unwrap());
338                let ladder = Ladder {
339                    rungs: sorted.iter().enumerate().map(|(i, p)| Rung {
340                        point: p.clone(), index: i as i32
341                    }).collect()
342                };
343                let (lo, hi) = ladder.bitrate_range();
344                assert!(lo <= hi, "bitrate range out of order: {lo} > {hi}");
345            }
346            #[test]
347            fn ladder_savings_bounded(
348                points in proptest::collection::vec(arb_point(), 1..60),
349                fixed_bitrate in 1000.0f64..20000.0f64,
350            ) {
351                let ladder = Ladder {
352                    rungs: points.iter().enumerate().map(|(i, p)| Rung {
353                        point: p.clone(), index: i as i32
354                    }).collect()
355                };
356                let s = ladder.savings(fixed_bitrate);
357                assert!(s >= 0.0, "savings negative: {s}");
358                assert!(s <= 100.0, "savings > 100%: {s}");
359            }
360
361        }
362    }
363
364    #[test]
365    fn test_select_empty_hull() {
366        let h = Hull { points: vec![] };
367        let ladder = select(&h, &Opts::default());
368        assert!(ladder.rungs.is_empty());
369    }
370
371    #[test]
372    fn test_select_zero_rungs() {
373        let h = hull_for(vec![point(500.0, 80.0), point(1000.0, 90.0)]);
374        let ladder = select(&h, &Opts { num_rungs: 0, ..Opts::default() });
375        assert!(ladder.rungs.is_empty());
376    }
377
378    #[test]
379    fn test_select_fewer_candidates_than_rungs() {
380        let h = hull_for(vec![point(500.0, 80.0), point(1000.0, 90.0)]);
381        let ladder = select(&h, &Opts { num_rungs: 6, ..Opts::default() });
382        assert!(!ladder.rungs.is_empty());
383        assert!(ladder.rungs.len() <= 2);
384    }
385
386    #[test]
387    fn test_select_filters_outside_bitrate_range() {
388        let h = hull_for(vec![
389            point(100.0, 50.0),
390            point(500.0, 80.0),
391            point(1000.0, 90.0),
392            point(10000.0, 98.0),
393        ]);
394        let opts =
395            Opts { num_rungs: 4, min_bitrate: 200.0, max_bitrate: 5000.0, ..Opts::default() };
396        let ladder = select(&h, &opts);
397        for rung in &ladder.rungs {
398            assert!(rung.point.bitrate >= 200.0);
399            assert!(rung.point.bitrate <= 5000.0);
400        }
401    }
402
403    #[test]
404    fn test_select_filters_below_min_vmaf() {
405        let h = hull_for(vec![
406            point(200.0, 30.0),
407            point(500.0, 60.0),
408            point(1000.0, 85.0),
409            point(2000.0, 95.0),
410        ]);
411        let opts = Opts { num_rungs: 4, min_vmaf: 50.0, ..Opts::default() };
412        let ladder = select(&h, &opts);
413        for rung in &ladder.rungs {
414            assert!(rung.point.vmaf >= 50.0);
415        }
416    }
417
418    #[test]
419    fn test_select_output_sorted() {
420        let h = hull_for(vec![
421            point(500.0, 70.0),
422            point(1000.0, 85.0),
423            point(2000.0, 93.0),
424            point(5000.0, 98.0),
425        ]);
426        let ladder = select(&h, &Opts::default());
427        assert!(ladder.rungs.windows(2).all(|w| w[0].point.bitrate <= w[1].point.bitrate));
428    }
429
430    #[test]
431    fn test_select_rung_indices() {
432        let h = hull_for(vec![point(500.0, 70.0), point(1000.0, 85.0), point(2000.0, 93.0)]);
433        let ladder = select(&h, &Opts { num_rungs: 3, ..Opts::default() });
434        for (i, rung) in ladder.rungs.iter().enumerate() {
435            assert_eq!(rung.index as usize, i);
436        }
437    }
438
439    #[test]
440    fn test_bitrate_range_empty() {
441        let ladder = Ladder { rungs: vec![] };
442        assert_eq!(ladder.bitrate_range(), (0.0, 0.0));
443    }
444
445    #[test]
446    fn test_bitrate_range() {
447        let rungs = vec![
448            Rung { point: point(500.0, 70.0), index: 0 },
449            Rung { point: point(2000.0, 93.0), index: 1 },
450        ];
451        let ladder = Ladder { rungs };
452        assert_eq!(ladder.bitrate_range(), (500.0, 2000.0));
453    }
454
455    #[test]
456    fn test_quality_range_empty() {
457        let ladder = Ladder { rungs: vec![] };
458        assert_eq!(ladder.quality_range(), (0.0, 0.0));
459    }
460
461    #[test]
462    fn test_quality_range() {
463        let rungs = vec![
464            Rung { point: point(500.0, 70.0), index: 0 },
465            Rung { point: point(2000.0, 93.0), index: 1 },
466        ];
467        let ladder = Ladder { rungs };
468        assert_eq!(ladder.quality_range(), (70.0, 93.0));
469    }
470
471    #[test]
472    fn test_savings_empty() {
473        let ladder = Ladder { rungs: vec![] };
474        assert_eq!(ladder.savings(8000.0), 0.0);
475    }
476
477    #[test]
478    fn test_savings_zero_fixed() {
479        let rungs = vec![Rung { point: point(2000.0, 93.0), index: 0 }];
480        let ladder = Ladder { rungs };
481        assert_eq!(ladder.savings(0.0), 0.0);
482    }
483
484    #[test]
485    fn test_savings_no_savings() {
486        let rungs = vec![Rung { point: point(8000.0, 93.0), index: 0 }];
487        let ladder = Ladder { rungs };
488        assert_eq!(ladder.savings(8000.0), 0.0);
489    }
490
491    #[test]
492    fn test_savings_calculated() {
493        let rungs = vec![Rung { point: point(4000.0, 93.0), index: 0 }];
494        let ladder = Ladder { rungs };
495        let s = ladder.savings(8000.0);
496        assert!((s - 50.0).abs() < 1e-9);
497    }
498
499    #[test]
500    fn test_netflix_old_ladder() {
501        let ladder = netflix_old();
502        assert_eq!(ladder.name, "Netflix Fixed (2015)");
503        assert_eq!(ladder.rungs.len(), 10);
504        assert!((ladder.total_bitrate() - 20170.0).abs() < 1e-9);
505        assert!((ladder.top_bitrate() - 5800.0).abs() < 1e-9);
506    }
507
508    #[test]
509    fn test_apple_hls_ladder() {
510        let ladder = apple_hls();
511        assert_eq!(ladder.name, "Apple HLS (2024)");
512        assert_eq!(ladder.rungs.len(), 9);
513        assert!((ladder.total_bitrate() - 25640.0).abs() < 1e-9);
514        assert!((ladder.top_bitrate() - 7800.0).abs() < 1e-9);
515    }
516
517    #[test]
518    fn test_select_respects_max_vmaf() {
519        let h = hull_for(vec![
520            point(500.0, 70.0),
521            point(1000.0, 85.0),
522            point(2000.0, 90.0),
523            point(3000.0, 93.0),
524            point(5000.0, 98.0),
525        ]);
526        let opts = Opts { num_rungs: 3, max_vmaf: 90.0, ..Opts::default() };
527        let ladder = select(&h, &opts);
528        // max_vmaf caps quality target range so targets are [70, 80, 90]
529        // without max_vmaf, targets would reach higher, changing selection
530        assert!(!ladder.rungs.is_empty());
531        // The highest vmaf candidate closest to target=90.0 is 90.0 itself
532        assert!(ladder.rungs.last().unwrap().point.vmaf <= 90.0 + 1e-9);
533    }
534
535    #[test]
536    fn test_opts_default() {
537        let opts = Opts::default();
538        assert_eq!(opts.num_rungs, 6);
539        assert!((opts.min_bitrate - 200.0).abs() < 1e-9);
540        assert!((opts.max_bitrate - 8000.0).abs() < 1e-9);
541        assert!((opts.min_vmaf - 40.0).abs() < 1e-9);
542        assert!((opts.max_vmaf - 97.0).abs() < 1e-9);
543    }
544}