Skip to main content

ff_stream/
live_abr.rs

1//! Multi-rendition ABR ladder for live frame-push output.
2//!
3//! [`LiveAbrLadder`] receives pre-decoded [`VideoFrame`] / [`AudioFrame`] values
4//! from the caller and fans them out to multiple encoders — one per rendition —
5//! each with its own resolution and bitrate. After [`StreamOutput::finish`], it
6//! writes a master playlist (`master.m3u8` for HLS, `manifest.mpd` for DASH).
7//!
8//! Each rendition is encoded and muxed independently by a [`LiveHlsOutput`] or
9//! [`LiveDashOutput`] instance stored inside the ladder. The input frame is
10//! passed to every rendition's `push_video` without pre-scaling; each inner
11//! encoder uses its own `SwsContext` to scale from the source resolution to the
12//! rendition's target dimensions.
13//!
14//! # Example
15//!
16//! ```ignore
17//! use ff_stream::{LiveAbrFormat, LiveAbrLadder, AbrRendition, StreamOutput};
18//! use std::time::Duration;
19//!
20//! let mut ladder = LiveAbrLadder::new("/var/www/live")
21//!     .add_rendition(AbrRendition {
22//!         width: 1920, height: 1080,
23//!         video_bitrate: 4_000_000, audio_bitrate: 192_000, name: None,
24//!     })
25//!     .add_rendition(AbrRendition {
26//!         width: 1280, height: 720,
27//!         video_bitrate: 2_000_000, audio_bitrate: 128_000, name: None,
28//!     })
29//!     .fps(30.0)
30//!     .audio(48000, 2)
31//!     .segment_duration(Duration::from_secs(6))
32//!     .build()?;
33//!
34//! // for each decoded frame:
35//! ladder.push_video(&video_frame)?;
36//! ladder.push_audio(&audio_frame)?;
37//!
38//! // when done:
39//! Box::new(ladder).finish()?;  // also writes master.m3u8
40//! ```
41
42use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45use ff_format::{AudioFrame, VideoCodec, VideoFrame};
46
47use crate::error::StreamError;
48use crate::live_dash::LiveDashOutput;
49use crate::live_hls::LiveHlsOutput;
50use crate::output::StreamOutput;
51
52// ============================================================================
53// AbrRendition
54// ============================================================================
55
56/// One resolution/bitrate tier in an ABR ladder.
57///
58/// Each rendition becomes an independent encoder stream. The output files are
59/// placed in a subdirectory named after the rendition (see [`dir_name`](Self::dir_name)).
60pub struct AbrRendition {
61    /// Target video width in pixels.
62    pub width: u32,
63    /// Target video height in pixels.
64    pub height: u32,
65    /// Target video encoder bitrate in bits per second.
66    pub video_bitrate: u64,
67    /// Target audio encoder bitrate in bits per second.
68    pub audio_bitrate: u64,
69    /// Optional subdirectory name. Defaults to `"{width}x{height}"`.
70    pub name: Option<String>,
71}
72
73impl AbrRendition {
74    /// Returns the subdirectory name for this rendition.
75    ///
76    /// Uses [`name`](Self::name) if set; otherwise `"{width}x{height}"`.
77    ///
78    /// # Example
79    ///
80    /// ```
81    /// use ff_stream::AbrRendition;
82    ///
83    /// let r = AbrRendition { width: 1920, height: 1080,
84    ///     video_bitrate: 4_000_000, audio_bitrate: 192_000, name: None };
85    /// assert_eq!(r.dir_name(), "1920x1080");
86    ///
87    /// let r2 = AbrRendition { width: 1280, height: 720,
88    ///     video_bitrate: 2_000_000, audio_bitrate: 128_000,
89    ///     name: Some("720p".into()) };
90    /// assert_eq!(r2.dir_name(), "720p");
91    /// ```
92    #[must_use]
93    pub fn dir_name(&self) -> String {
94        self.name
95            .clone()
96            .unwrap_or_else(|| format!("{}x{}", self.width, self.height))
97    }
98}
99
100// ============================================================================
101// LiveAbrFormat
102// ============================================================================
103
104/// Output container format for the ABR ladder.
105pub enum LiveAbrFormat {
106    /// Segmented HLS (`.ts` + `index.m3u8`). Writes `master.m3u8` on finish.
107    Hls,
108    /// Segmented DASH (`.m4s` + per-rendition `manifest.mpd`). Writes a
109    /// top-level `manifest.mpd` on finish.
110    Dash,
111}
112
113// ============================================================================
114// LiveAbrLadder — safe builder + StreamOutput impl
115// ============================================================================
116
117/// Live ABR ladder: fans frames to multiple encoders at different resolutions.
118///
119/// Build with [`LiveAbrLadder::new`], add renditions, configure encoding
120/// parameters, then call [`build`](Self::build). After that:
121///
122/// - [`push_video`](Self::push_video) and [`push_audio`](Self::push_audio)
123///   forward frames to all renditions (each scales internally via its own
124///   `SwsContext`).
125/// - [`StreamOutput::finish`] flushes all encoders and writes the master
126///   playlist.
127///
128/// All rendition subdirectories are created by `build()` if they do not exist.
129pub struct LiveAbrLadder {
130    output_dir: PathBuf,
131    renditions: Vec<AbrRendition>,
132    format: LiveAbrFormat,
133    segment_duration: Duration,
134    playlist_size: u32,
135    video_codec: VideoCodec,
136    fps: Option<f64>,
137    sample_rate: Option<u32>,
138    channels: Option<u32>,
139    /// Populated by `build()`. Empty before that.
140    outputs: Vec<Box<dyn StreamOutput>>,
141    finished: bool,
142}
143
144impl LiveAbrLadder {
145    /// Create a new builder that writes the ABR ladder to `output_dir`.
146    ///
147    /// Accepts any path-like value: `"/var/www/live"`, `Path::new(…)`, etc.
148    ///
149    /// # Example
150    ///
151    /// ```ignore
152    /// use ff_stream::LiveAbrLadder;
153    ///
154    /// let ladder = LiveAbrLadder::new("/var/www/live");
155    /// ```
156    #[must_use]
157    pub fn new(output_dir: impl AsRef<Path>) -> Self {
158        Self {
159            output_dir: output_dir.as_ref().to_path_buf(),
160            renditions: Vec::new(),
161            format: LiveAbrFormat::Hls,
162            segment_duration: Duration::from_secs(6),
163            playlist_size: 5,
164            video_codec: VideoCodec::H264,
165            fps: None,
166            sample_rate: None,
167            channels: None,
168            outputs: Vec::new(),
169            finished: false,
170        }
171    }
172
173    /// Add a rendition to the ladder.
174    ///
175    /// At least one rendition is required; [`build`](Self::build) returns
176    /// [`StreamError::InvalidConfig`] when the list is empty.
177    #[must_use]
178    pub fn add_rendition(mut self, rendition: AbrRendition) -> Self {
179        self.renditions.push(rendition);
180        self
181    }
182
183    /// Set the output container format.
184    ///
185    /// Default: [`LiveAbrFormat::Hls`].
186    #[must_use]
187    pub fn format(mut self, format: LiveAbrFormat) -> Self {
188        self.format = format;
189        self
190    }
191
192    /// Set the frame rate used for all renditions.
193    ///
194    /// This method **must** be called before [`build`](Self::build).
195    #[must_use]
196    pub fn fps(mut self, fps: f64) -> Self {
197        self.fps = Some(fps);
198        self
199    }
200
201    /// Enable audio output with the given sample rate and channel count.
202    ///
203    /// If not called, audio is disabled for all renditions.
204    #[must_use]
205    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
206        self.sample_rate = Some(sample_rate);
207        self.channels = Some(channels);
208        self
209    }
210
211    /// Set the target segment duration for all renditions.
212    ///
213    /// Default: 6 seconds.
214    #[must_use]
215    pub fn segment_duration(mut self, duration: Duration) -> Self {
216        self.segment_duration = duration;
217        self
218    }
219
220    /// Set the sliding-window playlist size (HLS only).
221    ///
222    /// Default: 5.
223    #[must_use]
224    pub fn playlist_size(mut self, size: u32) -> Self {
225        self.playlist_size = size;
226        self
227    }
228
229    /// Set the video codec for all renditions.
230    ///
231    /// Default: [`VideoCodec::H264`].
232    #[must_use]
233    pub fn video_codec(mut self, codec: VideoCodec) -> Self {
234        self.video_codec = codec;
235        self
236    }
237
238    /// Open all per-rendition `FFmpeg` contexts.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`StreamError::InvalidConfig`] when:
243    /// - `output_dir` is empty.
244    /// - No renditions have been added.
245    /// - [`fps`](Self::fps) was not called.
246    ///
247    /// Returns [`StreamError::Io`] when a rendition subdirectory cannot be
248    /// created. Returns [`StreamError::Ffmpeg`] when any `FFmpeg` operation
249    /// fails.
250    pub fn build(mut self) -> Result<Self, StreamError> {
251        if self.output_dir.as_os_str().is_empty() {
252            return Err(StreamError::InvalidConfig {
253                reason: "output_dir must not be empty".into(),
254            });
255        }
256
257        if self.renditions.is_empty() {
258            return Err(StreamError::InvalidConfig {
259                reason: "at least one rendition is required; call .add_rendition() before .build()"
260                    .into(),
261            });
262        }
263
264        let fps = self.fps.ok_or_else(|| StreamError::InvalidConfig {
265            reason: "fps not set; call .fps(value) before .build()".into(),
266        })?;
267
268        std::fs::create_dir_all(&self.output_dir)?;
269
270        let mut outputs: Vec<Box<dyn StreamOutput>> = Vec::with_capacity(self.renditions.len());
271
272        for rendition in &self.renditions {
273            let rendition_dir = self.output_dir.join(rendition.dir_name());
274
275            let output: Box<dyn StreamOutput> = match self.format {
276                LiveAbrFormat::Hls => {
277                    let mut builder = LiveHlsOutput::new(&rendition_dir)
278                        .video(rendition.width, rendition.height, fps)
279                        .video_bitrate(rendition.video_bitrate)
280                        .audio_bitrate(rendition.audio_bitrate)
281                        .segment_duration(self.segment_duration)
282                        .playlist_size(self.playlist_size)
283                        .video_codec(self.video_codec);
284
285                    if let (Some(sr), Some(ch)) = (self.sample_rate, self.channels) {
286                        builder = builder.audio(sr, ch);
287                    }
288
289                    Box::new(builder.build()?)
290                }
291                LiveAbrFormat::Dash => {
292                    let mut builder = LiveDashOutput::new(&rendition_dir)
293                        .video(rendition.width, rendition.height, fps)
294                        .video_bitrate(rendition.video_bitrate)
295                        .audio_bitrate(rendition.audio_bitrate)
296                        .segment_duration(self.segment_duration)
297                        .video_codec(self.video_codec);
298
299                    if let (Some(sr), Some(ch)) = (self.sample_rate, self.channels) {
300                        builder = builder.audio(sr, ch);
301                    }
302
303                    Box::new(builder.build()?)
304                }
305            };
306
307            outputs.push(output);
308        }
309
310        self.outputs = outputs;
311        Ok(self)
312    }
313}
314
315// ============================================================================
316// StreamOutput impl
317// ============================================================================
318
319impl StreamOutput for LiveAbrLadder {
320    fn push_video(&mut self, frame: &VideoFrame) -> Result<(), StreamError> {
321        if self.finished {
322            return Err(StreamError::InvalidConfig {
323                reason: "push_video called after finish()".into(),
324            });
325        }
326        if self.outputs.is_empty() {
327            return Err(StreamError::InvalidConfig {
328                reason: "push_video called before build()".into(),
329            });
330        }
331        for output in &mut self.outputs {
332            output.push_video(frame)?;
333        }
334        Ok(())
335    }
336
337    fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), StreamError> {
338        if self.finished {
339            return Err(StreamError::InvalidConfig {
340                reason: "push_audio called after finish()".into(),
341            });
342        }
343        if self.outputs.is_empty() {
344            return Err(StreamError::InvalidConfig {
345                reason: "push_audio called before build()".into(),
346            });
347        }
348        for output in &mut self.outputs {
349            output.push_audio(frame)?;
350        }
351        Ok(())
352    }
353
354    fn finish(mut self: Box<Self>) -> Result<(), StreamError> {
355        if self.finished {
356            return Ok(());
357        }
358        self.finished = true;
359
360        let outputs = std::mem::take(&mut self.outputs);
361        for output in outputs {
362            output.finish()?;
363        }
364
365        match self.format {
366            LiveAbrFormat::Hls => {
367                write_hls_master(&self.output_dir, &self.renditions)?;
368            }
369            LiveAbrFormat::Dash => {
370                write_dash_manifest(&self.output_dir, &self.renditions)?;
371            }
372        }
373
374        log::info!(
375            "live_abr finished output_dir={} renditions={}",
376            self.output_dir.display(),
377            self.renditions.len()
378        );
379        Ok(())
380    }
381}
382
383// ============================================================================
384// Playlist writers
385// ============================================================================
386
387/// Write `master.m3u8` listing all rendition variant streams.
388fn write_hls_master(output_dir: &Path, renditions: &[AbrRendition]) -> Result<(), StreamError> {
389    use std::fmt::Write as _;
390
391    let mut content = String::from("#EXTM3U\n#EXT-X-VERSION:3\n");
392    for r in renditions {
393        let bandwidth = r.video_bitrate + r.audio_bitrate;
394        let dir = r.dir_name();
395        let _ = write!(
396            content,
397            "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={}x{}\n{dir}/index.m3u8\n",
398            r.width, r.height,
399        );
400    }
401
402    let master_path = output_dir.join("master.m3u8");
403    std::fs::write(&master_path, content)?;
404    log::info!(
405        "live_abr wrote master playlist path={}",
406        master_path.display()
407    );
408    Ok(())
409}
410
411/// Write a top-level `manifest.mpd` referencing all rendition subdirectories.
412fn write_dash_manifest(output_dir: &Path, renditions: &[AbrRendition]) -> Result<(), StreamError> {
413    use std::fmt::Write as _;
414
415    let mut representations = String::new();
416    for r in renditions {
417        let bandwidth = r.video_bitrate + r.audio_bitrate;
418        let dir = r.dir_name();
419        let _ = write!(
420            representations,
421            "      <Representation bandwidth=\"{bandwidth}\" width=\"{}\" height=\"{}\">\
422\n        <BaseURL>{dir}/</BaseURL>\n      </Representation>\n",
423            r.width, r.height,
424        );
425    }
426
427    let content = format!(
428        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
429<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\" type=\"dynamic\"\
430 profiles=\"urn:mpeg:dash:profile:isoff-live:2011\">\n\
431  <Period>\n\
432    <AdaptationSet mimeType=\"video/mp4\" segmentAlignment=\"true\">\n\
433{representations}\
434    </AdaptationSet>\n\
435  </Period>\n\
436</MPD>\n"
437    );
438
439    let manifest_path = output_dir.join("manifest.mpd");
440    std::fs::write(&manifest_path, content)?;
441    log::info!(
442        "live_abr wrote dash manifest path={}",
443        manifest_path.display()
444    );
445    Ok(())
446}
447
448// ============================================================================
449// Unit tests
450// ============================================================================
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn build_with_empty_output_dir_should_return_invalid_config() {
458        let result = LiveAbrLadder::new("")
459            .add_rendition(AbrRendition {
460                width: 1280,
461                height: 720,
462                video_bitrate: 2_000_000,
463                audio_bitrate: 128_000,
464                name: None,
465            })
466            .fps(30.0)
467            .build();
468        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
469    }
470
471    #[test]
472    fn build_with_no_renditions_should_return_invalid_config() {
473        let result = LiveAbrLadder::new("/tmp/live_abr_test_no_renditions")
474            .fps(30.0)
475            .build();
476        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
477    }
478
479    #[test]
480    fn build_without_fps_should_return_invalid_config() {
481        let result = LiveAbrLadder::new("/tmp/live_abr_test_no_fps")
482            .add_rendition(AbrRendition {
483                width: 1280,
484                height: 720,
485                video_bitrate: 2_000_000,
486                audio_bitrate: 128_000,
487                name: None,
488            })
489            .build();
490        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
491    }
492
493    #[test]
494    fn segment_duration_default_should_be_six_seconds() {
495        let ladder = LiveAbrLadder::new("/tmp/x");
496        assert_eq!(ladder.segment_duration, Duration::from_secs(6));
497    }
498
499    #[test]
500    fn playlist_size_default_should_be_five() {
501        let ladder = LiveAbrLadder::new("/tmp/x");
502        assert_eq!(ladder.playlist_size, 5);
503    }
504
505    #[test]
506    fn abr_rendition_dir_name_default_should_use_resolution() {
507        let r = AbrRendition {
508            width: 1920,
509            height: 1080,
510            video_bitrate: 4_000_000,
511            audio_bitrate: 192_000,
512            name: None,
513        };
514        assert_eq!(r.dir_name(), "1920x1080");
515    }
516
517    #[test]
518    fn abr_rendition_dir_name_custom_should_use_name() {
519        let r = AbrRendition {
520            width: 1280,
521            height: 720,
522            video_bitrate: 2_000_000,
523            audio_bitrate: 128_000,
524            name: Some("720p".into()),
525        };
526        assert_eq!(r.dir_name(), "720p");
527    }
528}