Skip to main content

oximedia_transcode/
concat_transcode.rs

1#![allow(dead_code)]
2//! Concatenation and joining of multiple media sources into a single output.
3//!
4//! Handles cross-format joining with optional transition effects between
5//! segments, automatic audio/video alignment, and gap filling.
6//!
7//! # Mixed-Source Concatenation
8//!
9//! [`MixedSourceConcatenator`] handles sequences where sources have different
10//! resolutions, frame rates, or codecs.  It analyses each source segment,
11//! determines whether re-encoding is required, and produces a [`ConcatPlan`]
12//! that the caller can execute.
13
14use std::fmt;
15
16/// Strategy for handling format mismatches between segments.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ConformStrategy {
19    /// Re-encode every segment to match the first segment's format.
20    ReEncodeAll,
21    /// Re-encode only segments that differ from the target format.
22    ReEncodeDiffers,
23    /// Attempt stream-copy where possible (fastest, may fail on mismatches).
24    StreamCopy,
25}
26
27impl fmt::Display for ConformStrategy {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::ReEncodeAll => write!(f, "re-encode-all"),
31            Self::ReEncodeDiffers => write!(f, "re-encode-differs"),
32            Self::StreamCopy => write!(f, "stream-copy"),
33        }
34    }
35}
36
37/// Transition type between consecutive segments.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum TransitionKind {
40    /// Hard cut with no transition.
41    Cut,
42    /// Crossfade of the specified duration in seconds.
43    Crossfade(f64),
44    /// Fade to black then fade from black.
45    FadeThrough(f64),
46}
47
48impl TransitionKind {
49    /// Return the duration in seconds (0.0 for a hard cut).
50    #[must_use]
51    pub fn duration(&self) -> f64 {
52        match self {
53            Self::Cut => 0.0,
54            Self::Crossfade(d) | Self::FadeThrough(d) => *d,
55        }
56    }
57}
58
59/// A single input segment in the concat list.
60#[derive(Debug, Clone)]
61pub struct ConcatSegment {
62    /// Path or URI to the source media.
63    pub source: String,
64    /// Optional in-point in seconds (trim start).
65    pub in_point: Option<f64>,
66    /// Optional out-point in seconds (trim end).
67    pub out_point: Option<f64>,
68    /// Transition to apply *after* this segment (before the next).
69    pub transition: TransitionKind,
70}
71
72impl ConcatSegment {
73    /// Create a segment from a source path with defaults (full duration, hard cut).
74    pub fn new(source: impl Into<String>) -> Self {
75        Self {
76            source: source.into(),
77            in_point: None,
78            out_point: None,
79            transition: TransitionKind::Cut,
80        }
81    }
82
83    /// Set in-point.
84    #[must_use]
85    pub fn with_in_point(mut self, seconds: f64) -> Self {
86        self.in_point = Some(seconds);
87        self
88    }
89
90    /// Set out-point.
91    #[must_use]
92    pub fn with_out_point(mut self, seconds: f64) -> Self {
93        self.out_point = Some(seconds);
94        self
95    }
96
97    /// Set transition after this segment.
98    #[must_use]
99    pub fn with_transition(mut self, t: TransitionKind) -> Self {
100        self.transition = t;
101        self
102    }
103
104    /// Compute effective duration (returns `None` when both points are absent).
105    #[must_use]
106    pub fn effective_duration(&self) -> Option<f64> {
107        match (self.in_point, self.out_point) {
108            (Some(i), Some(o)) => Some((o - i).max(0.0)),
109            _ => None,
110        }
111    }
112}
113
114/// Overall concat job configuration.
115#[derive(Debug, Clone)]
116pub struct ConcatConfig {
117    /// Ordered list of segments.
118    pub segments: Vec<ConcatSegment>,
119    /// Output path.
120    pub output: String,
121    /// Conforming strategy.
122    pub conform: ConformStrategy,
123    /// Target video width (if re-encoding).
124    pub target_width: Option<u32>,
125    /// Target video height (if re-encoding).
126    pub target_height: Option<u32>,
127    /// Target frame rate numerator / denominator (if re-encoding).
128    pub target_fps: Option<(u32, u32)>,
129    /// Target audio sample rate.
130    pub target_sample_rate: Option<u32>,
131}
132
133impl ConcatConfig {
134    /// Create a new concat configuration.
135    pub fn new(output: impl Into<String>) -> Self {
136        Self {
137            segments: Vec::new(),
138            output: output.into(),
139            conform: ConformStrategy::ReEncodeDiffers,
140            target_width: None,
141            target_height: None,
142            target_fps: None,
143            target_sample_rate: None,
144        }
145    }
146
147    /// Add a segment.
148    pub fn add_segment(&mut self, seg: ConcatSegment) {
149        self.segments.push(seg);
150    }
151
152    /// Set conforming strategy.
153    #[must_use]
154    pub fn with_conform(mut self, strategy: ConformStrategy) -> Self {
155        self.conform = strategy;
156        self
157    }
158
159    /// Set target resolution.
160    #[must_use]
161    pub fn with_resolution(mut self, w: u32, h: u32) -> Self {
162        self.target_width = Some(w);
163        self.target_height = Some(h);
164        self
165    }
166
167    /// Set target frame rate.
168    #[must_use]
169    pub fn with_fps(mut self, num: u32, den: u32) -> Self {
170        self.target_fps = Some((num, den));
171        self
172    }
173
174    /// Set target audio sample rate.
175    #[must_use]
176    pub fn with_sample_rate(mut self, rate: u32) -> Self {
177        self.target_sample_rate = Some(rate);
178        self
179    }
180
181    /// Return total number of segments.
182    #[must_use]
183    pub fn segment_count(&self) -> usize {
184        self.segments.len()
185    }
186
187    /// Compute the total transition time between segments.
188    #[must_use]
189    pub fn total_transition_time(&self) -> f64 {
190        self.segments.iter().map(|s| s.transition.duration()).sum()
191    }
192
193    /// Compute the total known content duration (sum of effective durations).
194    /// Segments without known duration are excluded.
195    #[must_use]
196    pub fn total_known_duration(&self) -> f64 {
197        self.segments
198            .iter()
199            .filter_map(ConcatSegment::effective_duration)
200            .sum()
201    }
202}
203
204/// Result of a concat operation.
205#[derive(Debug, Clone)]
206pub struct ConcatResult {
207    /// Output file path.
208    pub output_path: String,
209    /// Number of segments joined.
210    pub segments_joined: usize,
211    /// Total output duration in seconds.
212    pub total_duration: f64,
213    /// Number of segments that required re-encoding.
214    pub re_encoded_count: usize,
215}
216
217/// Validate a concat configuration and return a list of issues (empty = valid).
218#[must_use]
219pub fn validate_concat(config: &ConcatConfig) -> Vec<String> {
220    let mut issues = Vec::new();
221    if config.segments.is_empty() {
222        issues.push("No segments specified".to_string());
223    }
224    if config.output.is_empty() {
225        issues.push("Output path is empty".to_string());
226    }
227    for (i, seg) in config.segments.iter().enumerate() {
228        if seg.source.is_empty() {
229            issues.push(format!("Segment {i} has empty source path"));
230        }
231        if let (Some(inp), Some(out)) = (seg.in_point, seg.out_point) {
232            if out <= inp {
233                issues.push(format!("Segment {i} out-point ({out}) <= in-point ({inp})"));
234            }
235        }
236    }
237    issues
238}
239
240// ─── Mixed-source concatenation ───────────────────────────────────────────────
241
242/// Codec and format properties of a single source segment.
243///
244/// Obtained by probing the source file before planning.  Filling in accurate
245/// values is the caller's responsibility; the planner uses these to decide
246/// whether re-encoding is necessary.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct SourceProperties {
249    /// Video codec identifier (e.g. `"h264"`, `"vp9"`, `"av1"`).
250    pub codec: String,
251    /// Frame width in pixels.
252    pub width: u32,
253    /// Frame height in pixels.
254    pub height: u32,
255    /// Frame rate numerator.
256    pub fps_num: u32,
257    /// Frame rate denominator.
258    pub fps_den: u32,
259    /// Audio sample rate in Hz.
260    pub sample_rate: u32,
261    /// Audio codec identifier (e.g. `"aac"`, `"opus"`).
262    pub audio_codec: String,
263}
264
265impl SourceProperties {
266    /// Returns `(width, height)`.
267    #[must_use]
268    pub fn resolution(&self) -> (u32, u32) {
269        (self.width, self.height)
270    }
271
272    /// Returns the frame rate as a floating-point number.
273    ///
274    /// Returns `0.0` when `fps_den` is zero.
275    #[must_use]
276    pub fn fps(&self) -> f64 {
277        if self.fps_den == 0 {
278            return 0.0;
279        }
280        f64::from(self.fps_num) / f64::from(self.fps_den)
281    }
282
283    /// Returns `true` when `other` has the same video codec, resolution,
284    /// frame rate, audio codec, and sample rate.
285    #[must_use]
286    pub fn is_compatible_with(&self, other: &Self) -> bool {
287        self.codec == other.codec
288            && self.width == other.width
289            && self.height == other.height
290            && self.fps_num == other.fps_num
291            && self.fps_den == other.fps_den
292            && self.sample_rate == other.sample_rate
293            && self.audio_codec == other.audio_codec
294    }
295}
296
297/// A [`ConcatSegment`] annotated with its probed source properties.
298#[derive(Debug, Clone)]
299pub struct AnnotatedSegment {
300    /// The segment specification.
301    pub segment: ConcatSegment,
302    /// Probed source properties.
303    pub properties: SourceProperties,
304}
305
306impl AnnotatedSegment {
307    /// Creates an annotated segment.
308    #[must_use]
309    pub fn new(segment: ConcatSegment, properties: SourceProperties) -> Self {
310        Self {
311            segment,
312            properties,
313        }
314    }
315}
316
317/// A single step in a [`ConcatPlan`].
318#[derive(Debug, Clone)]
319pub struct ConcatStep {
320    /// Source file path / URI.
321    pub source: String,
322    /// Whether this segment needs re-encoding to match the target parameters.
323    pub requires_reencode: bool,
324    /// Target width after possible rescaling.
325    pub target_width: u32,
326    /// Target height after possible rescaling.
327    pub target_height: u32,
328    /// Target frame rate numerator.
329    pub target_fps_num: u32,
330    /// Target frame rate denominator.
331    pub target_fps_den: u32,
332}
333
334impl ConcatStep {
335    /// Returns the target resolution as `(width, height)`.
336    #[must_use]
337    pub fn target_resolution(&self) -> (u32, u32) {
338        (self.target_width, self.target_height)
339    }
340
341    /// Returns the target frame rate as a float.
342    #[must_use]
343    pub fn target_fps(&self) -> f64 {
344        if self.target_fps_den == 0 {
345            return 0.0;
346        }
347        f64::from(self.target_fps_num) / f64::from(self.target_fps_den)
348    }
349}
350
351/// A fully-resolved plan for concatenating mixed-source segments.
352///
353/// Produced by [`MixedSourceConcatenator::build_plan`] and consumed by the
354/// caller's encoding engine.
355#[derive(Debug, Clone)]
356pub struct ConcatPlan {
357    /// Ordered processing steps, one per source segment.
358    pub steps: Vec<ConcatStep>,
359    /// Resolved target width for the output.
360    pub target_width: u32,
361    /// Resolved target height for the output.
362    pub target_height: u32,
363    /// Resolved target frame rate numerator.
364    pub target_fps_num: u32,
365    /// Resolved target frame rate denominator.
366    pub target_fps_den: u32,
367    /// Output file path.
368    pub output: String,
369}
370
371impl ConcatPlan {
372    /// Returns the number of segments that will be re-encoded.
373    #[must_use]
374    pub fn reencode_count(&self) -> usize {
375        self.steps.iter().filter(|s| s.requires_reencode).count()
376    }
377
378    /// Returns the number of segments that will be stream-copied.
379    #[must_use]
380    pub fn stream_copy_count(&self) -> usize {
381        self.steps.iter().filter(|s| !s.requires_reencode).count()
382    }
383
384    /// Returns `true` if all segments can be stream-copied (no re-encoding).
385    #[must_use]
386    pub fn all_stream_copy(&self) -> bool {
387        self.steps.iter().all(|s| !s.requires_reencode)
388    }
389}
390
391/// Analyses a list of annotated source segments and produces a [`ConcatPlan`]
392/// that handles mixed resolutions, frame rates, and codecs.
393///
394/// # Algorithm
395///
396/// 1. Determine the **reference properties** (from the first segment, or from
397///    the `ConcatConfig` target resolution / fps overrides).
398/// 2. For each segment compare its properties to the reference.
399/// 3. Under `ReEncodeAll`: every segment is re-encoded.
400/// 4. Under `ReEncodeDiffers`: only segments that differ from the reference.
401/// 5. Under `StreamCopy`: all segments are stream-copied (caller assumes
402///    compatible sources).
403pub struct MixedSourceConcatenator {
404    config: ConcatConfig,
405    sources: Vec<AnnotatedSegment>,
406}
407
408impl MixedSourceConcatenator {
409    /// Creates a new concatenator.
410    ///
411    /// `sources` must be in the same order as `config.segments`.
412    #[must_use]
413    pub fn new(config: ConcatConfig, sources: Vec<AnnotatedSegment>) -> Self {
414        Self { config, sources }
415    }
416
417    /// Returns the reference `SourceProperties` against which all segments are
418    /// compared.
419    ///
420    /// Priority: explicit config target → first source segment.
421    fn reference_properties(&self) -> SourceProperties {
422        let first = self.sources.first().map(|s| s.properties.clone());
423
424        let width = self
425            .config
426            .target_width
427            .or_else(|| first.as_ref().map(|p| p.width))
428            .unwrap_or(1920);
429        let height = self
430            .config
431            .target_height
432            .or_else(|| first.as_ref().map(|p| p.height))
433            .unwrap_or(1080);
434        let (fps_num, fps_den) = self
435            .config
436            .target_fps
437            .or_else(|| first.as_ref().map(|p| (p.fps_num, p.fps_den)))
438            .unwrap_or((30, 1));
439        let sample_rate = self
440            .config
441            .target_sample_rate
442            .or_else(|| first.as_ref().map(|p| p.sample_rate))
443            .unwrap_or(48_000);
444        let codec = first
445            .as_ref()
446            .map(|p| p.codec.clone())
447            .unwrap_or_else(|| "h264".into());
448        let audio_codec = first
449            .as_ref()
450            .map(|p| p.audio_codec.clone())
451            .unwrap_or_else(|| "aac".into());
452
453        SourceProperties {
454            codec,
455            width,
456            height,
457            fps_num,
458            fps_den,
459            sample_rate,
460            audio_codec,
461        }
462    }
463
464    /// Builds the [`ConcatPlan`] from the configured sources and strategy.
465    #[must_use]
466    pub fn build_plan(&self) -> ConcatPlan {
467        let reference = self.reference_properties();
468
469        let steps: Vec<ConcatStep> = self
470            .sources
471            .iter()
472            .map(|ann| {
473                let requires_reencode = match self.config.conform {
474                    ConformStrategy::ReEncodeAll => true,
475                    ConformStrategy::StreamCopy => false,
476                    ConformStrategy::ReEncodeDiffers => {
477                        !ann.properties.is_compatible_with(&reference)
478                    }
479                };
480
481                ConcatStep {
482                    source: ann.segment.source.clone(),
483                    requires_reencode,
484                    target_width: reference.width,
485                    target_height: reference.height,
486                    target_fps_num: reference.fps_num,
487                    target_fps_den: reference.fps_den,
488                }
489            })
490            .collect();
491
492        ConcatPlan {
493            steps,
494            target_width: reference.width,
495            target_height: reference.height,
496            target_fps_num: reference.fps_num,
497            target_fps_den: reference.fps_den,
498            output: self.config.output.clone(),
499        }
500    }
501
502    /// Returns the number of sources that require re-encoding.
503    #[must_use]
504    pub fn reencode_count(&self) -> usize {
505        self.build_plan().reencode_count()
506    }
507
508    /// Returns `true` if all sources are format-compatible (no re-encoding).
509    #[must_use]
510    pub fn all_compatible(&self) -> bool {
511        self.build_plan().all_stream_copy()
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    #[test]
520    fn test_conform_strategy_display() {
521        assert_eq!(ConformStrategy::ReEncodeAll.to_string(), "re-encode-all");
522        assert_eq!(
523            ConformStrategy::ReEncodeDiffers.to_string(),
524            "re-encode-differs"
525        );
526        assert_eq!(ConformStrategy::StreamCopy.to_string(), "stream-copy");
527    }
528
529    #[test]
530    fn test_transition_duration() {
531        assert!((TransitionKind::Cut.duration() - 0.0).abs() < f64::EPSILON);
532        assert!((TransitionKind::Crossfade(1.5).duration() - 1.5).abs() < f64::EPSILON);
533        assert!((TransitionKind::FadeThrough(2.0).duration() - 2.0).abs() < f64::EPSILON);
534    }
535
536    #[test]
537    fn test_segment_new() {
538        let seg = ConcatSegment::new("clip.mp4");
539        assert_eq!(seg.source, "clip.mp4");
540        assert!(seg.in_point.is_none());
541        assert!(seg.out_point.is_none());
542        assert_eq!(seg.transition, TransitionKind::Cut);
543    }
544
545    #[test]
546    fn test_segment_trim() {
547        let seg = ConcatSegment::new("clip.mp4")
548            .with_in_point(5.0)
549            .with_out_point(15.0);
550        assert!(
551            (seg.effective_duration().expect("should succeed in test") - 10.0).abs() < f64::EPSILON
552        );
553    }
554
555    #[test]
556    fn test_segment_no_duration() {
557        let seg = ConcatSegment::new("clip.mp4").with_in_point(5.0);
558        assert!(seg.effective_duration().is_none());
559    }
560
561    #[test]
562    fn test_concat_config_builder() {
563        let mut config = ConcatConfig::new("output.mp4")
564            .with_conform(ConformStrategy::StreamCopy)
565            .with_resolution(1920, 1080)
566            .with_fps(30, 1)
567            .with_sample_rate(48000);
568        config.add_segment(ConcatSegment::new("a.mp4"));
569        config.add_segment(ConcatSegment::new("b.mp4"));
570
571        assert_eq!(config.segment_count(), 2);
572        assert_eq!(config.conform, ConformStrategy::StreamCopy);
573        assert_eq!(config.target_width, Some(1920));
574        assert_eq!(config.target_height, Some(1080));
575        assert_eq!(config.target_fps, Some((30, 1)));
576        assert_eq!(config.target_sample_rate, Some(48000));
577    }
578
579    #[test]
580    fn test_total_transition_time() {
581        let mut config = ConcatConfig::new("out.mp4");
582        config.add_segment(
583            ConcatSegment::new("a.mp4").with_transition(TransitionKind::Crossfade(1.0)),
584        );
585        config.add_segment(
586            ConcatSegment::new("b.mp4").with_transition(TransitionKind::FadeThrough(0.5)),
587        );
588        config.add_segment(ConcatSegment::new("c.mp4"));
589        assert!((config.total_transition_time() - 1.5).abs() < f64::EPSILON);
590    }
591
592    #[test]
593    fn test_total_known_duration() {
594        let mut config = ConcatConfig::new("out.mp4");
595        config.add_segment(
596            ConcatSegment::new("a.mp4")
597                .with_in_point(0.0)
598                .with_out_point(10.0),
599        );
600        config.add_segment(ConcatSegment::new("b.mp4")); // unknown duration
601        config.add_segment(
602            ConcatSegment::new("c.mp4")
603                .with_in_point(5.0)
604                .with_out_point(20.0),
605        );
606        assert!((config.total_known_duration() - 25.0).abs() < f64::EPSILON);
607    }
608
609    #[test]
610    fn test_validate_empty_segments() {
611        let config = ConcatConfig::new("out.mp4");
612        let issues = validate_concat(&config);
613        assert!(issues.iter().any(|i| i.contains("No segments")));
614    }
615
616    #[test]
617    fn test_validate_empty_output() {
618        let mut config = ConcatConfig::new("");
619        config.add_segment(ConcatSegment::new("a.mp4"));
620        let issues = validate_concat(&config);
621        assert!(issues.iter().any(|i| i.contains("Output path")));
622    }
623
624    #[test]
625    fn test_validate_bad_trim() {
626        let mut config = ConcatConfig::new("out.mp4");
627        config.add_segment(
628            ConcatSegment::new("a.mp4")
629                .with_in_point(20.0)
630                .with_out_point(5.0),
631        );
632        let issues = validate_concat(&config);
633        assert!(issues.iter().any(|i| i.contains("out-point")));
634    }
635
636    #[test]
637    fn test_validate_valid_config() {
638        let mut config = ConcatConfig::new("out.mp4");
639        config.add_segment(
640            ConcatSegment::new("a.mp4")
641                .with_in_point(0.0)
642                .with_out_point(10.0),
643        );
644        let issues = validate_concat(&config);
645        assert!(issues.is_empty());
646    }
647
648    #[test]
649    fn test_concat_result_fields() {
650        let result = ConcatResult {
651            output_path: "out.mp4".to_string(),
652            segments_joined: 3,
653            total_duration: 30.0,
654            re_encoded_count: 1,
655        };
656        assert_eq!(result.segments_joined, 3);
657        assert!((result.total_duration - 30.0).abs() < f64::EPSILON);
658    }
659
660    // ── MixedSourceConcatenator tests ─────────────────────────────────────────
661
662    #[test]
663    fn test_source_properties_default() {
664        let props = SourceProperties {
665            codec: "h264".into(),
666            width: 1920,
667            height: 1080,
668            fps_num: 30,
669            fps_den: 1,
670            sample_rate: 48_000,
671            audio_codec: "aac".into(),
672        };
673        assert_eq!(props.codec, "h264");
674        assert_eq!(props.resolution(), (1920, 1080));
675    }
676
677    #[test]
678    fn test_source_properties_compatible() {
679        let a = SourceProperties {
680            codec: "h264".into(),
681            width: 1920,
682            height: 1080,
683            fps_num: 30,
684            fps_den: 1,
685            sample_rate: 48_000,
686            audio_codec: "aac".into(),
687        };
688        let b = a.clone();
689        assert!(a.is_compatible_with(&b));
690    }
691
692    #[test]
693    fn test_source_properties_incompatible_resolution() {
694        let a = SourceProperties {
695            codec: "h264".into(),
696            width: 1920,
697            height: 1080,
698            fps_num: 30,
699            fps_den: 1,
700            sample_rate: 48_000,
701            audio_codec: "aac".into(),
702        };
703        let b = SourceProperties {
704            width: 1280,
705            height: 720,
706            ..a.clone()
707        };
708        assert!(!a.is_compatible_with(&b));
709    }
710
711    #[test]
712    fn test_source_properties_incompatible_codec() {
713        let a = SourceProperties {
714            codec: "h264".into(),
715            width: 1920,
716            height: 1080,
717            fps_num: 30,
718            fps_den: 1,
719            sample_rate: 48_000,
720            audio_codec: "aac".into(),
721        };
722        let b = SourceProperties {
723            codec: "vp9".into(),
724            ..a.clone()
725        };
726        assert!(!a.is_compatible_with(&b));
727    }
728
729    #[test]
730    fn test_mixed_concatenator_uniform_sources() {
731        let props = SourceProperties {
732            codec: "h264".into(),
733            width: 1920,
734            height: 1080,
735            fps_num: 30,
736            fps_den: 1,
737            sample_rate: 48_000,
738            audio_codec: "aac".into(),
739        };
740        let sources = vec![
741            AnnotatedSegment {
742                segment: ConcatSegment::new("a.mp4"),
743                properties: props.clone(),
744            },
745            AnnotatedSegment {
746                segment: ConcatSegment::new("b.mp4"),
747                properties: props.clone(),
748            },
749        ];
750        let mut config =
751            ConcatConfig::new("out.mp4").with_conform(ConformStrategy::ReEncodeDiffers);
752        config.add_segment(ConcatSegment::new("a.mp4"));
753        config.add_segment(ConcatSegment::new("b.mp4"));
754
755        let concatenator = MixedSourceConcatenator::new(config.clone(), sources);
756        let plan = concatenator.build_plan();
757
758        // Uniform sources: no re-encoding needed
759        assert_eq!(plan.steps.len(), 2);
760        assert!(
761            plan.steps.iter().all(|s| !s.requires_reencode),
762            "Uniform sources should not require re-encoding"
763        );
764    }
765
766    #[test]
767    fn test_mixed_concatenator_mixed_resolution() {
768        let base = SourceProperties {
769            codec: "h264".into(),
770            width: 1920,
771            height: 1080,
772            fps_num: 30,
773            fps_den: 1,
774            sample_rate: 48_000,
775            audio_codec: "aac".into(),
776        };
777        let different = SourceProperties {
778            width: 1280,
779            height: 720,
780            ..base.clone()
781        };
782
783        let sources = vec![
784            AnnotatedSegment {
785                segment: ConcatSegment::new("hd.mp4"),
786                properties: base.clone(),
787            },
788            AnnotatedSegment {
789                segment: ConcatSegment::new("sd.mp4"),
790                properties: different.clone(),
791            },
792        ];
793        let mut config = ConcatConfig::new("out.mp4")
794            .with_conform(ConformStrategy::ReEncodeDiffers)
795            .with_resolution(1920, 1080);
796        config.add_segment(ConcatSegment::new("hd.mp4"));
797        config.add_segment(ConcatSegment::new("sd.mp4"));
798
799        let concatenator = MixedSourceConcatenator::new(config, sources);
800        let plan = concatenator.build_plan();
801
802        assert_eq!(plan.steps.len(), 2);
803        // Second segment is 720p — should be marked for re-encoding
804        assert!(
805            plan.steps[1].requires_reencode,
806            "Mixed-resolution segment should require re-encoding"
807        );
808        // Target resolution set to match the first/config resolution
809        assert_eq!(plan.target_width, 1920);
810        assert_eq!(plan.target_height, 1080);
811    }
812
813    #[test]
814    fn test_mixed_concatenator_reencode_all() {
815        let props = SourceProperties {
816            codec: "h264".into(),
817            width: 1920,
818            height: 1080,
819            fps_num: 30,
820            fps_den: 1,
821            sample_rate: 48_000,
822            audio_codec: "aac".into(),
823        };
824        let sources = vec![
825            AnnotatedSegment {
826                segment: ConcatSegment::new("a.mp4"),
827                properties: props.clone(),
828            },
829            AnnotatedSegment {
830                segment: ConcatSegment::new("b.mp4"),
831                properties: props.clone(),
832            },
833        ];
834        let mut config = ConcatConfig::new("out.mp4").with_conform(ConformStrategy::ReEncodeAll);
835        config.add_segment(ConcatSegment::new("a.mp4"));
836        config.add_segment(ConcatSegment::new("b.mp4"));
837
838        let concatenator = MixedSourceConcatenator::new(config, sources);
839        let plan = concatenator.build_plan();
840
841        // ReEncodeAll forces re-encoding even for compatible sources.
842        assert!(plan.steps.iter().all(|s| s.requires_reencode));
843    }
844
845    #[test]
846    fn test_concat_plan_reencode_count() {
847        let plan = ConcatPlan {
848            steps: vec![
849                ConcatStep {
850                    source: "a.mp4".into(),
851                    requires_reencode: false,
852                    target_width: 1920,
853                    target_height: 1080,
854                    target_fps_num: 30,
855                    target_fps_den: 1,
856                },
857                ConcatStep {
858                    source: "b.mp4".into(),
859                    requires_reencode: true,
860                    target_width: 1920,
861                    target_height: 1080,
862                    target_fps_num: 30,
863                    target_fps_den: 1,
864                },
865            ],
866            target_width: 1920,
867            target_height: 1080,
868            target_fps_num: 30,
869            target_fps_den: 1,
870            output: "out.mp4".into(),
871        };
872        assert_eq!(plan.reencode_count(), 1);
873        assert_eq!(plan.stream_copy_count(), 1);
874    }
875
876    #[test]
877    fn test_concat_plan_all_stream_copy() {
878        let plan = ConcatPlan {
879            steps: vec![ConcatStep {
880                source: "a.mp4".into(),
881                requires_reencode: false,
882                target_width: 1920,
883                target_height: 1080,
884                target_fps_num: 30,
885                target_fps_den: 1,
886            }],
887            target_width: 1920,
888            target_height: 1080,
889            target_fps_num: 30,
890            target_fps_den: 1,
891            output: "out.mp4".into(),
892        };
893        assert!(plan.all_stream_copy());
894    }
895
896    #[test]
897    fn test_concat_step_resolution() {
898        let step = ConcatStep {
899            source: "x.mp4".into(),
900            requires_reencode: true,
901            target_width: 3840,
902            target_height: 2160,
903            target_fps_num: 60,
904            target_fps_den: 1,
905        };
906        assert_eq!(step.target_resolution(), (3840, 2160));
907        assert!((step.target_fps() - 60.0).abs() < 1e-9);
908    }
909}