Skip to main content

ff_stream/
live_hls.rs

1//! Frame-push live HLS output.
2//!
3//! [`LiveHlsOutput`] receives pre-decoded [`ff_format::VideoFrame`] / [`ff_format::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, VideoCodec};
32
33use crate::error::StreamError;
34use crate::hls::HlsSegmentFormat;
35use crate::live_hls_inner::LiveHlsInner;
36
37/// Live HLS output: receives frames and writes a sliding-window `.m3u8` playlist.
38///
39/// Build with [`LiveHlsOutput::new`], chain setter methods, then call
40/// [`build`](Self::build) to open the `FFmpeg` contexts. After `build()`:
41///
42/// - `push_video` and `push_audio` encode and
43///   mux frames in real time.
44/// - [`crate::StreamOutput::finish`] flushes all encoders and writes the HLS trailer.
45///
46/// The output directory is created automatically by `build()` if it does not exist.
47pub struct LiveHlsOutput {
48    output_dir: PathBuf,
49    segment_duration: Duration,
50    playlist_size: u32,
51    video_codec: VideoCodec,
52    audio_codec: AudioCodec,
53    video_bitrate: u64,
54    audio_bitrate: u64,
55    video_width: Option<u32>,
56    video_height: Option<u32>,
57    fps: Option<f64>,
58    sample_rate: Option<u32>,
59    channels: Option<u32>,
60    segment_format: HlsSegmentFormat,
61    inner: Option<LiveHlsInner>,
62    finished: bool,
63}
64
65impl LiveHlsOutput {
66    /// Create a new builder that writes HLS output to `output_dir`.
67    ///
68    /// Accepts any path-like value: `"/var/www/live"`, `Path::new(…)`, etc.
69    ///
70    /// # Example
71    ///
72    /// ```ignore
73    /// use ff_stream::LiveHlsOutput;
74    ///
75    /// let out = LiveHlsOutput::new("/var/www/live");
76    /// ```
77    #[must_use]
78    pub fn new(output_dir: impl AsRef<Path>) -> Self {
79        Self {
80            output_dir: output_dir.as_ref().to_path_buf(),
81            segment_duration: Duration::from_secs(6),
82            playlist_size: 5,
83            video_codec: VideoCodec::H264,
84            audio_codec: AudioCodec::Aac,
85            video_bitrate: 2_000_000,
86            audio_bitrate: 128_000,
87            video_width: None,
88            video_height: None,
89            fps: None,
90            sample_rate: None,
91            channels: None,
92            segment_format: HlsSegmentFormat::Ts,
93            inner: None,
94            finished: false,
95        }
96    }
97
98    /// Set the target HLS segment duration.
99    ///
100    /// Default: 6 seconds.
101    #[must_use]
102    pub fn segment_duration(mut self, duration: Duration) -> Self {
103        self.segment_duration = duration;
104        self
105    }
106
107    /// Set the maximum number of segments kept in the sliding-window playlist.
108    ///
109    /// Default: 5.
110    #[must_use]
111    pub fn playlist_size(mut self, size: u32) -> Self {
112        self.playlist_size = size;
113        self
114    }
115
116    /// Set the HLS segment container format (default: [`HlsSegmentFormat::Ts`]).
117    ///
118    /// Use [`HlsSegmentFormat::Fmp4`] to produce CMAF-compatible fMP4 segments
119    /// (`.m4s`) with an `init.mp4` initialization segment.
120    #[must_use]
121    pub fn segment_format(mut self, fmt: HlsSegmentFormat) -> Self {
122        self.segment_format = fmt;
123        self
124    }
125
126    /// Open all `FFmpeg` contexts and write the HLS header.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`StreamError::InvalidConfig`] when:
131    /// - `output_dir` is empty.
132    /// - [`video`](Self::video) was not called before `build`.
133    ///
134    /// Returns [`StreamError::Io`] when the output directory cannot be created.
135    /// Returns [`StreamError::Ffmpeg`] when any `FFmpeg` operation fails.
136    pub fn build(mut self) -> Result<Self, StreamError> {
137        if self.output_dir.as_os_str().is_empty() {
138            return Err(StreamError::InvalidConfig {
139                reason: "output_dir must not be empty".into(),
140            });
141        }
142
143        let (Some(width), Some(height), Some(fps)) =
144            (self.video_width, self.video_height, self.fps)
145        else {
146            return Err(StreamError::InvalidConfig {
147                reason: "video parameters not set; call .video(width, height, fps) before .build()"
148                    .into(),
149            });
150        };
151
152        std::fs::create_dir_all(&self.output_dir)?;
153
154        let output_dir = self
155            .output_dir
156            .to_str()
157            .ok_or_else(|| StreamError::InvalidConfig {
158                reason: "output_dir contains non-UTF-8 characters".into(),
159            })?
160            .to_owned();
161
162        #[allow(clippy::cast_possible_truncation)]
163        let fps_int = fps.round().max(1.0) as i32;
164        #[allow(clippy::cast_possible_truncation)]
165        let segment_secs = self.segment_duration.as_secs().max(1) as u32;
166
167        let audio_params = self.sample_rate.zip(self.channels).map(|(sr, nc)| {
168            (
169                sr.cast_signed(),
170                nc.cast_signed(),
171                self.audio_bitrate.cast_signed(),
172            )
173        });
174
175        let inner = LiveHlsInner::open(
176            &output_dir,
177            segment_secs,
178            self.playlist_size,
179            width.cast_signed(),
180            height.cast_signed(),
181            fps_int,
182            self.video_bitrate,
183            audio_params,
184            self.segment_format,
185        )?;
186
187        self.inner = Some(inner);
188        Ok(self)
189    }
190}
191
192impl_live_stream_setters!(LiveHlsOutput, optional_audio);
193impl_frame_push_stream_output!(LiveHlsOutput);
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn build_without_video_should_return_invalid_config() {
201        let result = LiveHlsOutput::new("/tmp/live_hls_test_no_video").build();
202        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
203    }
204
205    #[test]
206    fn build_with_empty_output_dir_should_return_invalid_config() {
207        let result = LiveHlsOutput::new("").video(1280, 720, 30.0).build();
208        assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
209    }
210
211    #[test]
212    fn segment_duration_default_should_be_six_seconds() {
213        let out = LiveHlsOutput::new("/tmp/x");
214        assert_eq!(out.segment_duration, Duration::from_secs(6));
215    }
216
217    #[test]
218    fn playlist_size_default_should_be_five() {
219        let out = LiveHlsOutput::new("/tmp/x");
220        assert_eq!(out.playlist_size, 5);
221    }
222
223    #[test]
224    fn segment_format_default_should_be_ts() {
225        let out = LiveHlsOutput::new("/tmp/x");
226        assert_eq!(out.segment_format, HlsSegmentFormat::Ts);
227    }
228
229    #[test]
230    fn segment_format_setter_should_store_fmp4() {
231        let out = LiveHlsOutput::new("/tmp/x").segment_format(HlsSegmentFormat::Fmp4);
232        assert_eq!(out.segment_format, HlsSegmentFormat::Fmp4);
233    }
234}