Skip to main content

oximedia_transcode/
stream_copy.rs

1//! Stream copy (passthrough) mode for transcoding.
2//!
3//! When the source codec matches the desired output codec and no filters
4//! (resize, crop, tone-map, etc.) are required, the stream can be copied
5//! bit-for-bit without re-encoding. This is dramatically faster and
6//! preserves the original quality.
7
8#![allow(dead_code)]
9
10use serde::{Deserialize, Serialize};
11
12/// Describes which streams should be copied vs. re-encoded.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub enum StreamCopyMode {
15    /// Re-encode everything (default transcoding behaviour).
16    ReEncode,
17    /// Copy the video stream verbatim; re-encode audio.
18    CopyVideo,
19    /// Copy the audio stream verbatim; re-encode video.
20    CopyAudio,
21    /// Copy both video and audio streams (remux only).
22    CopyAll,
23    /// Automatic: detect matching codecs and copy where possible.
24    Auto,
25}
26
27impl Default for StreamCopyMode {
28    fn default() -> Self {
29        Self::ReEncode
30    }
31}
32
33/// Information about a single media stream used for copy-eligibility checks.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct StreamInfo {
36    /// Codec identifier (e.g. "av1", "vp9", "opus", "flac").
37    pub codec: String,
38    /// Stream type.
39    pub stream_type: StreamType,
40    /// Width in pixels (video only).
41    pub width: Option<u32>,
42    /// Height in pixels (video only).
43    pub height: Option<u32>,
44    /// Sample rate in Hz (audio only).
45    pub sample_rate: Option<u32>,
46    /// Number of audio channels (audio only).
47    pub channels: Option<u8>,
48    /// Bitrate in bits per second (if known).
49    pub bitrate: Option<u64>,
50}
51
52/// Type of a media stream.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum StreamType {
55    /// Video stream.
56    Video,
57    /// Audio stream.
58    Audio,
59    /// Subtitle stream.
60    Subtitle,
61    /// Data / metadata stream.
62    Data,
63}
64
65impl StreamInfo {
66    /// Creates a video stream info.
67    #[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    /// Creates an audio stream info.
81    #[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    /// Sets the bitrate.
95    #[must_use]
96    pub fn with_bitrate(mut self, bitrate: u64) -> Self {
97        self.bitrate = Some(bitrate);
98        self
99    }
100}
101
102/// Configuration for stream copy decisions.
103#[derive(Debug, Clone)]
104pub struct StreamCopyConfig {
105    /// The copy mode to use.
106    pub mode: StreamCopyMode,
107    /// Desired output video codec (if any).
108    pub target_video_codec: Option<String>,
109    /// Desired output audio codec (if any).
110    pub target_audio_codec: Option<String>,
111    /// Desired output width (if resizing is needed).
112    pub target_width: Option<u32>,
113    /// Desired output height (if resizing is needed).
114    pub target_height: Option<u32>,
115    /// Whether any video filters are applied (forces re-encode).
116    pub has_video_filters: bool,
117    /// Whether any audio filters are applied (forces re-encode).
118    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/// Result of a stream copy eligibility check.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct CopyDecision {
138    /// Whether the video stream can be copied.
139    pub copy_video: bool,
140    /// Whether the audio stream can be copied.
141    pub copy_audio: bool,
142    /// Reason video cannot be copied (if applicable).
143    pub video_reason: Option<String>,
144    /// Reason audio cannot be copied (if applicable).
145    pub audio_reason: Option<String>,
146}
147
148impl CopyDecision {
149    /// Returns `true` if at least one stream can be copied.
150    #[must_use]
151    pub fn any_copy(&self) -> bool {
152        self.copy_video || self.copy_audio
153    }
154
155    /// Returns `true` if all streams can be copied (full remux).
156    #[must_use]
157    pub fn full_remux(&self) -> bool {
158        self.copy_video && self.copy_audio
159    }
160
161    /// Returns the effective `StreamCopyMode` based on the decision.
162    #[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
173/// Detects whether streams can be copied without re-encoding.
174pub struct StreamCopyDetector;
175
176impl StreamCopyDetector {
177    /// Checks whether the given input stream can be copied to the output
178    /// given the configuration constraints.
179    #[must_use]
180    pub fn evaluate(
181        input_video: Option<&StreamInfo>,
182        input_audio: Option<&StreamInfo>,
183        config: &StreamCopyConfig,
184    ) -> CopyDecision {
185        // Handle explicit modes first
186        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                // Fall through to auto-detection below
231            }
232        }
233
234        // Auto mode: check each stream independently
235        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    /// Checks whether the video stream is eligible for copy.
247    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        // Check codec match
263        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        // Check resolution match (if target resolution is specified)
276        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    /// Checks whether the audio stream is eligible for copy.
293    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    /// Normalised codec name comparison.
324    ///
325    /// Handles common aliases like "libvpx-vp9" == "vp9", "libopus" == "opus", etc.
326    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    /// Normalises a codec name to a canonical form.
333    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
348/// Estimated speedup factor when stream-copying vs. re-encoding.
349///
350/// Typically stream copy is 50-100x faster than re-encoding, since it
351/// only involves demuxing and remuxing without any pixel processing.
352pub const STREAM_COPY_SPEEDUP_FACTOR: f64 = 50.0;
353
354/// Estimates the time saved by stream copying instead of re-encoding.
355///
356/// # Arguments
357///
358/// * `duration_secs` - Source duration in seconds.
359/// * `encode_speed_factor` - Encoding speed factor (from `TranscodeEstimator`).
360/// * `decision` - The copy decision from `StreamCopyDetector`.
361///
362/// # Returns
363///
364/// Estimated time saved in seconds.
365#[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    // Weight: video is typically 80% of encoding time, audio 20%
379    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    // ── StreamCopyMode tests ────────────────────────────────────────────
398
399    #[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    // ── StreamInfo constructors ─────────────────────────────────────────
411
412    #[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    // ── Codec normalisation ─────────────────────────────────────────────
439
440    #[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    // ── CopyDecision helpers ────────────────────────────────────────────
477
478    #[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    // ── StreamCopyDetector evaluate ─────────────────────────────────────
528
529    #[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        // CopyAll forces copy regardless of codec mismatch
638        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(); // Auto, no target codecs
680
681        let decision = StreamCopyDetector::evaluate(Some(&video), Some(&audio), &config);
682        // When no target codec is specified, copy is allowed
683        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    // ── estimate_time_saved ─────────────────────────────────────────────
709
710    #[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}