1use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45use ff_format::{AudioFrame, VideoCodec, VideoFrame};
46
47use crate::error::StreamError;
48use crate::live_dash::LiveDashOutput;
49use crate::live_hls::LiveHlsOutput;
50use crate::output::StreamOutput;
51
52pub struct AbrRendition {
61 pub width: u32,
63 pub height: u32,
65 pub video_bitrate: u64,
67 pub audio_bitrate: u64,
69 pub name: Option<String>,
71}
72
73impl AbrRendition {
74 #[must_use]
93 pub fn dir_name(&self) -> String {
94 self.name
95 .clone()
96 .unwrap_or_else(|| format!("{}x{}", self.width, self.height))
97 }
98}
99
100pub enum LiveAbrFormat {
106 Hls,
108 Dash,
111}
112
113pub struct LiveAbrLadder {
130 output_dir: PathBuf,
131 renditions: Vec<AbrRendition>,
132 format: LiveAbrFormat,
133 segment_duration: Duration,
134 playlist_size: u32,
135 video_codec: VideoCodec,
136 fps: Option<f64>,
137 sample_rate: Option<u32>,
138 channels: Option<u32>,
139 outputs: Vec<Box<dyn StreamOutput>>,
141 finished: bool,
142}
143
144impl LiveAbrLadder {
145 #[must_use]
157 pub fn new(output_dir: impl AsRef<Path>) -> Self {
158 Self {
159 output_dir: output_dir.as_ref().to_path_buf(),
160 renditions: Vec::new(),
161 format: LiveAbrFormat::Hls,
162 segment_duration: Duration::from_secs(6),
163 playlist_size: 5,
164 video_codec: VideoCodec::H264,
165 fps: None,
166 sample_rate: None,
167 channels: None,
168 outputs: Vec::new(),
169 finished: false,
170 }
171 }
172
173 #[must_use]
178 pub fn add_rendition(mut self, rendition: AbrRendition) -> Self {
179 self.renditions.push(rendition);
180 self
181 }
182
183 #[must_use]
187 pub fn format(mut self, format: LiveAbrFormat) -> Self {
188 self.format = format;
189 self
190 }
191
192 #[must_use]
196 pub fn fps(mut self, fps: f64) -> Self {
197 self.fps = Some(fps);
198 self
199 }
200
201 #[must_use]
205 pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
206 self.sample_rate = Some(sample_rate);
207 self.channels = Some(channels);
208 self
209 }
210
211 #[must_use]
215 pub fn segment_duration(mut self, duration: Duration) -> Self {
216 self.segment_duration = duration;
217 self
218 }
219
220 #[must_use]
224 pub fn playlist_size(mut self, size: u32) -> Self {
225 self.playlist_size = size;
226 self
227 }
228
229 #[must_use]
233 pub fn video_codec(mut self, codec: VideoCodec) -> Self {
234 self.video_codec = codec;
235 self
236 }
237
238 pub fn build(mut self) -> Result<Self, StreamError> {
251 if self.output_dir.as_os_str().is_empty() {
252 return Err(StreamError::InvalidConfig {
253 reason: "output_dir must not be empty".into(),
254 });
255 }
256
257 if self.renditions.is_empty() {
258 return Err(StreamError::InvalidConfig {
259 reason: "at least one rendition is required; call .add_rendition() before .build()"
260 .into(),
261 });
262 }
263
264 let fps = self.fps.ok_or_else(|| StreamError::InvalidConfig {
265 reason: "fps not set; call .fps(value) before .build()".into(),
266 })?;
267
268 std::fs::create_dir_all(&self.output_dir)?;
269
270 let mut outputs: Vec<Box<dyn StreamOutput>> = Vec::with_capacity(self.renditions.len());
271
272 for rendition in &self.renditions {
273 let rendition_dir = self.output_dir.join(rendition.dir_name());
274
275 let output: Box<dyn StreamOutput> = match self.format {
276 LiveAbrFormat::Hls => {
277 let mut builder = LiveHlsOutput::new(&rendition_dir)
278 .video(rendition.width, rendition.height, fps)
279 .video_bitrate(rendition.video_bitrate)
280 .audio_bitrate(rendition.audio_bitrate)
281 .segment_duration(self.segment_duration)
282 .playlist_size(self.playlist_size)
283 .video_codec(self.video_codec);
284
285 if let (Some(sr), Some(ch)) = (self.sample_rate, self.channels) {
286 builder = builder.audio(sr, ch);
287 }
288
289 Box::new(builder.build()?)
290 }
291 LiveAbrFormat::Dash => {
292 let mut builder = LiveDashOutput::new(&rendition_dir)
293 .video(rendition.width, rendition.height, fps)
294 .video_bitrate(rendition.video_bitrate)
295 .audio_bitrate(rendition.audio_bitrate)
296 .segment_duration(self.segment_duration)
297 .video_codec(self.video_codec);
298
299 if let (Some(sr), Some(ch)) = (self.sample_rate, self.channels) {
300 builder = builder.audio(sr, ch);
301 }
302
303 Box::new(builder.build()?)
304 }
305 };
306
307 outputs.push(output);
308 }
309
310 self.outputs = outputs;
311 Ok(self)
312 }
313}
314
315impl StreamOutput for LiveAbrLadder {
320 fn push_video(&mut self, frame: &VideoFrame) -> Result<(), StreamError> {
321 if self.finished {
322 return Err(StreamError::InvalidConfig {
323 reason: "push_video called after finish()".into(),
324 });
325 }
326 if self.outputs.is_empty() {
327 return Err(StreamError::InvalidConfig {
328 reason: "push_video called before build()".into(),
329 });
330 }
331 for output in &mut self.outputs {
332 output.push_video(frame)?;
333 }
334 Ok(())
335 }
336
337 fn push_audio(&mut self, frame: &AudioFrame) -> Result<(), StreamError> {
338 if self.finished {
339 return Err(StreamError::InvalidConfig {
340 reason: "push_audio called after finish()".into(),
341 });
342 }
343 if self.outputs.is_empty() {
344 return Err(StreamError::InvalidConfig {
345 reason: "push_audio called before build()".into(),
346 });
347 }
348 for output in &mut self.outputs {
349 output.push_audio(frame)?;
350 }
351 Ok(())
352 }
353
354 fn finish(mut self: Box<Self>) -> Result<(), StreamError> {
355 if self.finished {
356 return Ok(());
357 }
358 self.finished = true;
359
360 let outputs = std::mem::take(&mut self.outputs);
361 for output in outputs {
362 output.finish()?;
363 }
364
365 match self.format {
366 LiveAbrFormat::Hls => {
367 write_hls_master(&self.output_dir, &self.renditions)?;
368 }
369 LiveAbrFormat::Dash => {
370 write_dash_manifest(&self.output_dir, &self.renditions)?;
371 }
372 }
373
374 log::info!(
375 "live_abr finished output_dir={} renditions={}",
376 self.output_dir.display(),
377 self.renditions.len()
378 );
379 Ok(())
380 }
381}
382
383fn write_hls_master(output_dir: &Path, renditions: &[AbrRendition]) -> Result<(), StreamError> {
389 use std::fmt::Write as _;
390
391 let mut content = String::from("#EXTM3U\n#EXT-X-VERSION:3\n");
392 for r in renditions {
393 let bandwidth = r.video_bitrate + r.audio_bitrate;
394 let dir = r.dir_name();
395 let _ = write!(
396 content,
397 "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={}x{}\n{dir}/index.m3u8\n",
398 r.width, r.height,
399 );
400 }
401
402 let master_path = output_dir.join("master.m3u8");
403 std::fs::write(&master_path, content)?;
404 log::info!(
405 "live_abr wrote master playlist path={}",
406 master_path.display()
407 );
408 Ok(())
409}
410
411fn write_dash_manifest(output_dir: &Path, renditions: &[AbrRendition]) -> Result<(), StreamError> {
413 use std::fmt::Write as _;
414
415 let mut representations = String::new();
416 for r in renditions {
417 let bandwidth = r.video_bitrate + r.audio_bitrate;
418 let dir = r.dir_name();
419 let _ = write!(
420 representations,
421 " <Representation bandwidth=\"{bandwidth}\" width=\"{}\" height=\"{}\">\
422\n <BaseURL>{dir}/</BaseURL>\n </Representation>\n",
423 r.width, r.height,
424 );
425 }
426
427 let content = format!(
428 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
429<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\" type=\"dynamic\"\
430 profiles=\"urn:mpeg:dash:profile:isoff-live:2011\">\n\
431 <Period>\n\
432 <AdaptationSet mimeType=\"video/mp4\" segmentAlignment=\"true\">\n\
433{representations}\
434 </AdaptationSet>\n\
435 </Period>\n\
436</MPD>\n"
437 );
438
439 let manifest_path = output_dir.join("manifest.mpd");
440 std::fs::write(&manifest_path, content)?;
441 log::info!(
442 "live_abr wrote dash manifest path={}",
443 manifest_path.display()
444 );
445 Ok(())
446}
447
448#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn build_with_empty_output_dir_should_return_invalid_config() {
458 let result = LiveAbrLadder::new("")
459 .add_rendition(AbrRendition {
460 width: 1280,
461 height: 720,
462 video_bitrate: 2_000_000,
463 audio_bitrate: 128_000,
464 name: None,
465 })
466 .fps(30.0)
467 .build();
468 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
469 }
470
471 #[test]
472 fn build_with_no_renditions_should_return_invalid_config() {
473 let result = LiveAbrLadder::new("/tmp/live_abr_test_no_renditions")
474 .fps(30.0)
475 .build();
476 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
477 }
478
479 #[test]
480 fn build_without_fps_should_return_invalid_config() {
481 let result = LiveAbrLadder::new("/tmp/live_abr_test_no_fps")
482 .add_rendition(AbrRendition {
483 width: 1280,
484 height: 720,
485 video_bitrate: 2_000_000,
486 audio_bitrate: 128_000,
487 name: None,
488 })
489 .build();
490 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
491 }
492
493 #[test]
494 fn segment_duration_default_should_be_six_seconds() {
495 let ladder = LiveAbrLadder::new("/tmp/x");
496 assert_eq!(ladder.segment_duration, Duration::from_secs(6));
497 }
498
499 #[test]
500 fn playlist_size_default_should_be_five() {
501 let ladder = LiveAbrLadder::new("/tmp/x");
502 assert_eq!(ladder.playlist_size, 5);
503 }
504
505 #[test]
506 fn abr_rendition_dir_name_default_should_use_resolution() {
507 let r = AbrRendition {
508 width: 1920,
509 height: 1080,
510 video_bitrate: 4_000_000,
511 audio_bitrate: 192_000,
512 name: None,
513 };
514 assert_eq!(r.dir_name(), "1920x1080");
515 }
516
517 #[test]
518 fn abr_rendition_dir_name_custom_should_use_name() {
519 let r = AbrRendition {
520 width: 1280,
521 height: 720,
522 video_bitrate: 2_000_000,
523 audio_bitrate: 128_000,
524 name: Some("720p".into()),
525 };
526 assert_eq!(r.dir_name(), "720p");
527 }
528}