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}