1use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use ff_format::{AudioCodec, VideoCodec};
31
32use crate::error::StreamError;
33use crate::live_dash_inner::LiveDashInner;
34
35pub struct LiveDashOutput {
46 output_dir: PathBuf,
47 segment_duration: Duration,
48 video_codec: VideoCodec,
49 audio_codec: AudioCodec,
50 video_bitrate: u64,
51 audio_bitrate: u64,
52 video_width: Option<u32>,
53 video_height: Option<u32>,
54 fps: Option<f64>,
55 sample_rate: Option<u32>,
56 channels: Option<u32>,
57 inner: Option<LiveDashInner>,
58 finished: bool,
59}
60
61impl LiveDashOutput {
62 #[must_use]
74 pub fn new(output_dir: impl AsRef<Path>) -> Self {
75 Self {
76 output_dir: output_dir.as_ref().to_path_buf(),
77 segment_duration: Duration::from_secs(4),
78 video_codec: VideoCodec::H264,
79 audio_codec: AudioCodec::Aac,
80 video_bitrate: 2_000_000,
81 audio_bitrate: 128_000,
82 video_width: None,
83 video_height: None,
84 fps: None,
85 sample_rate: None,
86 channels: None,
87 inner: None,
88 finished: false,
89 }
90 }
91
92 #[must_use]
96 pub fn segment_duration(mut self, duration: Duration) -> Self {
97 self.segment_duration = duration;
98 self
99 }
100
101 pub fn build(mut self) -> Result<Self, StreamError> {
112 if self.output_dir.as_os_str().is_empty() {
113 return Err(StreamError::InvalidConfig {
114 reason: "output_dir must not be empty".into(),
115 });
116 }
117
118 let (Some(width), Some(height), Some(fps)) =
119 (self.video_width, self.video_height, self.fps)
120 else {
121 return Err(StreamError::InvalidConfig {
122 reason: "video parameters not set; call .video(width, height, fps) before .build()"
123 .into(),
124 });
125 };
126
127 std::fs::create_dir_all(&self.output_dir)?;
128
129 let output_dir = self
130 .output_dir
131 .to_str()
132 .ok_or_else(|| StreamError::InvalidConfig {
133 reason: "output_dir contains non-UTF-8 characters".into(),
134 })?
135 .to_owned();
136
137 #[allow(clippy::cast_possible_truncation)]
138 let fps_int = fps.round().max(1.0) as i32;
139 #[allow(clippy::cast_possible_truncation)]
140 let segment_secs = self.segment_duration.as_secs().max(1) as u32;
141
142 let audio_params = self.sample_rate.zip(self.channels).map(|(sr, nc)| {
143 (
144 sr.cast_signed(),
145 nc.cast_signed(),
146 self.audio_bitrate.cast_signed(),
147 )
148 });
149
150 let inner = LiveDashInner::open(
151 &output_dir,
152 segment_secs,
153 width.cast_signed(),
154 height.cast_signed(),
155 fps_int,
156 self.video_bitrate,
157 audio_params,
158 )?;
159
160 self.inner = Some(inner);
161 Ok(self)
162 }
163}
164
165impl_live_stream_setters!(LiveDashOutput, optional_audio);
166impl_frame_push_stream_output!(LiveDashOutput);
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn build_without_video_should_return_invalid_config() {
174 let result = LiveDashOutput::new("/tmp/live_dash_test_no_video").build();
175 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
176 }
177
178 #[test]
179 fn build_with_empty_output_dir_should_return_invalid_config() {
180 let result = LiveDashOutput::new("").video(1280, 720, 30.0).build();
181 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
182 }
183
184 #[test]
185 fn segment_duration_default_should_be_four_seconds() {
186 let out = LiveDashOutput::new("/tmp/x");
187 assert_eq!(out.segment_duration, Duration::from_secs(4));
188 }
189}