1#![allow(dead_code)]
2use std::fmt;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ConformStrategy {
19 ReEncodeAll,
21 ReEncodeDiffers,
23 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#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum TransitionKind {
40 Cut,
42 Crossfade(f64),
44 FadeThrough(f64),
46}
47
48impl TransitionKind {
49 #[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#[derive(Debug, Clone)]
61pub struct ConcatSegment {
62 pub source: String,
64 pub in_point: Option<f64>,
66 pub out_point: Option<f64>,
68 pub transition: TransitionKind,
70}
71
72impl ConcatSegment {
73 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 #[must_use]
85 pub fn with_in_point(mut self, seconds: f64) -> Self {
86 self.in_point = Some(seconds);
87 self
88 }
89
90 #[must_use]
92 pub fn with_out_point(mut self, seconds: f64) -> Self {
93 self.out_point = Some(seconds);
94 self
95 }
96
97 #[must_use]
99 pub fn with_transition(mut self, t: TransitionKind) -> Self {
100 self.transition = t;
101 self
102 }
103
104 #[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#[derive(Debug, Clone)]
116pub struct ConcatConfig {
117 pub segments: Vec<ConcatSegment>,
119 pub output: String,
121 pub conform: ConformStrategy,
123 pub target_width: Option<u32>,
125 pub target_height: Option<u32>,
127 pub target_fps: Option<(u32, u32)>,
129 pub target_sample_rate: Option<u32>,
131}
132
133impl ConcatConfig {
134 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 pub fn add_segment(&mut self, seg: ConcatSegment) {
149 self.segments.push(seg);
150 }
151
152 #[must_use]
154 pub fn with_conform(mut self, strategy: ConformStrategy) -> Self {
155 self.conform = strategy;
156 self
157 }
158
159 #[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 #[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 #[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 #[must_use]
183 pub fn segment_count(&self) -> usize {
184 self.segments.len()
185 }
186
187 #[must_use]
189 pub fn total_transition_time(&self) -> f64 {
190 self.segments.iter().map(|s| s.transition.duration()).sum()
191 }
192
193 #[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#[derive(Debug, Clone)]
206pub struct ConcatResult {
207 pub output_path: String,
209 pub segments_joined: usize,
211 pub total_duration: f64,
213 pub re_encoded_count: usize,
215}
216
217#[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#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct SourceProperties {
249 pub codec: String,
251 pub width: u32,
253 pub height: u32,
255 pub fps_num: u32,
257 pub fps_den: u32,
259 pub sample_rate: u32,
261 pub audio_codec: String,
263}
264
265impl SourceProperties {
266 #[must_use]
268 pub fn resolution(&self) -> (u32, u32) {
269 (self.width, self.height)
270 }
271
272 #[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 #[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#[derive(Debug, Clone)]
299pub struct AnnotatedSegment {
300 pub segment: ConcatSegment,
302 pub properties: SourceProperties,
304}
305
306impl AnnotatedSegment {
307 #[must_use]
309 pub fn new(segment: ConcatSegment, properties: SourceProperties) -> Self {
310 Self {
311 segment,
312 properties,
313 }
314 }
315}
316
317#[derive(Debug, Clone)]
319pub struct ConcatStep {
320 pub source: String,
322 pub requires_reencode: bool,
324 pub target_width: u32,
326 pub target_height: u32,
328 pub target_fps_num: u32,
330 pub target_fps_den: u32,
332}
333
334impl ConcatStep {
335 #[must_use]
337 pub fn target_resolution(&self) -> (u32, u32) {
338 (self.target_width, self.target_height)
339 }
340
341 #[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#[derive(Debug, Clone)]
356pub struct ConcatPlan {
357 pub steps: Vec<ConcatStep>,
359 pub target_width: u32,
361 pub target_height: u32,
363 pub target_fps_num: u32,
365 pub target_fps_den: u32,
367 pub output: String,
369}
370
371impl ConcatPlan {
372 #[must_use]
374 pub fn reencode_count(&self) -> usize {
375 self.steps.iter().filter(|s| s.requires_reencode).count()
376 }
377
378 #[must_use]
380 pub fn stream_copy_count(&self) -> usize {
381 self.steps.iter().filter(|s| !s.requires_reencode).count()
382 }
383
384 #[must_use]
386 pub fn all_stream_copy(&self) -> bool {
387 self.steps.iter().all(|s| !s.requires_reencode)
388 }
389}
390
391pub struct MixedSourceConcatenator {
404 config: ConcatConfig,
405 sources: Vec<AnnotatedSegment>,
406}
407
408impl MixedSourceConcatenator {
409 #[must_use]
413 pub fn new(config: ConcatConfig, sources: Vec<AnnotatedSegment>) -> Self {
414 Self { config, sources }
415 }
416
417 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 #[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 #[must_use]
504 pub fn reencode_count(&self) -> usize {
505 self.build_plan().reencode_count()
506 }
507
508 #[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")); 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 #[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 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 assert!(
805 plan.steps[1].requires_reencode,
806 "Mixed-resolution segment should require re-encoding"
807 );
808 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 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}