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
7use std::fmt;
8
9/// Strategy for handling format mismatches between segments.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ConformStrategy {
12    /// Re-encode every segment to match the first segment's format.
13    ReEncodeAll,
14    /// Re-encode only segments that differ from the target format.
15    ReEncodeDiffers,
16    /// Attempt stream-copy where possible (fastest, may fail on mismatches).
17    StreamCopy,
18}
19
20impl fmt::Display for ConformStrategy {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::ReEncodeAll => write!(f, "re-encode-all"),
24            Self::ReEncodeDiffers => write!(f, "re-encode-differs"),
25            Self::StreamCopy => write!(f, "stream-copy"),
26        }
27    }
28}
29
30/// Transition type between consecutive segments.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum TransitionKind {
33    /// Hard cut with no transition.
34    Cut,
35    /// Crossfade of the specified duration in seconds.
36    Crossfade(f64),
37    /// Fade to black then fade from black.
38    FadeThrough(f64),
39}
40
41impl TransitionKind {
42    /// Return the duration in seconds (0.0 for a hard cut).
43    #[must_use]
44    pub fn duration(&self) -> f64 {
45        match self {
46            Self::Cut => 0.0,
47            Self::Crossfade(d) | Self::FadeThrough(d) => *d,
48        }
49    }
50}
51
52/// A single input segment in the concat list.
53#[derive(Debug, Clone)]
54pub struct ConcatSegment {
55    /// Path or URI to the source media.
56    pub source: String,
57    /// Optional in-point in seconds (trim start).
58    pub in_point: Option<f64>,
59    /// Optional out-point in seconds (trim end).
60    pub out_point: Option<f64>,
61    /// Transition to apply *after* this segment (before the next).
62    pub transition: TransitionKind,
63}
64
65impl ConcatSegment {
66    /// Create a segment from a source path with defaults (full duration, hard cut).
67    pub fn new(source: impl Into<String>) -> Self {
68        Self {
69            source: source.into(),
70            in_point: None,
71            out_point: None,
72            transition: TransitionKind::Cut,
73        }
74    }
75
76    /// Set in-point.
77    #[must_use]
78    pub fn with_in_point(mut self, seconds: f64) -> Self {
79        self.in_point = Some(seconds);
80        self
81    }
82
83    /// Set out-point.
84    #[must_use]
85    pub fn with_out_point(mut self, seconds: f64) -> Self {
86        self.out_point = Some(seconds);
87        self
88    }
89
90    /// Set transition after this segment.
91    #[must_use]
92    pub fn with_transition(mut self, t: TransitionKind) -> Self {
93        self.transition = t;
94        self
95    }
96
97    /// Compute effective duration (returns `None` when both points are absent).
98    #[must_use]
99    pub fn effective_duration(&self) -> Option<f64> {
100        match (self.in_point, self.out_point) {
101            (Some(i), Some(o)) => Some((o - i).max(0.0)),
102            _ => None,
103        }
104    }
105}
106
107/// Overall concat job configuration.
108#[derive(Debug, Clone)]
109pub struct ConcatConfig {
110    /// Ordered list of segments.
111    pub segments: Vec<ConcatSegment>,
112    /// Output path.
113    pub output: String,
114    /// Conforming strategy.
115    pub conform: ConformStrategy,
116    /// Target video width (if re-encoding).
117    pub target_width: Option<u32>,
118    /// Target video height (if re-encoding).
119    pub target_height: Option<u32>,
120    /// Target frame rate numerator / denominator (if re-encoding).
121    pub target_fps: Option<(u32, u32)>,
122    /// Target audio sample rate.
123    pub target_sample_rate: Option<u32>,
124}
125
126impl ConcatConfig {
127    /// Create a new concat configuration.
128    pub fn new(output: impl Into<String>) -> Self {
129        Self {
130            segments: Vec::new(),
131            output: output.into(),
132            conform: ConformStrategy::ReEncodeDiffers,
133            target_width: None,
134            target_height: None,
135            target_fps: None,
136            target_sample_rate: None,
137        }
138    }
139
140    /// Add a segment.
141    pub fn add_segment(&mut self, seg: ConcatSegment) {
142        self.segments.push(seg);
143    }
144
145    /// Set conforming strategy.
146    #[must_use]
147    pub fn with_conform(mut self, strategy: ConformStrategy) -> Self {
148        self.conform = strategy;
149        self
150    }
151
152    /// Set target resolution.
153    #[must_use]
154    pub fn with_resolution(mut self, w: u32, h: u32) -> Self {
155        self.target_width = Some(w);
156        self.target_height = Some(h);
157        self
158    }
159
160    /// Set target frame rate.
161    #[must_use]
162    pub fn with_fps(mut self, num: u32, den: u32) -> Self {
163        self.target_fps = Some((num, den));
164        self
165    }
166
167    /// Set target audio sample rate.
168    #[must_use]
169    pub fn with_sample_rate(mut self, rate: u32) -> Self {
170        self.target_sample_rate = Some(rate);
171        self
172    }
173
174    /// Return total number of segments.
175    #[must_use]
176    pub fn segment_count(&self) -> usize {
177        self.segments.len()
178    }
179
180    /// Compute the total transition time between segments.
181    #[must_use]
182    pub fn total_transition_time(&self) -> f64 {
183        self.segments.iter().map(|s| s.transition.duration()).sum()
184    }
185
186    /// Compute the total known content duration (sum of effective durations).
187    /// Segments without known duration are excluded.
188    #[must_use]
189    pub fn total_known_duration(&self) -> f64 {
190        self.segments
191            .iter()
192            .filter_map(ConcatSegment::effective_duration)
193            .sum()
194    }
195}
196
197/// Result of a concat operation.
198#[derive(Debug, Clone)]
199pub struct ConcatResult {
200    /// Output file path.
201    pub output_path: String,
202    /// Number of segments joined.
203    pub segments_joined: usize,
204    /// Total output duration in seconds.
205    pub total_duration: f64,
206    /// Number of segments that required re-encoding.
207    pub re_encoded_count: usize,
208}
209
210/// Validate a concat configuration and return a list of issues (empty = valid).
211#[must_use]
212pub fn validate_concat(config: &ConcatConfig) -> Vec<String> {
213    let mut issues = Vec::new();
214    if config.segments.is_empty() {
215        issues.push("No segments specified".to_string());
216    }
217    if config.output.is_empty() {
218        issues.push("Output path is empty".to_string());
219    }
220    for (i, seg) in config.segments.iter().enumerate() {
221        if seg.source.is_empty() {
222            issues.push(format!("Segment {i} has empty source path"));
223        }
224        if let (Some(inp), Some(out)) = (seg.in_point, seg.out_point) {
225            if out <= inp {
226                issues.push(format!("Segment {i} out-point ({out}) <= in-point ({inp})"));
227            }
228        }
229    }
230    issues
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_conform_strategy_display() {
239        assert_eq!(ConformStrategy::ReEncodeAll.to_string(), "re-encode-all");
240        assert_eq!(
241            ConformStrategy::ReEncodeDiffers.to_string(),
242            "re-encode-differs"
243        );
244        assert_eq!(ConformStrategy::StreamCopy.to_string(), "stream-copy");
245    }
246
247    #[test]
248    fn test_transition_duration() {
249        assert!((TransitionKind::Cut.duration() - 0.0).abs() < f64::EPSILON);
250        assert!((TransitionKind::Crossfade(1.5).duration() - 1.5).abs() < f64::EPSILON);
251        assert!((TransitionKind::FadeThrough(2.0).duration() - 2.0).abs() < f64::EPSILON);
252    }
253
254    #[test]
255    fn test_segment_new() {
256        let seg = ConcatSegment::new("clip.mp4");
257        assert_eq!(seg.source, "clip.mp4");
258        assert!(seg.in_point.is_none());
259        assert!(seg.out_point.is_none());
260        assert_eq!(seg.transition, TransitionKind::Cut);
261    }
262
263    #[test]
264    fn test_segment_trim() {
265        let seg = ConcatSegment::new("clip.mp4")
266            .with_in_point(5.0)
267            .with_out_point(15.0);
268        assert!(
269            (seg.effective_duration().expect("should succeed in test") - 10.0).abs() < f64::EPSILON
270        );
271    }
272
273    #[test]
274    fn test_segment_no_duration() {
275        let seg = ConcatSegment::new("clip.mp4").with_in_point(5.0);
276        assert!(seg.effective_duration().is_none());
277    }
278
279    #[test]
280    fn test_concat_config_builder() {
281        let mut config = ConcatConfig::new("output.mp4")
282            .with_conform(ConformStrategy::StreamCopy)
283            .with_resolution(1920, 1080)
284            .with_fps(30, 1)
285            .with_sample_rate(48000);
286        config.add_segment(ConcatSegment::new("a.mp4"));
287        config.add_segment(ConcatSegment::new("b.mp4"));
288
289        assert_eq!(config.segment_count(), 2);
290        assert_eq!(config.conform, ConformStrategy::StreamCopy);
291        assert_eq!(config.target_width, Some(1920));
292        assert_eq!(config.target_height, Some(1080));
293        assert_eq!(config.target_fps, Some((30, 1)));
294        assert_eq!(config.target_sample_rate, Some(48000));
295    }
296
297    #[test]
298    fn test_total_transition_time() {
299        let mut config = ConcatConfig::new("out.mp4");
300        config.add_segment(
301            ConcatSegment::new("a.mp4").with_transition(TransitionKind::Crossfade(1.0)),
302        );
303        config.add_segment(
304            ConcatSegment::new("b.mp4").with_transition(TransitionKind::FadeThrough(0.5)),
305        );
306        config.add_segment(ConcatSegment::new("c.mp4"));
307        assert!((config.total_transition_time() - 1.5).abs() < f64::EPSILON);
308    }
309
310    #[test]
311    fn test_total_known_duration() {
312        let mut config = ConcatConfig::new("out.mp4");
313        config.add_segment(
314            ConcatSegment::new("a.mp4")
315                .with_in_point(0.0)
316                .with_out_point(10.0),
317        );
318        config.add_segment(ConcatSegment::new("b.mp4")); // unknown duration
319        config.add_segment(
320            ConcatSegment::new("c.mp4")
321                .with_in_point(5.0)
322                .with_out_point(20.0),
323        );
324        assert!((config.total_known_duration() - 25.0).abs() < f64::EPSILON);
325    }
326
327    #[test]
328    fn test_validate_empty_segments() {
329        let config = ConcatConfig::new("out.mp4");
330        let issues = validate_concat(&config);
331        assert!(issues.iter().any(|i| i.contains("No segments")));
332    }
333
334    #[test]
335    fn test_validate_empty_output() {
336        let mut config = ConcatConfig::new("");
337        config.add_segment(ConcatSegment::new("a.mp4"));
338        let issues = validate_concat(&config);
339        assert!(issues.iter().any(|i| i.contains("Output path")));
340    }
341
342    #[test]
343    fn test_validate_bad_trim() {
344        let mut config = ConcatConfig::new("out.mp4");
345        config.add_segment(
346            ConcatSegment::new("a.mp4")
347                .with_in_point(20.0)
348                .with_out_point(5.0),
349        );
350        let issues = validate_concat(&config);
351        assert!(issues.iter().any(|i| i.contains("out-point")));
352    }
353
354    #[test]
355    fn test_validate_valid_config() {
356        let mut config = ConcatConfig::new("out.mp4");
357        config.add_segment(
358            ConcatSegment::new("a.mp4")
359                .with_in_point(0.0)
360                .with_out_point(10.0),
361        );
362        let issues = validate_concat(&config);
363        assert!(issues.is_empty());
364    }
365
366    #[test]
367    fn test_concat_result_fields() {
368        let result = ConcatResult {
369            output_path: "out.mp4".to_string(),
370            segments_joined: 3,
371            total_duration: 30.0,
372            re_encoded_count: 1,
373        };
374        assert_eq!(result.segments_joined, 3);
375        assert!((result.total_duration - 30.0).abs() < f64::EPSILON);
376    }
377}