Skip to main content

oximedia_transcode/
abr_ladder.rs

1//! Adaptive bitrate ladder generation, per-rung settings, and ABR rules.
2//!
3//! Provides tools for generating HLS/DASH ABR ladders with per-rung codec
4//! settings and bandwidth-based selection rules.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// A single rung in an ABR ladder describing one quality level.
10#[derive(Debug, Clone, PartialEq)]
11pub struct AbrRungConfig {
12    /// Label for this rung (e.g., "1080p", "720p").
13    pub label: String,
14    /// Video width in pixels.
15    pub width: u32,
16    /// Video height in pixels.
17    pub height: u32,
18    /// Target video bitrate in bits per second.
19    pub video_bitrate_bps: u64,
20    /// Target audio bitrate in bits per second.
21    pub audio_bitrate_bps: u64,
22    /// Frame rate numerator.
23    pub fps_num: u32,
24    /// Frame rate denominator.
25    pub fps_den: u32,
26    /// Constant Rate Factor (lower = better quality).
27    pub crf: Option<u8>,
28    /// Codec profile (e.g., "high", "main", "baseline").
29    pub profile: Option<String>,
30    /// Maximum buffer size in bits.
31    pub bufsize_bits: Option<u64>,
32}
33
34impl AbrRungConfig {
35    /// Creates a new ABR rung configuration.
36    #[must_use]
37    pub fn new(
38        label: impl Into<String>,
39        width: u32,
40        height: u32,
41        video_bitrate_bps: u64,
42        audio_bitrate_bps: u64,
43    ) -> Self {
44        Self {
45            label: label.into(),
46            width,
47            height,
48            video_bitrate_bps,
49            audio_bitrate_bps,
50            fps_num: 30,
51            fps_den: 1,
52            crf: None,
53            profile: None,
54            bufsize_bits: None,
55        }
56    }
57
58    /// Sets the frame rate for this rung.
59    #[must_use]
60    pub fn with_fps(mut self, num: u32, den: u32) -> Self {
61        self.fps_num = num;
62        self.fps_den = den;
63        self
64    }
65
66    /// Sets the CRF value for quality-based encoding.
67    #[must_use]
68    pub fn with_crf(mut self, crf: u8) -> Self {
69        self.crf = Some(crf);
70        self
71    }
72
73    /// Sets the codec profile.
74    #[must_use]
75    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
76        self.profile = Some(profile.into());
77        self
78    }
79
80    /// Sets the buffer size in bits (typically 2x the video bitrate).
81    #[must_use]
82    pub fn with_bufsize(mut self, bufsize_bits: u64) -> Self {
83        self.bufsize_bits = Some(bufsize_bits);
84        self
85    }
86
87    /// Returns the total bitrate (video + audio) in bits per second.
88    #[must_use]
89    pub fn total_bitrate_bps(&self) -> u64 {
90        self.video_bitrate_bps + self.audio_bitrate_bps
91    }
92
93    /// Returns the frame rate as a floating point value.
94    #[must_use]
95    pub fn fps_f64(&self) -> f64 {
96        f64::from(self.fps_num) / f64::from(self.fps_den)
97    }
98
99    /// Returns the pixel count for this rung.
100    #[must_use]
101    pub fn pixel_count(&self) -> u64 {
102        u64::from(self.width) * u64::from(self.height)
103    }
104}
105
106/// Strategy for selecting the appropriate ABR rung.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum LadderSelectionStrategy {
109    /// Select the highest quality rung that fits within available bandwidth.
110    BandwidthFit,
111    /// Select the rung whose resolution best matches the display size.
112    ResolutionMatch,
113    /// Conservatively stay one rung below the maximum fitting rung.
114    Conservative,
115    /// Aggressively pick the highest rung within 150% of available bandwidth.
116    Aggressive,
117}
118
119/// Rule for switching between rungs.
120#[derive(Debug, Clone)]
121pub struct SwitchRule {
122    /// Minimum bandwidth required to switch up (in bits per second).
123    pub switch_up_bandwidth_bps: u64,
124    /// Bandwidth threshold to switch down (in bits per second).
125    pub switch_down_bandwidth_bps: u64,
126    /// Minimum consecutive measurements before switching up.
127    pub switch_up_samples: u32,
128    /// Whether to allow switching up more than one rung at a time.
129    pub allow_multi_rung_up: bool,
130}
131
132impl SwitchRule {
133    /// Creates a new switch rule.
134    #[must_use]
135    pub fn new(switch_up_bps: u64, switch_down_bps: u64) -> Self {
136        Self {
137            switch_up_bandwidth_bps: switch_up_bps,
138            switch_down_bandwidth_bps: switch_down_bps,
139            switch_up_samples: 3,
140            allow_multi_rung_up: false,
141        }
142    }
143
144    /// Sets the number of consecutive measurements required to switch up.
145    #[must_use]
146    pub fn with_switch_up_samples(mut self, samples: u32) -> Self {
147        self.switch_up_samples = samples;
148        self
149    }
150}
151
152/// A complete ABR ladder with multiple quality rungs.
153#[derive(Debug, Clone)]
154pub struct AbrLadderConfig {
155    /// All rungs sorted from lowest to highest quality.
156    pub rungs: Vec<AbrRungConfig>,
157    /// Selection strategy.
158    pub strategy: LadderSelectionStrategy,
159    /// Switch rules between rungs.
160    pub switch_rules: Vec<SwitchRule>,
161    /// Segment duration in seconds.
162    pub segment_duration_secs: f64,
163    /// Target codec for all rungs.
164    pub codec: String,
165}
166
167impl AbrLadderConfig {
168    /// Creates a new ABR ladder configuration.
169    #[must_use]
170    pub fn new(codec: impl Into<String>) -> Self {
171        Self {
172            rungs: Vec::new(),
173            strategy: LadderSelectionStrategy::BandwidthFit,
174            switch_rules: Vec::new(),
175            segment_duration_secs: 6.0,
176            codec: codec.into(),
177        }
178    }
179
180    /// Adds a rung to the ladder.
181    #[must_use]
182    pub fn add_rung(mut self, rung: AbrRungConfig) -> Self {
183        self.rungs.push(rung);
184        self.rungs.sort_by_key(|r| r.video_bitrate_bps);
185        self
186    }
187
188    /// Sets the selection strategy.
189    #[must_use]
190    pub fn with_strategy(mut self, strategy: LadderSelectionStrategy) -> Self {
191        self.strategy = strategy;
192        self
193    }
194
195    /// Sets the segment duration.
196    #[must_use]
197    pub fn with_segment_duration(mut self, secs: f64) -> Self {
198        self.segment_duration_secs = secs;
199        self
200    }
201
202    /// Generates the standard Netflix-style HLS ladder for H.264.
203    #[must_use]
204    pub fn standard_hls_h264() -> Self {
205        Self::new("h264")
206            .add_rung(
207                AbrRungConfig::new("240p", 426, 240, 400_000, 64_000).with_profile("baseline"),
208            )
209            .add_rung(AbrRungConfig::new("360p", 640, 360, 800_000, 96_000).with_profile("main"))
210            .add_rung(AbrRungConfig::new("480p", 854, 480, 1_400_000, 128_000).with_profile("main"))
211            .add_rung(
212                AbrRungConfig::new("720p", 1280, 720, 2_800_000, 128_000).with_profile("high"),
213            )
214            .add_rung(
215                AbrRungConfig::new("1080p", 1920, 1080, 5_000_000, 192_000).with_profile("high"),
216            )
217            .add_rung(
218                AbrRungConfig::new("4K", 3840, 2160, 15_000_000, 192_000).with_profile("high"),
219            )
220    }
221
222    /// Selects the best rung for the given available bandwidth.
223    #[must_use]
224    pub fn select_rung(&self, available_bps: u64) -> Option<&AbrRungConfig> {
225        match self.strategy {
226            LadderSelectionStrategy::BandwidthFit => self
227                .rungs
228                .iter()
229                .rfind(|r| r.total_bitrate_bps() <= available_bps),
230            LadderSelectionStrategy::Conservative => {
231                let fitting: Vec<&AbrRungConfig> = self
232                    .rungs
233                    .iter()
234                    .filter(|r| r.total_bitrate_bps() <= available_bps)
235                    .collect();
236                if fitting.len() > 1 {
237                    fitting.get(fitting.len() - 2).copied()
238                } else {
239                    fitting.into_iter().last()
240                }
241            }
242            LadderSelectionStrategy::Aggressive => self
243                .rungs
244                .iter()
245                .rfind(|r| r.total_bitrate_bps() <= available_bps * 3 / 2),
246            LadderSelectionStrategy::ResolutionMatch => {
247                // Default to bandwidth fit if no display size info
248                self.rungs
249                    .iter()
250                    .rfind(|r| r.total_bitrate_bps() <= available_bps)
251            }
252        }
253    }
254
255    /// Returns the number of rungs in the ladder.
256    #[must_use]
257    pub fn rung_count(&self) -> usize {
258        self.rungs.len()
259    }
260
261    /// Returns the lowest quality rung.
262    #[must_use]
263    pub fn lowest_rung(&self) -> Option<&AbrRungConfig> {
264        self.rungs.first()
265    }
266
267    /// Returns the highest quality rung.
268    #[must_use]
269    pub fn highest_rung(&self) -> Option<&AbrRungConfig> {
270        self.rungs.last()
271    }
272
273    /// Generates switch rules for all adjacent rung pairs.
274    pub fn generate_switch_rules(&mut self) {
275        self.switch_rules.clear();
276        for window in self.rungs.windows(2) {
277            let lower = &window[0];
278            let upper = &window[1];
279            // Switch up when bandwidth exceeds upper rung by 20%
280            let switch_up = upper.total_bitrate_bps() * 120 / 100;
281            // Switch down when bandwidth drops below lower rung
282            let switch_down = lower.total_bitrate_bps();
283            self.switch_rules
284                .push(SwitchRule::new(switch_up, switch_down));
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_rung_total_bitrate() {
295        let rung = AbrRungConfig::new("720p", 1280, 720, 2_800_000, 128_000);
296        assert_eq!(rung.total_bitrate_bps(), 2_928_000);
297    }
298
299    #[test]
300    fn test_rung_fps_f64() {
301        let rung = AbrRungConfig::new("1080p", 1920, 1080, 5_000_000, 192_000).with_fps(60, 1);
302        assert!((rung.fps_f64() - 60.0).abs() < f64::EPSILON);
303    }
304
305    #[test]
306    fn test_rung_pixel_count() {
307        let rung = AbrRungConfig::new("1080p", 1920, 1080, 5_000_000, 192_000);
308        assert_eq!(rung.pixel_count(), 1920 * 1080);
309    }
310
311    #[test]
312    fn test_rung_with_crf() {
313        let rung = AbrRungConfig::new("720p", 1280, 720, 2_800_000, 128_000).with_crf(23);
314        assert_eq!(rung.crf, Some(23));
315    }
316
317    #[test]
318    fn test_rung_with_profile() {
319        let rung = AbrRungConfig::new("1080p", 1920, 1080, 5_000_000, 192_000).with_profile("high");
320        assert_eq!(rung.profile.as_deref(), Some("high"));
321    }
322
323    #[test]
324    fn test_rung_with_bufsize() {
325        let rung = AbrRungConfig::new("480p", 854, 480, 1_400_000, 128_000).with_bufsize(2_800_000);
326        assert_eq!(rung.bufsize_bits, Some(2_800_000));
327    }
328
329    #[test]
330    fn test_ladder_rung_count() {
331        let ladder = AbrLadderConfig::standard_hls_h264();
332        assert_eq!(ladder.rung_count(), 6);
333    }
334
335    #[test]
336    fn test_ladder_sorted_by_bitrate() {
337        let ladder = AbrLadderConfig::standard_hls_h264();
338        let bitrates: Vec<u64> = ladder.rungs.iter().map(|r| r.video_bitrate_bps).collect();
339        let mut sorted = bitrates.clone();
340        sorted.sort_unstable();
341        assert_eq!(bitrates, sorted);
342    }
343
344    #[test]
345    fn test_select_rung_bandwidth_fit() {
346        let ladder = AbrLadderConfig::standard_hls_h264();
347        // 3 Mbps should select 720p (2.928 Mbps total)
348        let rung = ladder
349            .select_rung(3_000_000)
350            .expect("should succeed in test");
351        assert_eq!(rung.label, "720p");
352    }
353
354    #[test]
355    fn test_select_rung_conservative() {
356        let ladder = AbrLadderConfig::standard_hls_h264()
357            .with_strategy(LadderSelectionStrategy::Conservative);
358        let rung = ladder
359            .select_rung(3_000_000)
360            .expect("should succeed in test");
361        // Should be one below 720p = 480p
362        assert_eq!(rung.label, "480p");
363    }
364
365    #[test]
366    fn test_select_rung_no_fit() {
367        let ladder = AbrLadderConfig::standard_hls_h264();
368        // Very low bandwidth - no rung fits
369        let rung = ladder.select_rung(100_000);
370        assert!(rung.is_none());
371    }
372
373    #[test]
374    fn test_lowest_highest_rung() {
375        let ladder = AbrLadderConfig::standard_hls_h264();
376        assert_eq!(
377            ladder.lowest_rung().expect("should succeed in test").label,
378            "240p"
379        );
380        assert_eq!(
381            ladder.highest_rung().expect("should succeed in test").label,
382            "4K"
383        );
384    }
385
386    #[test]
387    fn test_generate_switch_rules() {
388        let mut ladder = AbrLadderConfig::standard_hls_h264();
389        ladder.generate_switch_rules();
390        // N rungs => N-1 switch rules
391        assert_eq!(ladder.switch_rules.len(), ladder.rung_count() - 1);
392    }
393
394    #[test]
395    fn test_switch_rule_new() {
396        let rule = SwitchRule::new(5_000_000, 2_000_000).with_switch_up_samples(5);
397        assert_eq!(rule.switch_up_bandwidth_bps, 5_000_000);
398        assert_eq!(rule.switch_down_bandwidth_bps, 2_000_000);
399        assert_eq!(rule.switch_up_samples, 5);
400    }
401
402    #[test]
403    fn test_ladder_segment_duration() {
404        let ladder = AbrLadderConfig::new("vp9").with_segment_duration(4.0);
405        assert!((ladder.segment_duration_secs - 4.0).abs() < f64::EPSILON);
406    }
407
408    #[test]
409    fn test_ladder_codec() {
410        let ladder = AbrLadderConfig::new("av1");
411        assert_eq!(ladder.codec, "av1");
412    }
413}