Skip to main content

ff_stream/
live_hls.rs

1//! Frame-push live HLS output.
2//!
3//! [`LiveHlsOutput`] receives pre-decoded [`VideoFrame`] / [`AudioFrame`] values
4//! from the caller, encodes them with H.264/AAC, and muxes them into a sliding-
5//! window HLS playlist (`index.m3u8`) backed by `.ts` segment files.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use ff_stream::{LiveHlsOutput, StreamOutput};
11//! use std::time::Duration;
12//!
13//! let mut out = LiveHlsOutput::new("/var/www/live")
14//!     .video(1280, 720, 30.0)
15//!     .audio(48000, 2)
16//!     .segment_duration(Duration::from_secs(4))
17//!     .playlist_size(5)
18//!     .build()?;
19//!
20//! // for each decoded frame:
21//! out.push_video(&video_frame)?;
22//! out.push_audio(&audio_frame)?;
23//!
24//! // when done:
25//! Box::new(out).finish()?;
26//! ```
27
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use ff_format::{AudioCodec, AudioFrame, VideoCodec, VideoFrame};
32
33use crate::error::StreamError;
34use crate::hls::HlsSegmentFormat;
35use crate::live_hls_inner::LiveHlsInner;
36use crate::output::StreamOutput;
37
38// ============================================================================
39// LiveHlsOutput — safe builder + StreamOutput impl
40// ============================================================================
41
42/// Live HLS output: receives frames and writes a sliding-window `.m3u8` playlist.
43///
44/// Build with [`LiveHlsOutput::new`], chain setter methods, then call
45/// [`build`](Self::build) to open the `FFmpeg` contexts. After `build()`:
46///
47/// - [`push_video`](Self::push_video) and [`push_audio`](Self::push_audio) encode and
48///   mux frames in real time.
49/// - [`StreamOutput::finish`] flushes all encoders and writes the HLS trailer.
50///
51/// The output directory is created automatically by `build()` if it does not exist.
52pub struct LiveHlsOutput {
53    output_dir: PathBuf,
54    segment_duration: Duration,
55    playlist_size: u32,
56    video_codec: VideoCodec,
57    audio_codec: AudioCodec,
58    video_bitrate: u64,
59    audio_bitrate: u64,
60    video_width: Option<u32>,
61    video_height: Option<u32>,
62    fps: Option<f64>,
63    sample_rate: Option<u32>,
64    channels: Option<u32>,
65    segment_format: HlsSegmentFormat,
66    inner: Option<LiveHlsInner>,
67    finished: bool,
68}
69
70impl LiveHlsOutput {
71    /// Create a new builder that writes HLS output to `output_dir`.
72    ///
73    /// Accepts any path-like value: `"/var/www/live"`, `Path::new(…)`, etc.
74    ///
75    /// # Example
76    ///
77    /// ```ignore
78    /// use ff_stream::LiveHlsOutput;
79    ///
80    /// let out = LiveHlsOutput::new("/var/www/live");
81    /// ```
82    #[must_use]
83    pub fn new(output_dir: impl AsRef<Path>) -> Self {
84        Self {
85            output_dir: output_dir.as_ref().to_path_buf(),
86            segment_duration: Duration::from_secs(6),
87            playlist_size: 5,
88            video_codec: VideoCodec::H264,
89            audio_codec: AudioCodec::Aac,
90            video_bitrate: 2_000_000,
91            audio_bitrate: 128_000,
92            video_width: None,
93            video_height: None,
94            fps: None,
95            sample_rate: None,
96            channels: None,
97            segment_format: HlsSegmentFormat::Ts,
98            inner: None,
99            finished: false,
100        }
101    }
102
103    /// Set the video encoding parameters.
104    ///
105    /// This method **must** be called before [`build`](Self::build).
106    #[must_use]
107    pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
108        self.video_width = Some(width);
109        self.video_height = Some(height);
110        self.fps = Some(fps);
111        self
112    }
113
114    /// Enable audio output with the given sample rate and channel count.
115    ///
116    /// If this method is not called, audio is disabled.
117    #[must_use]
118    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
119        self.sample_rate = Some(sample_rate);
120        self.channels = Some(channels);
121        self
122    }
123
124    /// Set the target HLS segment duration.
125    ///
126    /// Default: 6 seconds.
127    #[must_use]
128    pub fn segment_duration(mut self, duration: Duration) -> Self {
129        self.segment_duration = duration;
130        self
131    }
132
133    /// Set the maximum number of segments kept in the sliding-window playlist.
134    ///
135    /// Default: 5.
136    #[must_use]
137    pub fn playlist_size(mut self, size: u32) -> Self {
138        self.playlist_size = size;
139        self
140    }
141
142    /// Set the video codec.
143    ///
144    /// Default: [`VideoCodec::H264`].
145    #[must_use]
146    pub fn video_codec(mut self, codec: VideoCodec) -> Self {
147        self.video_codec = codec;
148        self
149    }
150
151    /// Set the audio codec.
152    ///
153    /// Default: [`AudioCodec::Aac`].
154    #[must_use]
155    pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
156        self.audio_codec = codec;
157        self
158    }
159
160    /// Set the video encoder target bit rate in bits/s.
161    ///
162    /// Default: 2 000 000 (2 Mbit/s).
163    #[must_use]
164    pub fn video_bitrate(mut self, bitrate: u64) -> Self {
165        self.video_bitrate = bitrate;
166        self
167    }
168
169    /// Set the audio encoder target bit rate in bits/s.
170    ///
171    /// Default: 128 000 (128 kbit/s).
172    #[must_use]
173    pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
174        self.audio_bitrate = bitrate;
175        self
176    }
177
178    /// Set the HLS segment container format (default: [`HlsSegmentFormat::Ts`]).
179    ///
180    /// Use [`HlsSegmentFormat::Fmp4`] to produce CMAF-compatible fMP4 segments
181    /// (`.m4s`) with an `init.mp4` initialization segment.
182    #[must_use]
183    pub fn segment_format(mut self, fmt: HlsSegmentFormat) -> Self {
184        self.segment_format = fmt;
185        self
186    }
187
188    /// Open all `FFmpeg` contexts and write the HLS header.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`StreamError::InvalidConfig`] when:
193    /// - `output_dir` is empty.
194    /// - [`video`](Self::video) was not called before `build`.
195    ///
196    /// Returns [`StreamError::Io`] when the output directory cannot be created.
197    /// Returns [`StreamError::Ffmpeg`] when any `FFmpeg` operation fails.
198    pub fn build(mut self) -> Result<Self, StreamError> {
199        if self.output_dir.as_os_str().is_empty() {
200            return Err(StreamError::InvalidConfig {
201                reason: "output_dir must not be empty".into(),
202            });
203        }
204
205        let (Some(width), Some(height), Some(fps)) =
206            (self.video_width, self.video_height, self.fps)
207        else {
208            return Err(StreamError::InvalidConfig {
209                reason: "video parameters not set; call .video(width, height, fps) before .build()"
210                    .into(),
211            });
212        };
213
214        std::fs::create_dir_all(&self.output_dir)?;
215
216        let output_dir = self
217            .output_dir
218            .to_str()
219            .ok_or_else(|| StreamError::InvalidConfig {
220                reason: "output_dir contains non-UTF-8 characters".into(),
221            })?
222            .to_owned();
223
224        #[allow(clippy::cast_possible_truncation)]
225        let fps_int = fps.round().max(1.0) as i32;
226        #[allow(clippy::cast_possible_truncation)]
227        let segment_secs = self.segment_duration.as_secs().max(1) as u32;
228
229        let audio_params = self.sample_rate.zip(self.channels).map(|(sr, nc)| {
230            (
231                sr.cast_signed(),
232                nc.cast_signed(),
233                self.audio_bitrate.cast_signed(),
234            )
235        });
236
237        let inner = LiveHlsInner::open(
238            &output_dir,
239            segment_secs,
240            self.playlist_size,
241            width.cast_signed(),
242            height.cast_signed(),
243            fps_int,
244            self.video_bitrate,
245            audio_params,
246            self.segment_format,
247        )?;
248
249        self.inner = Some(inner);
250        Ok(self)
251    }
252}
253
254// ============================================================================
255// StreamOutput impl
256// ============================================================================
257
258impl StreamOutput for LiveHlsOutput {
259    fn push_video(&mut self, frame: &VideoFrame) -> Result<(), StreamError> {
260        if self.finished {
261            return Err(StreamError::InvalidConfig {
262                reason: "push_video called after finish()".into(),
263            });
264        }
265        let inner = self
266            .inner
267            .as_mut()
268            .ok_or_else(|| StreamError::InvalidConfig {
269                reason: "push_video called before build()".into(),
270            })?;
271        inner.push_video(frame)
272    }
273
274    fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), StreamError> {
275        if self.finished {
276            return Err(StreamError::InvalidConfig {
277                reason: "push_audio called after finish()".into(),
278            });
279        }
280        let inner = self
281            .inner
282            .as_mut()
283            .ok_or_else(|| StreamError::InvalidConfig {
284                reason: "push_audio called before build()".into(),
285            })?;
286        inner.push_audio(frame);
287        Ok(())
288    }
289
290    fn finish(mut self: Box<Self>) -> Result<(), StreamError> {
291        if self.finished {
292            return Ok(());
293        }
294        self.finished = true;
295        let inner = self
296            .inner
297            .take()
298            .ok_or_else(|| StreamError::InvalidConfig {
299                reason: "finish() called before build()".into(),
300            })?;
301        inner.flush_and_close();
302        Ok(())
303    }
304}
305
306// ============================================================================
307// Unit tests
308// ============================================================================
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn build_without_video_should_return_invalid_config() {
316        let result = LiveHlsOutput::new("/tmp/live_hls_test_no_video").build();
317        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
318    }
319
320    #[test]
321    fn build_with_empty_output_dir_should_return_invalid_config() {
322        let result = LiveHlsOutput::new("").video(1280, 720, 30.0).build();
323        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
324    }
325
326    #[test]
327    fn segment_duration_default_should_be_six_seconds() {
328        let out = LiveHlsOutput::new("/tmp/x");
329        assert_eq!(out.segment_duration, Duration::from_secs(6));
330    }
331
332    #[test]
333    fn playlist_size_default_should_be_five() {
334        let out = LiveHlsOutput::new("/tmp/x");
335        assert_eq!(out.playlist_size, 5);
336    }
337
338    #[test]
339    fn segment_format_default_should_be_ts() {
340        let out = LiveHlsOutput::new("/tmp/x");
341        assert_eq!(out.segment_format, HlsSegmentFormat::Ts);
342    }
343
344    #[test]
345    fn segment_format_setter_should_store_fmp4() {
346        let out = LiveHlsOutput::new("/tmp/x").segment_format(HlsSegmentFormat::Fmp4);
347        assert_eq!(out.segment_format, HlsSegmentFormat::Fmp4);
348    }
349}