1use 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
38pub 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 #[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 #[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 #[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 #[must_use]
128 pub fn segment_duration(mut self, duration: Duration) -> Self {
129 self.segment_duration = duration;
130 self
131 }
132
133 #[must_use]
137 pub fn playlist_size(mut self, size: u32) -> Self {
138 self.playlist_size = size;
139 self
140 }
141
142 #[must_use]
146 pub fn video_codec(mut self, codec: VideoCodec) -> Self {
147 self.video_codec = codec;
148 self
149 }
150
151 #[must_use]
155 pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
156 self.audio_codec = codec;
157 self
158 }
159
160 #[must_use]
164 pub fn video_bitrate(mut self, bitrate: u64) -> Self {
165 self.video_bitrate = bitrate;
166 self
167 }
168
169 #[must_use]
173 pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
174 self.audio_bitrate = bitrate;
175 self
176 }
177
178 #[must_use]
183 pub fn segment_format(mut self, fmt: HlsSegmentFormat) -> Self {
184 self.segment_format = fmt;
185 self
186 }
187
188 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
254impl 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#[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}