1use 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
37pub 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 #[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 #[must_use]
102 pub fn segment_duration(mut self, duration: Duration) -> Self {
103 self.segment_duration = duration;
104 self
105 }
106
107 #[must_use]
111 pub fn playlist_size(mut self, size: u32) -> Self {
112 self.playlist_size = size;
113 self
114 }
115
116 #[must_use]
121 pub fn segment_format(mut self, fmt: HlsSegmentFormat) -> Self {
122 self.segment_format = fmt;
123 self
124 }
125
126 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}