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}