Skip to main content

ff_stream/
live_dash.rs

1//! Frame-push live DASH output.
2//!
3//! [`LiveDashOutput`] receives pre-decoded [`VideoFrame`] / [`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, AudioFrame, VideoCodec, VideoFrame};
31
32use crate::error::StreamError;
33use crate::live_dash_inner::LiveDashInner;
34use crate::output::StreamOutput;
35
36// ============================================================================
37// LiveDashOutput — safe builder + StreamOutput impl
38// ============================================================================
39
40/// Live DASH output: receives frames and writes a `manifest.mpd` playlist.
41///
42/// Build with [`LiveDashOutput::new`], chain setter methods, then call
43/// [`build`](Self::build) to open the `FFmpeg` contexts. After `build()`:
44///
45/// - [`push_video`](Self::push_video) and [`push_audio`](Self::push_audio) encode and
46///   mux frames in real time.
47/// - [`StreamOutput::finish`] flushes all encoders and writes the DASH trailer.
48///
49/// The output directory is created automatically by `build()` if it does not exist.
50pub struct LiveDashOutput {
51    output_dir: PathBuf,
52    segment_duration: Duration,
53    video_codec: VideoCodec,
54    audio_codec: AudioCodec,
55    video_bitrate: u64,
56    audio_bitrate: u64,
57    video_width: Option<u32>,
58    video_height: Option<u32>,
59    fps: Option<f64>,
60    sample_rate: Option<u32>,
61    channels: Option<u32>,
62    inner: Option<LiveDashInner>,
63    finished: bool,
64}
65
66impl LiveDashOutput {
67    /// Create a new builder that writes DASH output to `output_dir`.
68    ///
69    /// Accepts any path-like value: `"/var/www/live"`, `Path::new(…)`, etc.
70    ///
71    /// # Example
72    ///
73    /// ```ignore
74    /// use ff_stream::LiveDashOutput;
75    ///
76    /// let out = LiveDashOutput::new("/var/www/live");
77    /// ```
78    #[must_use]
79    pub fn new(output_dir: impl AsRef<Path>) -> Self {
80        Self {
81            output_dir: output_dir.as_ref().to_path_buf(),
82            segment_duration: Duration::from_secs(4),
83            video_codec: VideoCodec::H264,
84            audio_codec: AudioCodec::Aac,
85            video_bitrate: 2_000_000,
86            audio_bitrate: 128_000,
87            video_width: None,
88            video_height: None,
89            fps: None,
90            sample_rate: None,
91            channels: None,
92            inner: None,
93            finished: false,
94        }
95    }
96
97    /// Set the video encoding parameters.
98    ///
99    /// This method **must** be called before [`build`](Self::build).
100    #[must_use]
101    pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
102        self.video_width = Some(width);
103        self.video_height = Some(height);
104        self.fps = Some(fps);
105        self
106    }
107
108    /// Enable audio output with the given sample rate and channel count.
109    ///
110    /// If this method is not called, audio is disabled.
111    #[must_use]
112    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
113        self.sample_rate = Some(sample_rate);
114        self.channels = Some(channels);
115        self
116    }
117
118    /// Set the target DASH segment duration.
119    ///
120    /// Default: 4 seconds.
121    #[must_use]
122    pub fn segment_duration(mut self, duration: Duration) -> Self {
123        self.segment_duration = duration;
124        self
125    }
126
127    /// Set the video codec.
128    ///
129    /// Default: [`VideoCodec::H264`].
130    #[must_use]
131    pub fn video_codec(mut self, codec: VideoCodec) -> Self {
132        self.video_codec = codec;
133        self
134    }
135
136    /// Set the audio codec.
137    ///
138    /// Default: [`AudioCodec::Aac`].
139    #[must_use]
140    pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
141        self.audio_codec = codec;
142        self
143    }
144
145    /// Set the video encoder target bit rate in bits/s.
146    ///
147    /// Default: 2 000 000 (2 Mbit/s).
148    #[must_use]
149    pub fn video_bitrate(mut self, bitrate: u64) -> Self {
150        self.video_bitrate = bitrate;
151        self
152    }
153
154    /// Set the audio encoder target bit rate in bits/s.
155    ///
156    /// Default: 128 000 (128 kbit/s).
157    #[must_use]
158    pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
159        self.audio_bitrate = bitrate;
160        self
161    }
162
163    /// Open all `FFmpeg` contexts and write the DASH header.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`StreamError::InvalidConfig`] when:
168    /// - `output_dir` is empty.
169    /// - [`video`](Self::video) was not called before `build`.
170    ///
171    /// Returns [`StreamError::Io`] when the output directory cannot be created.
172    /// Returns [`StreamError::Ffmpeg`] when any `FFmpeg` operation fails.
173    pub fn build(mut self) -> Result<Self, StreamError> {
174        if self.output_dir.as_os_str().is_empty() {
175            return Err(StreamError::InvalidConfig {
176                reason: "output_dir must not be empty".into(),
177            });
178        }
179
180        let (Some(width), Some(height), Some(fps)) =
181            (self.video_width, self.video_height, self.fps)
182        else {
183            return Err(StreamError::InvalidConfig {
184                reason: "video parameters not set; call .video(width, height, fps) before .build()"
185                    .into(),
186            });
187        };
188
189        std::fs::create_dir_all(&self.output_dir)?;
190
191        let output_dir = self
192            .output_dir
193            .to_str()
194            .ok_or_else(|| StreamError::InvalidConfig {
195                reason: "output_dir contains non-UTF-8 characters".into(),
196            })?
197            .to_owned();
198
199        #[allow(clippy::cast_possible_truncation)]
200        let fps_int = fps.round().max(1.0) as i32;
201        #[allow(clippy::cast_possible_truncation)]
202        let segment_secs = self.segment_duration.as_secs().max(1) as u32;
203
204        let audio_params = self.sample_rate.zip(self.channels).map(|(sr, nc)| {
205            (
206                sr.cast_signed(),
207                nc.cast_signed(),
208                self.audio_bitrate.cast_signed(),
209            )
210        });
211
212        let inner = LiveDashInner::open(
213            &output_dir,
214            segment_secs,
215            width.cast_signed(),
216            height.cast_signed(),
217            fps_int,
218            self.video_bitrate,
219            audio_params,
220        )?;
221
222        self.inner = Some(inner);
223        Ok(self)
224    }
225}
226
227// ============================================================================
228// StreamOutput impl
229// ============================================================================
230
231impl StreamOutput for LiveDashOutput {
232    fn push_video(&mut self, frame: &VideoFrame) -> Result<(), StreamError> {
233        if self.finished {
234            return Err(StreamError::InvalidConfig {
235                reason: "push_video called after finish()".into(),
236            });
237        }
238        let inner = self
239            .inner
240            .as_mut()
241            .ok_or_else(|| StreamError::InvalidConfig {
242                reason: "push_video called before build()".into(),
243            })?;
244        inner.push_video(frame)
245    }
246
247    fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), StreamError> {
248        if self.finished {
249            return Err(StreamError::InvalidConfig {
250                reason: "push_audio called after finish()".into(),
251            });
252        }
253        let inner = self
254            .inner
255            .as_mut()
256            .ok_or_else(|| StreamError::InvalidConfig {
257                reason: "push_audio called before build()".into(),
258            })?;
259        inner.push_audio(frame);
260        Ok(())
261    }
262
263    fn finish(mut self: Box<Self>) -> Result<(), StreamError> {
264        if self.finished {
265            return Ok(());
266        }
267        self.finished = true;
268        let inner = self
269            .inner
270            .take()
271            .ok_or_else(|| StreamError::InvalidConfig {
272                reason: "finish() called before build()".into(),
273            })?;
274        inner.flush_and_close();
275        Ok(())
276    }
277}
278
279// ============================================================================
280// Unit tests
281// ============================================================================
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn build_without_video_should_return_invalid_config() {
289        let result = LiveDashOutput::new("/tmp/live_dash_test_no_video").build();
290        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
291    }
292
293    #[test]
294    fn build_with_empty_output_dir_should_return_invalid_config() {
295        let result = LiveDashOutput::new("").video(1280, 720, 30.0).build();
296        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
297    }
298
299    #[test]
300    fn segment_duration_default_should_be_four_seconds() {
301        let out = LiveDashOutput::new("/tmp/x");
302        assert_eq!(out.segment_duration, Duration::from_secs(4));
303    }
304}