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