Skip to main content

ff_stream/
dash.rs

1//! DASH segmented output builder.
2//!
3//! This module exposes [`DashOutput`], a consuming builder that configures and
4//! writes a DASH segmented stream. Validation is deferred to
5//! [`DashOutput::build`] so setter calls are infallible.
6
7use std::time::Duration;
8
9use crate::error::StreamError;
10
11/// Builds and writes a DASH segmented output.
12///
13/// `DashOutput` follows the consuming-builder pattern: each setter takes `self`
14/// and returns a new `Self`, and the final [`build`](Self::build) call validates
15/// the configuration before returning a ready-to-write instance.
16///
17/// # Examples
18///
19/// ```ignore
20/// use ff_stream::DashOutput;
21/// use std::time::Duration;
22///
23/// DashOutput::new("/var/www/dash")
24///     .input("source.mp4")
25///     .segment_duration(Duration::from_secs(4))
26///     .build()?
27///     .write()?;
28/// ```
29pub struct DashOutput {
30    output_dir: String,
31    input_path: Option<String>,
32    segment_duration: Duration,
33}
34
35impl DashOutput {
36    /// Create a new builder targeting `output_dir`.
37    ///
38    /// The directory does not need to exist at construction time; it will be
39    /// created (if absent) by the `FFmpeg` DASH muxer when [`write`](Self::write)
40    /// is called.
41    ///
42    /// Default: segment duration = 4 s.
43    #[must_use]
44    pub fn new(output_dir: &str) -> Self {
45        Self {
46            output_dir: output_dir.to_owned(),
47            input_path: None,
48            segment_duration: Duration::from_secs(4),
49        }
50    }
51
52    /// Set the input media file path.
53    ///
54    /// This is required; [`build`](Self::build) will return
55    /// [`StreamError::InvalidConfig`] if no input is supplied.
56    #[must_use]
57    pub fn input(mut self, path: &str) -> Self {
58        self.input_path = Some(path.to_owned());
59        self
60    }
61
62    /// Override the DASH segment duration (default: 4 s).
63    ///
64    /// MPEG-DASH recommends 2–10 s segments; 4 s is a common default that
65    /// balances latency against the overhead of many small files.
66    #[must_use]
67    pub fn segment_duration(mut self, d: Duration) -> Self {
68        self.segment_duration = d;
69        self
70    }
71
72    /// Validate the configuration and return a ready-to-write `DashOutput`.
73    ///
74    /// # Errors
75    ///
76    /// - [`StreamError::InvalidConfig`] when `output_dir` is empty.
77    /// - [`StreamError::InvalidConfig`] when no input path has been set via
78    ///   [`input`](Self::input).
79    ///
80    /// # Examples
81    ///
82    /// ```ignore
83    /// use ff_stream::DashOutput;
84    ///
85    /// // Missing input → error
86    /// assert!(DashOutput::new("/tmp/dash").build().is_err());
87    ///
88    /// // Valid configuration → ok
89    /// assert!(DashOutput::new("/tmp/dash").input("src.mp4").build().is_ok());
90    /// ```
91    pub fn build(self) -> Result<Self, StreamError> {
92        if self.output_dir.is_empty() {
93            return Err(StreamError::InvalidConfig {
94                reason: "output_dir must not be empty".into(),
95            });
96        }
97        if self.input_path.is_none() {
98            return Err(StreamError::InvalidConfig {
99                reason: "input path is required".into(),
100            });
101        }
102        log::info!(
103            "dash output configured output_dir={} segment_duration={:.1}s",
104            self.output_dir,
105            self.segment_duration.as_secs_f64(),
106        );
107        Ok(self)
108    }
109
110    /// Write DASH segments to the output directory.
111    ///
112    /// On success the output directory will contain a `manifest.mpd` file and
113    /// the corresponding initialization and media segments.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`StreamError::InvalidConfig`] when the builder is not fully
118    /// configured, or [`StreamError::Ffmpeg`] when an `FFmpeg` operation fails.
119    pub fn write(self) -> Result<(), StreamError> {
120        let input_path = self.input_path.ok_or_else(|| StreamError::InvalidConfig {
121            reason: "input path missing after build (internal error)".into(),
122        })?;
123        let seg_secs = self.segment_duration.as_secs_f64();
124        log::info!(
125            "dash write starting input={input_path} output_dir={} segment_duration={seg_secs:.1}s",
126            self.output_dir
127        );
128        crate::dash_inner::write_dash(&input_path, &self.output_dir, seg_secs)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn new_should_store_output_dir() {
138        let d = DashOutput::new("/tmp/dash");
139        assert_eq!(d.output_dir, "/tmp/dash");
140    }
141
142    #[test]
143    fn build_without_input_should_return_invalid_config() {
144        let result = DashOutput::new("/tmp/dash").build();
145        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
146    }
147
148    #[test]
149    fn build_with_valid_config_should_succeed() {
150        let result = DashOutput::new("/tmp/dash").input("/src/video.mp4").build();
151        assert!(result.is_ok());
152    }
153
154    #[test]
155    fn write_without_build_should_return_invalid_config() {
156        let result = DashOutput::new("/tmp/dash").write();
157        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
158    }
159}