1#![allow(dead_code)]
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub enum StreamCopyMode {
15 ReEncode,
17 CopyVideo,
19 CopyAudio,
21 CopyAll,
23 Auto,
25}
26
27impl Default for StreamCopyMode {
28 fn default() -> Self {
29 Self::ReEncode
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct StreamInfo {
36 pub codec: String,
38 pub stream_type: StreamType,
40 pub width: Option<u32>,
42 pub height: Option<u32>,
44 pub sample_rate: Option<u32>,
46 pub channels: Option<u8>,
48 pub bitrate: Option<u64>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum StreamType {
55 Video,
57 Audio,
59 Subtitle,
61 Data,
63}
64
65impl StreamInfo {
66 #[must_use]
68 pub fn video(codec: impl Into<String>, width: u32, height: u32) -> Self {
69 Self {
70 codec: codec.into(),
71 stream_type: StreamType::Video,
72 width: Some(width),
73 height: Some(height),
74 sample_rate: None,
75 channels: None,
76 bitrate: None,
77 }
78 }
79
80 #[must_use]
82 pub fn audio(codec: impl Into<String>, sample_rate: u32, channels: u8) -> Self {
83 Self {
84 codec: codec.into(),
85 stream_type: StreamType::Audio,
86 width: None,
87 height: None,
88 sample_rate: Some(sample_rate),
89 channels: Some(channels),
90 bitrate: None,
91 }
92 }
93
94 #[must_use]
96 pub fn with_bitrate(mut self, bitrate: u64) -> Self {
97 self.bitrate = Some(bitrate);
98 self
99 }
100}
101
102#[derive(Debug, Clone)]
104pub struct StreamCopyConfig {
105 pub mode: StreamCopyMode,
107 pub target_video_codec: Option<String>,
109 pub target_audio_codec: Option<String>,
111 pub target_width: Option<u32>,
113 pub target_height: Option<u32>,
115 pub has_video_filters: bool,
117 pub has_audio_filters: bool,
119}
120
121impl Default for StreamCopyConfig {
122 fn default() -> Self {
123 Self {
124 mode: StreamCopyMode::Auto,
125 target_video_codec: None,
126 target_audio_codec: None,
127 target_width: None,
128 target_height: None,
129 has_video_filters: false,
130 has_audio_filters: false,
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct CopyDecision {
138 pub copy_video: bool,
140 pub copy_audio: bool,
142 pub video_reason: Option<String>,
144 pub audio_reason: Option<String>,
146}
147
148impl CopyDecision {
149 #[must_use]
151 pub fn any_copy(&self) -> bool {
152 self.copy_video || self.copy_audio
153 }
154
155 #[must_use]
157 pub fn full_remux(&self) -> bool {
158 self.copy_video && self.copy_audio
159 }
160
161 #[must_use]
163 pub fn effective_mode(&self) -> StreamCopyMode {
164 match (self.copy_video, self.copy_audio) {
165 (true, true) => StreamCopyMode::CopyAll,
166 (true, false) => StreamCopyMode::CopyVideo,
167 (false, true) => StreamCopyMode::CopyAudio,
168 (false, false) => StreamCopyMode::ReEncode,
169 }
170 }
171}
172
173pub struct StreamCopyDetector;
175
176impl StreamCopyDetector {
177 #[must_use]
180 pub fn evaluate(
181 input_video: Option<&StreamInfo>,
182 input_audio: Option<&StreamInfo>,
183 config: &StreamCopyConfig,
184 ) -> CopyDecision {
185 match config.mode {
187 StreamCopyMode::ReEncode => {
188 return CopyDecision {
189 copy_video: false,
190 copy_audio: false,
191 video_reason: Some("Re-encode mode selected".to_string()),
192 audio_reason: Some("Re-encode mode selected".to_string()),
193 };
194 }
195 StreamCopyMode::CopyAll => {
196 return CopyDecision {
197 copy_video: input_video.is_some(),
198 copy_audio: input_audio.is_some(),
199 video_reason: if input_video.is_none() {
200 Some("No video stream".to_string())
201 } else {
202 None
203 },
204 audio_reason: if input_audio.is_none() {
205 Some("No audio stream".to_string())
206 } else {
207 None
208 },
209 };
210 }
211 StreamCopyMode::CopyVideo => {
212 let (copy_v, v_reason) = Self::check_video_copy(input_video, config);
213 return CopyDecision {
214 copy_video: copy_v,
215 copy_audio: false,
216 video_reason: v_reason,
217 audio_reason: Some("Copy-video mode: audio will be re-encoded".to_string()),
218 };
219 }
220 StreamCopyMode::CopyAudio => {
221 let (copy_a, a_reason) = Self::check_audio_copy(input_audio, config);
222 return CopyDecision {
223 copy_video: false,
224 copy_audio: copy_a,
225 video_reason: Some("Copy-audio mode: video will be re-encoded".to_string()),
226 audio_reason: a_reason,
227 };
228 }
229 StreamCopyMode::Auto => {
230 }
232 }
233
234 let (copy_v, v_reason) = Self::check_video_copy(input_video, config);
236 let (copy_a, a_reason) = Self::check_audio_copy(input_audio, config);
237
238 CopyDecision {
239 copy_video: copy_v,
240 copy_audio: copy_a,
241 video_reason: v_reason,
242 audio_reason: a_reason,
243 }
244 }
245
246 fn check_video_copy(
248 input: Option<&StreamInfo>,
249 config: &StreamCopyConfig,
250 ) -> (bool, Option<String>) {
251 let Some(stream) = input else {
252 return (false, Some("No video stream present".to_string()));
253 };
254
255 if config.has_video_filters {
256 return (
257 false,
258 Some("Video filters are applied; re-encoding required".to_string()),
259 );
260 }
261
262 if let Some(target_codec) = &config.target_video_codec {
264 if !Self::codecs_match(&stream.codec, target_codec) {
265 return (
266 false,
267 Some(format!(
268 "Codec mismatch: source={}, target={}",
269 stream.codec, target_codec
270 )),
271 );
272 }
273 }
274
275 if let (Some(tw), Some(th)) = (config.target_width, config.target_height) {
277 if let (Some(sw), Some(sh)) = (stream.width, stream.height) {
278 if sw != tw || sh != th {
279 return (
280 false,
281 Some(format!(
282 "Resolution mismatch: source={sw}x{sh}, target={tw}x{th}"
283 )),
284 );
285 }
286 }
287 }
288
289 (true, None)
290 }
291
292 fn check_audio_copy(
294 input: Option<&StreamInfo>,
295 config: &StreamCopyConfig,
296 ) -> (bool, Option<String>) {
297 let Some(stream) = input else {
298 return (false, Some("No audio stream present".to_string()));
299 };
300
301 if config.has_audio_filters {
302 return (
303 false,
304 Some("Audio filters are applied; re-encoding required".to_string()),
305 );
306 }
307
308 if let Some(target_codec) = &config.target_audio_codec {
309 if !Self::codecs_match(&stream.codec, target_codec) {
310 return (
311 false,
312 Some(format!(
313 "Codec mismatch: source={}, target={}",
314 stream.codec, target_codec
315 )),
316 );
317 }
318 }
319
320 (true, None)
321 }
322
323 fn codecs_match(a: &str, b: &str) -> bool {
327 let na = Self::normalise_codec(a);
328 let nb = Self::normalise_codec(b);
329 na == nb
330 }
331
332 fn normalise_codec(name: &str) -> &str {
334 match name {
335 "libvpx-vp9" | "libvpx_vp9" => "vp9",
336 "libvpx" | "libvpx-vp8" => "vp8",
337 "libaom-av1" | "libaom_av1" | "svt-av1" | "svt_av1" | "rav1e" => "av1",
338 "libx264" | "x264" | "h.264" | "avc" => "h264",
339 "libx265" | "x265" | "h.265" => "hevc",
340 "libopus" => "opus",
341 "libvorbis" => "vorbis",
342 "pcm_s16le" | "pcm_s24le" | "pcm_s32le" | "pcm_f32le" => "pcm",
343 other => other,
344 }
345 }
346}
347
348pub const STREAM_COPY_SPEEDUP_FACTOR: f64 = 50.0;
353
354#[must_use]
366pub fn estimate_time_saved(
367 duration_secs: f64,
368 encode_speed_factor: f64,
369 decision: &CopyDecision,
370) -> f64 {
371 if duration_secs <= 0.0 || encode_speed_factor <= 0.0 {
372 return 0.0;
373 }
374
375 let encode_time = duration_secs * encode_speed_factor;
376 let copy_time = duration_secs / STREAM_COPY_SPEEDUP_FACTOR;
377
378 let video_weight = 0.8;
380 let audio_weight = 0.2;
381
382 let mut saved = 0.0;
383 if decision.copy_video {
384 saved += (encode_time - copy_time) * video_weight;
385 }
386 if decision.copy_audio {
387 saved += (encode_time - copy_time) * audio_weight;
388 }
389
390 saved.max(0.0)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
400 fn test_default_mode_is_reencode() {
401 assert_eq!(StreamCopyMode::default(), StreamCopyMode::ReEncode);
402 }
403
404 #[test]
405 fn test_stream_copy_mode_equality() {
406 assert_eq!(StreamCopyMode::Auto, StreamCopyMode::Auto);
407 assert_ne!(StreamCopyMode::Auto, StreamCopyMode::CopyAll);
408 }
409
410 #[test]
413 fn test_video_stream_info() {
414 let info = StreamInfo::video("vp9", 1920, 1080);
415 assert_eq!(info.codec, "vp9");
416 assert_eq!(info.stream_type, StreamType::Video);
417 assert_eq!(info.width, Some(1920));
418 assert_eq!(info.height, Some(1080));
419 assert!(info.sample_rate.is_none());
420 }
421
422 #[test]
423 fn test_audio_stream_info() {
424 let info = StreamInfo::audio("opus", 48000, 2);
425 assert_eq!(info.codec, "opus");
426 assert_eq!(info.stream_type, StreamType::Audio);
427 assert_eq!(info.sample_rate, Some(48000));
428 assert_eq!(info.channels, Some(2));
429 assert!(info.width.is_none());
430 }
431
432 #[test]
433 fn test_stream_info_with_bitrate() {
434 let info = StreamInfo::video("av1", 3840, 2160).with_bitrate(15_000_000);
435 assert_eq!(info.bitrate, Some(15_000_000));
436 }
437
438 #[test]
441 fn test_normalise_codec_vp9_aliases() {
442 assert_eq!(StreamCopyDetector::normalise_codec("vp9"), "vp9");
443 assert_eq!(StreamCopyDetector::normalise_codec("libvpx-vp9"), "vp9");
444 assert_eq!(StreamCopyDetector::normalise_codec("libvpx_vp9"), "vp9");
445 }
446
447 #[test]
448 fn test_normalise_codec_av1_aliases() {
449 assert_eq!(StreamCopyDetector::normalise_codec("av1"), "av1");
450 assert_eq!(StreamCopyDetector::normalise_codec("libaom-av1"), "av1");
451 assert_eq!(StreamCopyDetector::normalise_codec("svt-av1"), "av1");
452 assert_eq!(StreamCopyDetector::normalise_codec("rav1e"), "av1");
453 }
454
455 #[test]
456 fn test_normalise_codec_h264_aliases() {
457 assert_eq!(StreamCopyDetector::normalise_codec("h264"), "h264");
458 assert_eq!(StreamCopyDetector::normalise_codec("libx264"), "h264");
459 assert_eq!(StreamCopyDetector::normalise_codec("avc"), "h264");
460 }
461
462 #[test]
463 fn test_normalise_codec_opus_aliases() {
464 assert_eq!(StreamCopyDetector::normalise_codec("opus"), "opus");
465 assert_eq!(StreamCopyDetector::normalise_codec("libopus"), "opus");
466 }
467
468 #[test]
469 fn test_normalise_codec_unknown() {
470 assert_eq!(
471 StreamCopyDetector::normalise_codec("custom_codec"),
472 "custom_codec"
473 );
474 }
475
476 #[test]
479 fn test_copy_decision_full_remux() {
480 let d = CopyDecision {
481 copy_video: true,
482 copy_audio: true,
483 video_reason: None,
484 audio_reason: None,
485 };
486 assert!(d.full_remux());
487 assert!(d.any_copy());
488 assert_eq!(d.effective_mode(), StreamCopyMode::CopyAll);
489 }
490
491 #[test]
492 fn test_copy_decision_video_only() {
493 let d = CopyDecision {
494 copy_video: true,
495 copy_audio: false,
496 video_reason: None,
497 audio_reason: Some("mismatch".to_string()),
498 };
499 assert!(!d.full_remux());
500 assert!(d.any_copy());
501 assert_eq!(d.effective_mode(), StreamCopyMode::CopyVideo);
502 }
503
504 #[test]
505 fn test_copy_decision_audio_only() {
506 let d = CopyDecision {
507 copy_video: false,
508 copy_audio: true,
509 video_reason: Some("mismatch".to_string()),
510 audio_reason: None,
511 };
512 assert_eq!(d.effective_mode(), StreamCopyMode::CopyAudio);
513 }
514
515 #[test]
516 fn test_copy_decision_reencode() {
517 let d = CopyDecision {
518 copy_video: false,
519 copy_audio: false,
520 video_reason: Some("mismatch".to_string()),
521 audio_reason: Some("mismatch".to_string()),
522 };
523 assert!(!d.any_copy());
524 assert_eq!(d.effective_mode(), StreamCopyMode::ReEncode);
525 }
526
527 #[test]
530 fn test_auto_matching_codecs_copies_both() {
531 let video = StreamInfo::video("vp9", 1920, 1080);
532 let audio = StreamInfo::audio("opus", 48000, 2);
533 let config = StreamCopyConfig {
534 mode: StreamCopyMode::Auto,
535 target_video_codec: Some("vp9".to_string()),
536 target_audio_codec: Some("opus".to_string()),
537 ..StreamCopyConfig::default()
538 };
539
540 let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
541 assert!(decision.copy_video);
542 assert!(decision.copy_audio);
543 assert!(decision.full_remux());
544 }
545
546 #[test]
547 fn test_auto_mismatched_video_codec() {
548 let video = StreamInfo::video("h264", 1920, 1080);
549 let audio = StreamInfo::audio("opus", 48000, 2);
550 let config = StreamCopyConfig {
551 mode: StreamCopyMode::Auto,
552 target_video_codec: Some("vp9".to_string()),
553 target_audio_codec: Some("opus".to_string()),
554 ..StreamCopyConfig::default()
555 };
556
557 let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
558 assert!(!decision.copy_video);
559 assert!(decision.copy_audio);
560 assert!(decision.video_reason.is_some());
561 }
562
563 #[test]
564 fn test_auto_mismatched_resolution() {
565 let video = StreamInfo::video("vp9", 1280, 720);
566 let config = StreamCopyConfig {
567 mode: StreamCopyMode::Auto,
568 target_video_codec: Some("vp9".to_string()),
569 target_width: Some(1920),
570 target_height: Some(1080),
571 ..StreamCopyConfig::default()
572 };
573
574 let decision = StreamCopyDetector::evaluate(Some(&video), None, &config);
575 assert!(!decision.copy_video);
576 assert!(decision
577 .video_reason
578 .as_deref()
579 .map_or(false, |r| r.contains("Resolution")));
580 }
581
582 #[test]
583 fn test_auto_with_video_filters_forces_reencode() {
584 let video = StreamInfo::video("vp9", 1920, 1080);
585 let config = StreamCopyConfig {
586 mode: StreamCopyMode::Auto,
587 target_video_codec: Some("vp9".to_string()),
588 has_video_filters: true,
589 ..StreamCopyConfig::default()
590 };
591
592 let decision = StreamCopyDetector::evaluate(Some(&video), None, &config);
593 assert!(!decision.copy_video);
594 }
595
596 #[test]
597 fn test_auto_with_audio_filters_forces_reencode() {
598 let audio = StreamInfo::audio("opus", 48000, 2);
599 let config = StreamCopyConfig {
600 mode: StreamCopyMode::Auto,
601 target_audio_codec: Some("opus".to_string()),
602 has_audio_filters: true,
603 ..StreamCopyConfig::default()
604 };
605
606 let decision = StreamCopyDetector::evaluate(None, Some(&audio), &config);
607 assert!(!decision.copy_audio);
608 }
609
610 #[test]
611 fn test_explicit_reencode_mode() {
612 let video = StreamInfo::video("vp9", 1920, 1080);
613 let audio = StreamInfo::audio("opus", 48000, 2);
614 let config = StreamCopyConfig {
615 mode: StreamCopyMode::ReEncode,
616 target_video_codec: Some("vp9".to_string()),
617 target_audio_codec: Some("opus".to_string()),
618 ..StreamCopyConfig::default()
619 };
620
621 let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
622 assert!(!decision.copy_video);
623 assert!(!decision.copy_audio);
624 }
625
626 #[test]
627 fn test_explicit_copy_all_mode() {
628 let video = StreamInfo::video("h264", 1920, 1080);
629 let audio = StreamInfo::audio("aac", 44100, 2);
630 let config = StreamCopyConfig {
631 mode: StreamCopyMode::CopyAll,
632 target_video_codec: Some("vp9".to_string()),
633 target_audio_codec: Some("opus".to_string()),
634 ..StreamCopyConfig::default()
635 };
636
637 let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
639 assert!(decision.copy_video);
640 assert!(decision.copy_audio);
641 }
642
643 #[test]
644 fn test_explicit_copy_video_mode() {
645 let video = StreamInfo::video("vp9", 1920, 1080);
646 let audio = StreamInfo::audio("opus", 48000, 2);
647 let config = StreamCopyConfig {
648 mode: StreamCopyMode::CopyVideo,
649 target_video_codec: Some("vp9".to_string()),
650 target_audio_codec: Some("opus".to_string()),
651 ..StreamCopyConfig::default()
652 };
653
654 let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
655 assert!(decision.copy_video);
656 assert!(!decision.copy_audio);
657 }
658
659 #[test]
660 fn test_explicit_copy_audio_mode() {
661 let video = StreamInfo::video("vp9", 1920, 1080);
662 let audio = StreamInfo::audio("opus", 48000, 2);
663 let config = StreamCopyConfig {
664 mode: StreamCopyMode::CopyAudio,
665 target_video_codec: Some("vp9".to_string()),
666 target_audio_codec: Some("opus".to_string()),
667 ..StreamCopyConfig::default()
668 };
669
670 let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
671 assert!(!decision.copy_video);
672 assert!(decision.copy_audio);
673 }
674
675 #[test]
676 fn test_auto_no_target_codec_allows_copy() {
677 let video = StreamInfo::video("av1", 1920, 1080);
678 let audio = StreamInfo::audio("opus", 48000, 2);
679 let config = StreamCopyConfig::default(); let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
682 assert!(decision.copy_video);
684 assert!(decision.copy_audio);
685 }
686
687 #[test]
688 fn test_codec_alias_matching() {
689 let video = StreamInfo::video("libvpx-vp9", 1920, 1080);
690 let config = StreamCopyConfig {
691 mode: StreamCopyMode::Auto,
692 target_video_codec: Some("vp9".to_string()),
693 ..StreamCopyConfig::default()
694 };
695
696 let decision = StreamCopyDetector::evaluate(Some(&video), None, &config);
697 assert!(decision.copy_video, "libvpx-vp9 should match vp9");
698 }
699
700 #[test]
701 fn test_no_streams_present() {
702 let config = StreamCopyConfig::default();
703 let decision = StreamCopyDetector::evaluate(None, None, &config);
704 assert!(!decision.copy_video);
705 assert!(!decision.copy_audio);
706 }
707
708 #[test]
711 fn test_estimate_time_saved_full_remux() {
712 let decision = CopyDecision {
713 copy_video: true,
714 copy_audio: true,
715 video_reason: None,
716 audio_reason: None,
717 };
718 let saved = estimate_time_saved(60.0, 5.0, &decision);
719 assert!(saved > 0.0, "Should save time with full remux");
720 }
721
722 #[test]
723 fn test_estimate_time_saved_no_copy() {
724 let decision = CopyDecision {
725 copy_video: false,
726 copy_audio: false,
727 video_reason: Some("mismatch".to_string()),
728 audio_reason: Some("mismatch".to_string()),
729 };
730 let saved = estimate_time_saved(60.0, 5.0, &decision);
731 assert!(
732 (saved - 0.0).abs() < f64::EPSILON,
733 "No time saved without copy"
734 );
735 }
736
737 #[test]
738 fn test_estimate_time_saved_zero_duration() {
739 let decision = CopyDecision {
740 copy_video: true,
741 copy_audio: true,
742 video_reason: None,
743 audio_reason: None,
744 };
745 assert_eq!(estimate_time_saved(0.0, 5.0, &decision), 0.0);
746 }
747
748 #[test]
749 fn test_estimate_time_saved_negative_duration() {
750 let decision = CopyDecision {
751 copy_video: true,
752 copy_audio: true,
753 video_reason: None,
754 audio_reason: None,
755 };
756 assert_eq!(estimate_time_saved(-10.0, 5.0, &decision), 0.0);
757 }
758
759 #[test]
760 fn test_estimate_time_saved_video_only_copy() {
761 let full = CopyDecision {
762 copy_video: true,
763 copy_audio: true,
764 video_reason: None,
765 audio_reason: None,
766 };
767 let video_only = CopyDecision {
768 copy_video: true,
769 copy_audio: false,
770 video_reason: None,
771 audio_reason: Some("mismatch".to_string()),
772 };
773 let full_saved = estimate_time_saved(60.0, 5.0, &full);
774 let video_saved = estimate_time_saved(60.0, 5.0, &video_only);
775 assert!(video_saved > 0.0);
776 assert!(
777 video_saved < full_saved,
778 "Video-only should save less than full remux"
779 );
780 }
781}