Skip to main content

ff_stream/
abr.rs

1//! Adaptive bitrate (ABR) ladder for multi-rendition HLS / DASH output.
2//!
3//! This module provides [`AbrLadder`] and [`Rendition`]. An `AbrLadder` holds
4//! an ordered list of [`Rendition`]s (resolution + bitrate pairs) and produces
5//! multi-variant HLS or multi-representation DASH output from a single input
6//! file in one call.
7
8use std::fmt::Write as _;
9use std::time::Duration;
10
11use crate::error::StreamError;
12
13/// A single resolution/bitrate rendition in an ABR ladder.
14///
15/// Each `Rendition` describes one quality level that the player can switch
16/// between based on available bandwidth.
17///
18/// # Examples
19///
20/// ```
21/// use ff_stream::Rendition;
22///
23/// let r = Rendition::new(1280, 720, 3_000_000);
24/// assert_eq!(r.width, 1280);
25/// assert_eq!(r.bitrate, 3_000_000);
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Rendition {
29    /// Output width in pixels.
30    pub width: u32,
31    /// Output height in pixels.
32    pub height: u32,
33    /// Target bitrate in bits per second.
34    pub bitrate: u64,
35}
36
37impl Rendition {
38    /// Create a rendition with the given width, height, and bitrate.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use ff_stream::Rendition;
44    ///
45    /// let hd = Rendition::new(1280, 720, 3_000_000);
46    /// let fhd = Rendition::new(1920, 1080, 6_000_000);
47    /// ```
48    #[must_use]
49    pub const fn new(width: u32, height: u32, bitrate: u64) -> Self {
50        Self {
51            width,
52            height,
53            bitrate,
54        }
55    }
56}
57
58/// Produces multi-rendition HLS or DASH output from a single input.
59///
60/// `AbrLadder` accepts one or more [`Rendition`]s and encodes the input at
61/// each quality level, writing the results into a directory structure that a
62/// player can consume with a single master playlist or MPD manifest.
63///
64/// # Examples
65///
66/// ```ignore
67/// use ff_stream::{AbrLadder, Rendition};
68///
69/// AbrLadder::new("source.mp4")
70///     .add_rendition(Rendition { width: 1920, height: 1080, bitrate: 6_000_000 })
71///     .add_rendition(Rendition { width: 1280, height:  720, bitrate: 3_000_000 })
72///     .hls("/var/www/hls")?;
73/// ```
74pub struct AbrLadder {
75    input_path: String,
76    renditions: Vec<Rendition>,
77}
78
79impl AbrLadder {
80    /// Create a new ladder for the given input file.
81    ///
82    /// No renditions are added at construction time; use
83    /// [`add_rendition`](Self::add_rendition) to populate the ladder before
84    /// calling [`hls`](Self::hls) or [`dash`](Self::dash).
85    #[must_use]
86    pub fn new(input_path: &str) -> Self {
87        Self {
88            input_path: input_path.to_owned(),
89            renditions: Vec::new(),
90        }
91    }
92
93    /// Append a rendition to the ladder.
94    ///
95    /// Renditions are encoded in the order they are added. By convention,
96    /// list them from highest to lowest quality so that the master playlist
97    /// presents them in that order.
98    #[must_use]
99    pub fn add_rendition(mut self, r: Rendition) -> Self {
100        self.renditions.push(r);
101        self
102    }
103
104    /// Write a multi-variant HLS output to `output_dir`.
105    ///
106    /// Each rendition is written to a numbered sub-directory
107    /// (`output_dir/0/`, `output_dir/1/`, …) containing its own
108    /// `playlist.m3u8`. A master playlist at `output_dir/master.m3u8`
109    /// references all renditions.
110    ///
111    /// # Errors
112    ///
113    /// - [`StreamError::InvalidConfig`] with `"no renditions added"` when the
114    ///   ladder is empty.
115    /// - Any [`StreamError`] returned by the underlying HLS muxer.
116    ///
117    /// # Examples
118    ///
119    /// ```ignore
120    /// use ff_stream::{AbrLadder, Rendition};
121    ///
122    /// // Empty ladder → error
123    /// assert!(AbrLadder::new("src.mp4").hls("/tmp/hls").is_err());
124    /// ```
125    pub fn hls(self, output_dir: &str) -> Result<(), StreamError> {
126        if self.renditions.is_empty() {
127            return Err(StreamError::InvalidConfig {
128                reason: "no renditions added".into(),
129            });
130        }
131        for (i, rendition) in self.renditions.iter().enumerate() {
132            let subdir = format!("{output_dir}/{i}");
133            crate::hls::HlsOutput::new(&subdir)
134                .input(&self.input_path)
135                .segment_duration(Duration::from_secs(6))
136                .bitrate(rendition.bitrate)
137                .video_size(rendition.width, rendition.height)
138                .build()?
139                .write()?;
140        }
141        let mut content = String::from("#EXTM3U\n");
142        for (i, rendition) in self.renditions.iter().enumerate() {
143            let _ = write!(
144                content,
145                "#EXT-X-STREAM-INF:BANDWIDTH={},RESOLUTION={}x{}\n{i}/playlist.m3u8\n",
146                rendition.bitrate, rendition.width, rendition.height
147            );
148        }
149        std::fs::write(format!("{output_dir}/master.m3u8"), content.as_bytes())?;
150        Ok(())
151    }
152
153    /// Write a multi-representation DASH output to `output_dir`.
154    ///
155    /// Each rendition is written to a numbered sub-directory
156    /// (`output_dir/0/`, `output_dir/1/`, …) containing its own
157    /// `manifest.mpd` and segments.
158    ///
159    /// # Errors
160    ///
161    /// - [`StreamError::InvalidConfig`] with `"no renditions added"` when the
162    ///   ladder is empty.
163    /// - Any [`StreamError`] returned by the underlying DASH muxer.
164    ///
165    /// # Examples
166    ///
167    /// ```ignore
168    /// use ff_stream::{AbrLadder, Rendition};
169    ///
170    /// // Empty ladder → error
171    /// assert!(AbrLadder::new("src.mp4").dash("/tmp/dash").is_err());
172    /// ```
173    pub fn dash(self, output_dir: &str) -> Result<(), StreamError> {
174        if self.renditions.is_empty() {
175            return Err(StreamError::InvalidConfig {
176                reason: "no renditions added".into(),
177            });
178        }
179        let rendition_params: Vec<(i64, i32, i32)> = self
180            .renditions
181            .iter()
182            .map(|r| {
183                (
184                    r.bitrate.cast_signed(),
185                    r.width.cast_signed(),
186                    r.height.cast_signed(),
187                )
188            })
189            .collect();
190        crate::dash_inner::write_dash_abr(&self.input_path, output_dir, 4.0, &rendition_params)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn rendition_should_store_all_fields() {
200        let r = Rendition {
201            width: 1920,
202            height: 1080,
203            bitrate: 6_000_000,
204        };
205        assert_eq!(r.width, 1920);
206        assert_eq!(r.height, 1080);
207        assert_eq!(r.bitrate, 6_000_000);
208    }
209
210    #[test]
211    fn rendition_should_be_equal_when_fields_match() {
212        let a = Rendition {
213            width: 854,
214            height: 480,
215            bitrate: 1_500_000,
216        };
217        let b = Rendition {
218            width: 854,
219            height: 480,
220            bitrate: 1_500_000,
221        };
222        assert_eq!(a, b);
223    }
224
225    #[test]
226    fn rendition_should_not_be_equal_when_fields_differ() {
227        let a = Rendition {
228            width: 1280,
229            height: 720,
230            bitrate: 3_000_000,
231        };
232        let b = Rendition {
233            width: 1280,
234            height: 720,
235            bitrate: 2_000_000,
236        };
237        assert_ne!(a, b);
238    }
239
240    #[test]
241    fn rendition_should_implement_debug() {
242        let r = Rendition {
243            width: 640,
244            height: 360,
245            bitrate: 800_000,
246        };
247        let s = format!("{r:?}");
248        assert!(s.contains("640"));
249        assert!(s.contains("360"));
250        assert!(s.contains("800000"));
251    }
252
253    #[test]
254    fn rendition_should_be_copyable() {
255        let original = Rendition {
256            width: 1280,
257            height: 720,
258            bitrate: 3_000_000,
259        };
260        let copy = original;
261        assert_eq!(copy.width, original.width);
262        assert_eq!(copy.height, original.height);
263        assert_eq!(copy.bitrate, original.bitrate);
264    }
265
266    #[test]
267    fn new_should_store_input_path() {
268        let ladder = AbrLadder::new("/src/video.mp4");
269        assert_eq!(ladder.input_path, "/src/video.mp4");
270    }
271
272    #[test]
273    fn add_rendition_should_store_rendition() {
274        let ladder = AbrLadder::new("/src/video.mp4").add_rendition(Rendition {
275            width: 1280,
276            height: 720,
277            bitrate: 3_000_000,
278        });
279        assert_eq!(ladder.renditions.len(), 1);
280        assert_eq!(ladder.renditions[0].width, 1280);
281    }
282
283    #[test]
284    fn hls_with_no_renditions_should_return_invalid_config() {
285        let result = AbrLadder::new("/src/video.mp4").hls("/tmp/hls");
286        assert!(
287            matches!(result, Err(StreamError::InvalidConfig { reason }) if reason == "no renditions added")
288        );
289    }
290
291    #[test]
292    fn dash_with_no_renditions_should_return_invalid_config() {
293        let result = AbrLadder::new("/src/video.mp4").dash("/tmp/dash");
294        assert!(
295            matches!(result, Err(StreamError::InvalidConfig { reason }) if reason == "no renditions added")
296        );
297    }
298}