1use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use ff_format::{AudioCodec, AudioFrame, VideoCodec, VideoFrame};
31
32use crate::error::StreamError;
33use crate::live_dash_inner::LiveDashInner;
34use crate::output::StreamOutput;
35
36pub struct LiveDashOutput {
51 output_dir: PathBuf,
52 segment_duration: Duration,
53 video_codec: VideoCodec,
54 audio_codec: AudioCodec,
55 video_bitrate: u64,
56 audio_bitrate: u64,
57 video_width: Option<u32>,
58 video_height: Option<u32>,
59 fps: Option<f64>,
60 sample_rate: Option<u32>,
61 channels: Option<u32>,
62 inner: Option<LiveDashInner>,
63 finished: bool,
64}
65
66impl LiveDashOutput {
67 #[must_use]
79 pub fn new(output_dir: impl AsRef<Path>) -> Self {
80 Self {
81 output_dir: output_dir.as_ref().to_path_buf(),
82 segment_duration: Duration::from_secs(4),
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 inner: None,
93 finished: false,
94 }
95 }
96
97 #[must_use]
101 pub fn video(mut self, width: u32, height: u32, fps: f64) -> Self {
102 self.video_width = Some(width);
103 self.video_height = Some(height);
104 self.fps = Some(fps);
105 self
106 }
107
108 #[must_use]
112 pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
113 self.sample_rate = Some(sample_rate);
114 self.channels = Some(channels);
115 self
116 }
117
118 #[must_use]
122 pub fn segment_duration(mut self, duration: Duration) -> Self {
123 self.segment_duration = duration;
124 self
125 }
126
127 #[must_use]
131 pub fn video_codec(mut self, codec: VideoCodec) -> Self {
132 self.video_codec = codec;
133 self
134 }
135
136 #[must_use]
140 pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
141 self.audio_codec = codec;
142 self
143 }
144
145 #[must_use]
149 pub fn video_bitrate(mut self, bitrate: u64) -> Self {
150 self.video_bitrate = bitrate;
151 self
152 }
153
154 #[must_use]
158 pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
159 self.audio_bitrate = bitrate;
160 self
161 }
162
163 pub fn build(mut self) -> Result<Self, StreamError> {
174 if self.output_dir.as_os_str().is_empty() {
175 return Err(StreamError::InvalidConfig {
176 reason: "output_dir must not be empty".into(),
177 });
178 }
179
180 let (Some(width), Some(height), Some(fps)) =
181 (self.video_width, self.video_height, self.fps)
182 else {
183 return Err(StreamError::InvalidConfig {
184 reason: "video parameters not set; call .video(width, height, fps) before .build()"
185 .into(),
186 });
187 };
188
189 std::fs::create_dir_all(&self.output_dir)?;
190
191 let output_dir = self
192 .output_dir
193 .to_str()
194 .ok_or_else(|| StreamError::InvalidConfig {
195 reason: "output_dir contains non-UTF-8 characters".into(),
196 })?
197 .to_owned();
198
199 #[allow(clippy::cast_possible_truncation)]
200 let fps_int = fps.round().max(1.0) as i32;
201 #[allow(clippy::cast_possible_truncation)]
202 let segment_secs = self.segment_duration.as_secs().max(1) as u32;
203
204 let audio_params = self.sample_rate.zip(self.channels).map(|(sr, nc)| {
205 (
206 sr.cast_signed(),
207 nc.cast_signed(),
208 self.audio_bitrate.cast_signed(),
209 )
210 });
211
212 let inner = LiveDashInner::open(
213 &output_dir,
214 segment_secs,
215 width.cast_signed(),
216 height.cast_signed(),
217 fps_int,
218 self.video_bitrate,
219 audio_params,
220 )?;
221
222 self.inner = Some(inner);
223 Ok(self)
224 }
225}
226
227impl StreamOutput for LiveDashOutput {
232 fn push_video(&mut self, frame: &VideoFrame) -> Result<(), StreamError> {
233 if self.finished {
234 return Err(StreamError::InvalidConfig {
235 reason: "push_video called after finish()".into(),
236 });
237 }
238 let inner = self
239 .inner
240 .as_mut()
241 .ok_or_else(|| StreamError::InvalidConfig {
242 reason: "push_video called before build()".into(),
243 })?;
244 inner.push_video(frame)
245 }
246
247 fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), StreamError> {
248 if self.finished {
249 return Err(StreamError::InvalidConfig {
250 reason: "push_audio called after finish()".into(),
251 });
252 }
253 let inner = self
254 .inner
255 .as_mut()
256 .ok_or_else(|| StreamError::InvalidConfig {
257 reason: "push_audio called before build()".into(),
258 })?;
259 inner.push_audio(frame);
260 Ok(())
261 }
262
263 fn finish(mut self: Box<Self>) -> Result<(), StreamError> {
264 if self.finished {
265 return Ok(());
266 }
267 self.finished = true;
268 let inner = self
269 .inner
270 .take()
271 .ok_or_else(|| StreamError::InvalidConfig {
272 reason: "finish() called before build()".into(),
273 })?;
274 inner.flush_and_close();
275 Ok(())
276 }
277}
278
279#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn build_without_video_should_return_invalid_config() {
289 let result = LiveDashOutput::new("/tmp/live_dash_test_no_video").build();
290 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
291 }
292
293 #[test]
294 fn build_with_empty_output_dir_should_return_invalid_config() {
295 let result = LiveDashOutput::new("").video(1280, 720, 30.0).build();
296 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
297 }
298
299 #[test]
300 fn segment_duration_default_should_be_four_seconds() {
301 let out = LiveDashOutput::new("/tmp/x");
302 assert_eq!(out.segment_duration, Duration::from_secs(4));
303 }
304}