1#![allow(dead_code)]
2use std::fmt;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ConformStrategy {
12 ReEncodeAll,
14 ReEncodeDiffers,
16 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#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum TransitionKind {
33 Cut,
35 Crossfade(f64),
37 FadeThrough(f64),
39}
40
41impl TransitionKind {
42 #[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#[derive(Debug, Clone)]
54pub struct ConcatSegment {
55 pub source: String,
57 pub in_point: Option<f64>,
59 pub out_point: Option<f64>,
61 pub transition: TransitionKind,
63}
64
65impl ConcatSegment {
66 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 #[must_use]
78 pub fn with_in_point(mut self, seconds: f64) -> Self {
79 self.in_point = Some(seconds);
80 self
81 }
82
83 #[must_use]
85 pub fn with_out_point(mut self, seconds: f64) -> Self {
86 self.out_point = Some(seconds);
87 self
88 }
89
90 #[must_use]
92 pub fn with_transition(mut self, t: TransitionKind) -> Self {
93 self.transition = t;
94 self
95 }
96
97 #[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#[derive(Debug, Clone)]
109pub struct ConcatConfig {
110 pub segments: Vec<ConcatSegment>,
112 pub output: String,
114 pub conform: ConformStrategy,
116 pub target_width: Option<u32>,
118 pub target_height: Option<u32>,
120 pub target_fps: Option<(u32, u32)>,
122 pub target_sample_rate: Option<u32>,
124}
125
126impl ConcatConfig {
127 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 pub fn add_segment(&mut self, seg: ConcatSegment) {
142 self.segments.push(seg);
143 }
144
145 #[must_use]
147 pub fn with_conform(mut self, strategy: ConformStrategy) -> Self {
148 self.conform = strategy;
149 self
150 }
151
152 #[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 #[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 #[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 #[must_use]
176 pub fn segment_count(&self) -> usize {
177 self.segments.len()
178 }
179
180 #[must_use]
182 pub fn total_transition_time(&self) -> f64 {
183 self.segments.iter().map(|s| s.transition.duration()).sum()
184 }
185
186 #[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#[derive(Debug, Clone)]
199pub struct ConcatResult {
200 pub output_path: String,
202 pub segments_joined: usize,
204 pub total_duration: f64,
206 pub re_encoded_count: usize,
208}
209
210#[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")); 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}