Skip to main content

ff_stream/
hls.rs

1//! HLS segmented output builder.
2//!
3//! This module exposes [`HlsOutput`], a consuming builder that configures and
4//! writes an HLS segmented stream. Validation is deferred to [`HlsOutput::build`]
5//! so setter calls are infallible.
6
7use std::time::Duration;
8
9use crate::error::StreamError;
10
11/// Builds and writes an HLS segmented output.
12///
13/// `HlsOutput` 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::HlsOutput;
21/// use std::time::Duration;
22///
23/// HlsOutput::new("/var/www/hls")
24///     .input("source.mp4")
25///     .segment_duration(Duration::from_secs(6))
26///     .keyframe_interval(48)
27///     .build()?
28///     .write()?;
29/// ```
30pub struct HlsOutput {
31    output_dir: String,
32    input_path: Option<String>,
33    segment_duration: Duration,
34    keyframe_interval: u32,
35    target_bitrate: Option<u64>,
36    target_video_size: Option<(u32, u32)>,
37}
38
39impl HlsOutput {
40    /// Create a new builder targeting `output_dir`.
41    ///
42    /// The directory does not need to exist at construction time; it will be
43    /// created (if absent) by the `FFmpeg` HLS muxer when [`write`](Self::write)
44    /// is called.
45    ///
46    /// Defaults: segment duration = 6 s, keyframe interval = 48 frames.
47    #[must_use]
48    pub fn new(output_dir: &str) -> Self {
49        Self {
50            output_dir: output_dir.to_owned(),
51            input_path: None,
52            segment_duration: Duration::from_secs(6),
53            keyframe_interval: 48,
54            target_bitrate: None,
55            target_video_size: None,
56        }
57    }
58
59    /// Set the input media file path.
60    ///
61    /// This is required; [`build`](Self::build) will return
62    /// [`StreamError::InvalidConfig`] if no input is supplied.
63    #[must_use]
64    pub fn input(mut self, path: &str) -> Self {
65        self.input_path = Some(path.to_owned());
66        self
67    }
68
69    /// Override the HLS segment duration (default: 6 s).
70    ///
71    /// Apple's HLS recommendation is 6 s for live streams and up to 10 s for
72    /// VOD. Smaller values reduce latency but increase the number of segment
73    /// files and playlist entries.
74    #[must_use]
75    pub fn segment_duration(mut self, d: Duration) -> Self {
76        self.segment_duration = d;
77        self
78    }
79
80    /// Override the target video bitrate in bits per second.
81    ///
82    /// When not set, the encoder uses a default of 2 Mbit/s.
83    #[must_use]
84    pub fn bitrate(mut self, bps: u64) -> Self {
85        self.target_bitrate = Some(bps);
86        self
87    }
88
89    /// Override the output video dimensions.
90    ///
91    /// When not set, the encoder uses the input stream's dimensions.
92    #[must_use]
93    pub fn video_size(mut self, width: u32, height: u32) -> Self {
94        self.target_video_size = Some((width, height));
95        self
96    }
97
98    /// Override the keyframe interval in frames (default: 48).
99    ///
100    /// HLS requires segment boundaries to align with keyframes. Setting this to
101    /// `fps × segment_duration` (e.g. 24 fps × 2 s = 48) ensures clean cuts
102    /// without decoding artefacts at the start of each segment.
103    #[must_use]
104    pub fn keyframe_interval(mut self, frames: u32) -> Self {
105        self.keyframe_interval = frames;
106        self
107    }
108
109    /// Validate the configuration and return a ready-to-write `HlsOutput`.
110    ///
111    /// # Errors
112    ///
113    /// - [`StreamError::InvalidConfig`] when `output_dir` is empty.
114    /// - [`StreamError::InvalidConfig`] when no input path has been set via
115    ///   [`input`](Self::input).
116    ///
117    /// # Examples
118    ///
119    /// ```ignore
120    /// use ff_stream::HlsOutput;
121    ///
122    /// // Missing input → error
123    /// assert!(HlsOutput::new("/tmp/hls").build().is_err());
124    ///
125    /// // Valid configuration → ok
126    /// assert!(HlsOutput::new("/tmp/hls").input("src.mp4").build().is_ok());
127    /// ```
128    pub fn build(self) -> Result<Self, StreamError> {
129        if self.output_dir.is_empty() {
130            return Err(StreamError::InvalidConfig {
131                reason: "output_dir must not be empty".into(),
132            });
133        }
134        if self.input_path.is_none() {
135            return Err(StreamError::InvalidConfig {
136                reason: "input path is required".into(),
137            });
138        }
139        log::info!(
140            "hls output configured output_dir={} segment_duration={:.1}s keyframe_interval={} \
141             bitrate={:?} video_size={:?}",
142            self.output_dir,
143            self.segment_duration.as_secs_f64(),
144            self.keyframe_interval,
145            self.target_bitrate,
146            self.target_video_size,
147        );
148        Ok(self)
149    }
150
151    /// Write HLS segments to the output directory.
152    ///
153    /// On success the output directory will contain a `playlist.m3u8` file and
154    /// numbered segment files (`segment000.ts`, `segment001.ts`, …).
155    ///
156    /// # Errors
157    ///
158    /// - [`StreamError::InvalidConfig`] when called without first calling
159    ///   [`build`](Self::build) (i.e. `input_path` is `None`).
160    /// - [`StreamError::Io`] when the output directory cannot be created.
161    /// - [`StreamError::Ffmpeg`] when the `FFmpeg` HLS muxer fails.
162    pub fn write(self) -> Result<(), StreamError> {
163        let input_path = self.input_path.ok_or_else(|| StreamError::InvalidConfig {
164            reason: "input path missing after build (internal error)".into(),
165        })?;
166        let seg_secs = self.segment_duration.as_secs_f64();
167        log::info!(
168            "hls write starting input={input_path} output_dir={} \
169             segment_duration={seg_secs:.1}s keyframe_interval={}",
170            self.output_dir,
171            self.keyframe_interval
172        );
173        let target_bitrate = self.target_bitrate.map_or(0i64, |b| b.cast_signed());
174        let (target_width, target_height) = self
175            .target_video_size
176            .map_or((0i32, 0i32), |(w, h)| (w.cast_signed(), h.cast_signed()));
177        crate::hls_inner::write_hls(
178            &input_path,
179            &self.output_dir,
180            seg_secs,
181            self.keyframe_interval,
182            target_bitrate,
183            target_width,
184            target_height,
185        )
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn new_should_store_output_dir() {
195        let h = HlsOutput::new("/tmp/hls");
196        assert_eq!(h.output_dir, "/tmp/hls");
197    }
198
199    #[test]
200    fn input_should_store_input_path() {
201        let h = HlsOutput::new("/tmp/hls").input("/src/video.mp4");
202        assert_eq!(h.input_path.as_deref(), Some("/src/video.mp4"));
203    }
204
205    #[test]
206    fn segment_duration_should_store_duration() {
207        let d = Duration::from_secs(10);
208        let h = HlsOutput::new("/tmp/hls").segment_duration(d);
209        assert_eq!(h.segment_duration, d);
210    }
211
212    #[test]
213    fn keyframe_interval_should_store_interval() {
214        let h = HlsOutput::new("/tmp/hls").keyframe_interval(24);
215        assert_eq!(h.keyframe_interval, 24);
216    }
217
218    #[test]
219    fn build_without_input_should_return_invalid_config() {
220        let result = HlsOutput::new("/tmp/hls").build();
221        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
222    }
223
224    #[test]
225    fn build_with_empty_output_dir_should_return_invalid_config() {
226        let result = HlsOutput::new("").input("/src/video.mp4").build();
227        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
228    }
229
230    #[test]
231    fn build_with_valid_config_should_succeed() {
232        let result = HlsOutput::new("/tmp/hls").input("/src/video.mp4").build();
233        assert!(result.is_ok());
234    }
235
236    #[test]
237    fn write_without_build_should_return_invalid_config() {
238        // input_path is None because build() was not called
239        let result = HlsOutput::new("/tmp/hls").write();
240        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
241    }
242
243    #[test]
244    fn bitrate_should_store_bitrate() {
245        let h = HlsOutput::new("/tmp/hls").bitrate(3_000_000);
246        assert_eq!(h.target_bitrate, Some(3_000_000));
247    }
248
249    #[test]
250    fn video_size_should_store_dimensions() {
251        let h = HlsOutput::new("/tmp/hls").video_size(1280, 720);
252        assert_eq!(h.target_video_size, Some((1280, 720)));
253    }
254
255    #[test]
256    fn bitrate_default_should_be_none() {
257        let h = HlsOutput::new("/tmp/hls");
258        assert_eq!(h.target_bitrate, None);
259    }
260
261    #[test]
262    fn video_size_default_should_be_none() {
263        let h = HlsOutput::new("/tmp/hls");
264        assert_eq!(h.target_video_size, None);
265    }
266
267    #[test]
268    fn build_with_bitrate_and_video_size_should_succeed() {
269        let result = HlsOutput::new("/tmp/hls")
270            .input("/src/video.mp4")
271            .bitrate(4_000_000)
272            .video_size(1920, 1080)
273            .build();
274        assert!(result.is_ok());
275        let h = result.unwrap();
276        assert_eq!(h.target_bitrate, Some(4_000_000));
277        assert_eq!(h.target_video_size, Some((1920, 1080)));
278    }
279}