Skip to main content

ff_stream/
live_dash.rs

1//! Frame-push live DASH output.
2//!
3//! [`LiveDashOutput`] receives pre-decoded [`ff_format::VideoFrame`] / [`ff_format::AudioFrame`] values
4//! from the caller, encodes them with H.264/AAC, and muxes them into a DASH
5//! manifest (`manifest.mpd`) backed by `.m4s` segment files.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use ff_stream::{LiveDashOutput, StreamOutput};
11//! use std::time::Duration;
12//!
13//! let mut out = LiveDashOutput::new("/var/www/live")
14//!     .video(1280, 720, 30.0)
15//!     .audio(48000, 2)
16//!     .segment_duration(Duration::from_secs(4))
17//!     .build()?;
18//!
19//! // for each decoded frame:
20//! out.push_video(&video_frame)?;
21//! out.push_audio(&audio_frame)?;
22//!
23//! // when done:
24//! Box::new(out).finish()?;
25//! ```
26
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use ff_format::{AudioCodec, VideoCodec};
31
32use crate::error::StreamError;
33use crate::live_dash_inner::LiveDashInner;
34
35/// Live DASH output: receives frames and writes a `manifest.mpd` playlist.
36///
37/// Build with [`LiveDashOutput::new`], chain setter methods, then call
38/// [`build`](Self::build) to open the `FFmpeg` contexts. After `build()`:
39///
40/// - `push_video` and `push_audio` encode and
41///   mux frames in real time.
42/// - [`crate::StreamOutput::finish`] flushes all encoders and writes the DASH trailer.
43///
44/// The output directory is created automatically by `build()` if it does not exist.
45pub struct LiveDashOutput {
46    output_dir: PathBuf,
47    segment_duration: Duration,
48    video_codec: VideoCodec,
49    audio_codec: AudioCodec,
50    video_bitrate: u64,
51    audio_bitrate: u64,
52    video_width: Option<u32>,
53    video_height: Option<u32>,
54    fps: Option<f64>,
55    sample_rate: Option<u32>,
56    channels: Option<u32>,
57    inner: Option<LiveDashInner>,
58    finished: bool,
59}
60
61impl LiveDashOutput {
62    /// Create a new builder that writes DASH output to `output_dir`.
63    ///
64    /// Accepts any path-like value: `"/var/www/live"`, `Path::new(…)`, etc.
65    ///
66    /// # Example
67    ///
68    /// ```ignore
69    /// use ff_stream::LiveDashOutput;
70    ///
71    /// let out = LiveDashOutput::new("/var/www/live");
72    /// ```
73    #[must_use]
74    pub fn new(output_dir: impl AsRef<Path>) -> Self {
75        Self {
76            output_dir: output_dir.as_ref().to_path_buf(),
77            segment_duration: Duration::from_secs(4),
78            video_codec: VideoCodec::H264,
79            audio_codec: AudioCodec::Aac,
80            video_bitrate: 2_000_000,
81            audio_bitrate: 128_000,
82            video_width: None,
83            video_height: None,
84            fps: None,
85            sample_rate: None,
86            channels: None,
87            inner: None,
88            finished: false,
89        }
90    }
91
92    /// Set the target DASH segment duration.
93    ///
94    /// Default: 4 seconds.
95    #[must_use]
96    pub fn segment_duration(mut self, duration: Duration) -> Self {
97        self.segment_duration = duration;
98        self
99    }
100
101    /// Open all `FFmpeg` contexts and write the DASH header.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`StreamError::InvalidConfig`] when:
106    /// - `output_dir` is empty.
107    /// - [`video`](Self::video) was not called before `build`.
108    ///
109    /// Returns [`StreamError::Io`] when the output directory cannot be created.
110    /// Returns [`StreamError::Ffmpeg`] when any `FFmpeg` operation fails.
111    pub fn build(mut self) -> Result<Self, StreamError> {
112        if self.output_dir.as_os_str().is_empty() {
113            return Err(StreamError::InvalidConfig {
114                reason: "output_dir must not be empty".into(),
115            });
116        }
117
118        let (Some(width), Some(height), Some(fps)) =
119            (self.video_width, self.video_height, self.fps)
120        else {
121            return Err(StreamError::InvalidConfig {
122                reason: "video parameters not set; call .video(width, height, fps) before .build()"
123                    .into(),
124            });
125        };
126
127        std::fs::create_dir_all(&self.output_dir)?;
128
129        let output_dir = self
130            .output_dir
131            .to_str()
132            .ok_or_else(|| StreamError::InvalidConfig {
133                reason: "output_dir contains non-UTF-8 characters".into(),
134            })?
135            .to_owned();
136
137        #[allow(clippy::cast_possible_truncation)]
138        let fps_int = fps.round().max(1.0) as i32;
139        #[allow(clippy::cast_possible_truncation)]
140        let segment_secs = self.segment_duration.as_secs().max(1) as u32;
141
142        let audio_params = self.sample_rate.zip(self.channels).map(|(sr, nc)| {
143            (
144                sr.cast_signed(),
145                nc.cast_signed(),
146                self.audio_bitrate.cast_signed(),
147            )
148        });
149
150        let inner = LiveDashInner::open(
151            &output_dir,
152            segment_secs,
153            width.cast_signed(),
154            height.cast_signed(),
155            fps_int,
156            self.video_bitrate,
157            audio_params,
158        )?;
159
160        self.inner = Some(inner);
161        Ok(self)
162    }
163}
164
165impl_live_stream_setters!(LiveDashOutput, optional_audio);
166impl_frame_push_stream_output!(LiveDashOutput);
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn build_without_video_should_return_invalid_config() {
174        let result = LiveDashOutput::new("/tmp/live_dash_test_no_video").build();
175        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
176    }
177
178    #[test]
179    fn build_with_empty_output_dir_should_return_invalid_config() {
180        let result = LiveDashOutput::new("").video(1280, 720, 30.0).build();
181        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
182    }
183
184    #[test]
185    fn segment_duration_default_should_be_four_seconds() {
186        let out = LiveDashOutput::new("/tmp/x");
187        assert_eq!(out.segment_duration, Duration::from_secs(4));
188    }
189}