Skip to main content

oximedia_transcode/
frame_trim.rs

1//! Frame-accurate trim and cut support for the transcoding pipeline.
2//!
3//! This module provides utilities for specifying frame-accurate trim points,
4//! validating trim ranges, computing output durations, and assembling multi-cut
5//! edit lists.  The actual frame-dropping during encode is handled by the
6//! pipeline; these types describe *what* to cut.
7
8#![allow(dead_code)]
9
10use crate::{Result, TranscodeError};
11
12// ─── Time representation ──────────────────────────────────────────────────────
13
14/// A precise media timecode expressed in both frame number and milliseconds.
15///
16/// Storing both avoids lossy round-trips between the two representations.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct FrameTimecode {
19    /// Zero-based frame index.
20    pub frame: u64,
21    /// Timestamp in milliseconds (derived from `frame` and `fps`).
22    pub timestamp_ms: u64,
23}
24
25impl FrameTimecode {
26    /// Creates a `FrameTimecode` from a frame number and a frame rate (num/den).
27    ///
28    /// # Panics-safe
29    ///
30    /// Returns `None` if `fps_den` is zero.
31    #[must_use]
32    pub fn from_frame(frame: u64, fps_num: u32, fps_den: u32) -> Option<Self> {
33        if fps_den == 0 || fps_num == 0 {
34            return None;
35        }
36        let timestamp_ms = frame * 1_000 * u64::from(fps_den) / u64::from(fps_num);
37        Some(Self {
38            frame,
39            timestamp_ms,
40        })
41    }
42
43    /// Creates a `FrameTimecode` from a millisecond timestamp, snapped to the
44    /// nearest frame boundary.
45    ///
46    /// Returns `None` if `fps_den` is zero.
47    #[must_use]
48    pub fn from_ms(timestamp_ms: u64, fps_num: u32, fps_den: u32) -> Option<Self> {
49        if fps_den == 0 || fps_num == 0 {
50            return None;
51        }
52        let frame = timestamp_ms * u64::from(fps_num) / (1_000 * u64::from(fps_den));
53        // Re-snap the ms to the actual frame boundary.
54        let snapped_ms = frame * 1_000 * u64::from(fps_den) / u64::from(fps_num);
55        Some(Self {
56            frame,
57            timestamp_ms: snapped_ms,
58        })
59    }
60
61    /// Returns the duration in milliseconds between `self` and `other`.
62    ///
63    /// Returns `None` if `other` is before `self`.
64    #[must_use]
65    pub fn duration_to(&self, other: &Self) -> Option<u64> {
66        other.timestamp_ms.checked_sub(self.timestamp_ms)
67    }
68
69    /// Returns the number of frames between `self` and `other`.
70    ///
71    /// Returns `None` if `other` is before `self`.
72    #[must_use]
73    pub fn frames_to(&self, other: &Self) -> Option<u64> {
74        other.frame.checked_sub(self.frame)
75    }
76}
77
78// ─── TrimPoint ────────────────────────────────────────────────────────────────
79
80/// A single inclusive trim point (in-point or out-point).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum TrimPoint {
83    /// Trim at a frame number.
84    Frame(u64),
85    /// Trim at a millisecond timestamp (snapped to nearest frame during validation).
86    Milliseconds(u64),
87}
88
89impl TrimPoint {
90    /// Resolves the trim point to a `FrameTimecode` given the source frame rate.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the frame rate is zero.
95    pub fn resolve(&self, fps_num: u32, fps_den: u32) -> Result<FrameTimecode> {
96        match self {
97            Self::Frame(f) => FrameTimecode::from_frame(*f, fps_num, fps_den).ok_or_else(|| {
98                TranscodeError::ValidationError(crate::ValidationError::Unsupported(
99                    "Invalid frame rate: fps_num or fps_den is zero".into(),
100                ))
101            }),
102            Self::Milliseconds(ms) => {
103                FrameTimecode::from_ms(*ms, fps_num, fps_den).ok_or_else(|| {
104                    TranscodeError::ValidationError(crate::ValidationError::Unsupported(
105                        "Invalid frame rate: fps_num or fps_den is zero".into(),
106                    ))
107                })
108            }
109        }
110    }
111}
112
113// ─── TrimRange ────────────────────────────────────────────────────────────────
114
115/// A single contiguous inclusive trim range `[in_point, out_point]`.
116///
117/// The `in_point` is the first frame to *keep*; `out_point` is the last frame
118/// to *keep* (both inclusive).
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct TrimRange {
121    /// First frame (inclusive) to keep.
122    pub in_point: TrimPoint,
123    /// Last frame (inclusive) to keep.
124    pub out_point: TrimPoint,
125}
126
127impl TrimRange {
128    /// Creates a frame-accurate trim range.
129    #[must_use]
130    pub fn frames(in_frame: u64, out_frame: u64) -> Self {
131        Self {
132            in_point: TrimPoint::Frame(in_frame),
133            out_point: TrimPoint::Frame(out_frame),
134        }
135    }
136
137    /// Creates a millisecond-based trim range.
138    #[must_use]
139    pub fn milliseconds(in_ms: u64, out_ms: u64) -> Self {
140        Self {
141            in_point: TrimPoint::Milliseconds(in_ms),
142            out_point: TrimPoint::Milliseconds(out_ms),
143        }
144    }
145
146    /// Validates that `in_point < out_point` and optionally that both points
147    /// lie within `total_frames`.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the range is invalid.
152    pub fn validate(&self, fps_num: u32, fps_den: u32, total_frames: Option<u64>) -> Result<()> {
153        let in_tc = self.in_point.resolve(fps_num, fps_den)?;
154        let out_tc = self.out_point.resolve(fps_num, fps_den)?;
155
156        if in_tc.frame >= out_tc.frame {
157            return Err(TranscodeError::ValidationError(
158                crate::ValidationError::Unsupported(format!(
159                    "Trim in-point frame {} must be less than out-point frame {}",
160                    in_tc.frame, out_tc.frame
161                )),
162            ));
163        }
164
165        if let Some(total) = total_frames {
166            if out_tc.frame >= total {
167                return Err(TranscodeError::ValidationError(
168                    crate::ValidationError::Unsupported(format!(
169                        "Trim out-point frame {} exceeds total frames {}",
170                        out_tc.frame, total
171                    )),
172                ));
173            }
174        }
175
176        Ok(())
177    }
178
179    /// Resolves the trim range to resolved `FrameTimecode` values.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if the frame rate is invalid.
184    pub fn resolve(&self, fps_num: u32, fps_den: u32) -> Result<ResolvedTrimRange> {
185        let in_tc = self.in_point.resolve(fps_num, fps_den)?;
186        let out_tc = self.out_point.resolve(fps_num, fps_den)?;
187        Ok(ResolvedTrimRange {
188            in_point: in_tc,
189            out_point: out_tc,
190        })
191    }
192}
193
194/// A `TrimRange` whose points have been resolved to concrete `FrameTimecode` values.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct ResolvedTrimRange {
197    /// Resolved in-point.
198    pub in_point: FrameTimecode,
199    /// Resolved out-point.
200    pub out_point: FrameTimecode,
201}
202
203impl ResolvedTrimRange {
204    /// Returns the duration in frames (inclusive).
205    #[must_use]
206    pub fn frame_count(&self) -> u64 {
207        self.out_point.frame.saturating_sub(self.in_point.frame) + 1
208    }
209
210    /// Returns the duration in milliseconds.
211    #[must_use]
212    pub fn duration_ms(&self) -> u64 {
213        self.out_point
214            .timestamp_ms
215            .saturating_sub(self.in_point.timestamp_ms)
216    }
217
218    /// Returns `true` if `frame` falls within this trim range (inclusive).
219    #[must_use]
220    pub fn contains_frame(&self, frame: u64) -> bool {
221        frame >= self.in_point.frame && frame <= self.out_point.frame
222    }
223}
224
225// ─── FrameTrimConfig ──────────────────────────────────────────────────────────
226
227/// Complete frame-accurate trim / multi-cut configuration for a transcode job.
228///
229/// Multiple `TrimRange` entries create a *cut list* — the pipeline concatenates
230/// only the segments that correspond to each range.
231#[derive(Debug, Clone)]
232pub struct FrameTrimConfig {
233    /// Source frame rate numerator.
234    pub fps_num: u32,
235    /// Source frame rate denominator.
236    pub fps_den: u32,
237    /// Total frame count of the source (used for bounds checking).
238    pub total_source_frames: Option<u64>,
239    /// Ordered list of ranges to include in the output.
240    pub ranges: Vec<TrimRange>,
241}
242
243impl FrameTrimConfig {
244    /// Creates a new trim config for the given frame rate.
245    #[must_use]
246    pub fn new(fps_num: u32, fps_den: u32) -> Self {
247        Self {
248            fps_num,
249            fps_den,
250            total_source_frames: None,
251            ranges: Vec::new(),
252        }
253    }
254
255    /// Sets the total frame count for bounds checking.
256    #[must_use]
257    pub fn total_frames(mut self, n: u64) -> Self {
258        self.total_source_frames = Some(n);
259        self
260    }
261
262    /// Adds a trim range.
263    #[must_use]
264    pub fn add_range(mut self, range: TrimRange) -> Self {
265        self.ranges.push(range);
266        self
267    }
268
269    /// Validates all ranges and returns resolved ranges sorted by in-point.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if any range is invalid or if ranges overlap.
274    pub fn validate_and_resolve(&self) -> Result<Vec<ResolvedTrimRange>> {
275        if self.ranges.is_empty() {
276            return Err(TranscodeError::ValidationError(
277                crate::ValidationError::Unsupported("Trim config has no ranges".into()),
278            ));
279        }
280
281        let mut resolved: Vec<ResolvedTrimRange> = self
282            .ranges
283            .iter()
284            .map(|r| {
285                r.validate(self.fps_num, self.fps_den, self.total_source_frames)?;
286                r.resolve(self.fps_num, self.fps_den)
287            })
288            .collect::<Result<Vec<_>>>()?;
289
290        // Sort by in-point frame
291        resolved.sort_by_key(|r| r.in_point.frame);
292
293        // Check for overlapping ranges
294        for pair in resolved.windows(2) {
295            let a = &pair[0];
296            let b = &pair[1];
297            if b.in_point.frame <= a.out_point.frame {
298                return Err(TranscodeError::ValidationError(
299                    crate::ValidationError::Unsupported(format!(
300                        "Trim ranges overlap: [{}, {}] and [{}, {}]",
301                        a.in_point.frame, a.out_point.frame, b.in_point.frame, b.out_point.frame
302                    )),
303                ));
304            }
305        }
306
307        Ok(resolved)
308    }
309
310    /// Returns the total output frame count across all ranges.
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if validation fails.
315    pub fn total_output_frames(&self) -> Result<u64> {
316        let resolved = self.validate_and_resolve()?;
317        Ok(resolved.iter().map(|r| r.frame_count()).sum())
318    }
319
320    /// Returns the total output duration in milliseconds.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if validation fails.
325    pub fn total_output_duration_ms(&self) -> Result<u64> {
326        let resolved = self.validate_and_resolve()?;
327        Ok(resolved.iter().map(|r| r.duration_ms()).sum())
328    }
329
330    /// Returns `true` if the given source frame should be included in the output.
331    ///
332    /// Pre-resolves ranges lazily; panics-safe (returns `false` on error).
333    #[must_use]
334    pub fn should_include_frame(&self, frame: u64) -> bool {
335        match self.validate_and_resolve() {
336            Ok(resolved) => resolved.iter().any(|r| r.contains_frame(frame)),
337            Err(_) => false,
338        }
339    }
340}
341
342// ─── Tests ────────────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_frame_timecode_from_frame_30fps() {
350        let tc = FrameTimecode::from_frame(30, 30, 1).expect("valid");
351        assert_eq!(tc.frame, 30);
352        assert_eq!(tc.timestamp_ms, 1000);
353    }
354
355    #[test]
356    fn test_frame_timecode_from_ms_30fps() {
357        let tc = FrameTimecode::from_ms(1000, 30, 1).expect("valid");
358        assert_eq!(tc.frame, 30);
359        assert_eq!(tc.timestamp_ms, 1000);
360    }
361
362    #[test]
363    fn test_frame_timecode_zero_fps_returns_none() {
364        assert!(FrameTimecode::from_frame(5, 0, 1).is_none());
365        assert!(FrameTimecode::from_frame(5, 30, 0).is_none());
366    }
367
368    #[test]
369    fn test_frame_timecode_duration_to() {
370        let a = FrameTimecode::from_frame(0, 30, 1).expect("valid");
371        let b = FrameTimecode::from_frame(30, 30, 1).expect("valid");
372        assert_eq!(a.duration_to(&b), Some(1000));
373    }
374
375    #[test]
376    fn test_frame_timecode_duration_to_reverse_is_none() {
377        let a = FrameTimecode::from_frame(30, 30, 1).expect("valid");
378        let b = FrameTimecode::from_frame(0, 30, 1).expect("valid");
379        assert_eq!(a.duration_to(&b), None);
380    }
381
382    #[test]
383    fn test_trim_range_validate_ok() {
384        let range = TrimRange::frames(0, 59);
385        assert!(range.validate(30, 1, Some(120)).is_ok());
386    }
387
388    #[test]
389    fn test_trim_range_validate_in_ge_out_fails() {
390        let range = TrimRange::frames(60, 30);
391        assert!(range.validate(30, 1, None).is_err());
392    }
393
394    #[test]
395    fn test_trim_range_validate_out_exceeds_total_fails() {
396        let range = TrimRange::frames(0, 200);
397        assert!(range.validate(30, 1, Some(100)).is_err());
398    }
399
400    #[test]
401    fn test_trim_range_resolve_ms() {
402        let range = TrimRange::milliseconds(0, 1000);
403        let resolved = range.resolve(30, 1).expect("valid");
404        assert_eq!(resolved.in_point.frame, 0);
405        assert_eq!(resolved.out_point.frame, 30);
406    }
407
408    #[test]
409    fn test_resolved_range_frame_count() {
410        let range = TrimRange::frames(10, 19);
411        let r = range.resolve(30, 1).expect("valid");
412        assert_eq!(r.frame_count(), 10);
413    }
414
415    #[test]
416    fn test_resolved_range_contains_frame() {
417        let range = TrimRange::frames(10, 19);
418        let r = range.resolve(30, 1).expect("valid");
419        assert!(r.contains_frame(10));
420        assert!(r.contains_frame(15));
421        assert!(r.contains_frame(19));
422        assert!(!r.contains_frame(9));
423        assert!(!r.contains_frame(20));
424    }
425
426    #[test]
427    fn test_trim_config_total_output_frames() {
428        let cfg = FrameTrimConfig::new(30, 1)
429            .total_frames(300)
430            .add_range(TrimRange::frames(0, 29)) // 30 frames
431            .add_range(TrimRange::frames(60, 89)); // 30 frames
432        assert_eq!(cfg.total_output_frames().expect("valid"), 60);
433    }
434
435    #[test]
436    fn test_trim_config_overlapping_ranges_fails() {
437        let cfg = FrameTrimConfig::new(30, 1)
438            .add_range(TrimRange::frames(0, 59))
439            .add_range(TrimRange::frames(30, 89)); // overlaps
440        assert!(cfg.validate_and_resolve().is_err());
441    }
442
443    #[test]
444    fn test_trim_config_no_ranges_fails() {
445        let cfg = FrameTrimConfig::new(30, 1);
446        assert!(cfg.validate_and_resolve().is_err());
447    }
448
449    #[test]
450    fn test_should_include_frame() {
451        let cfg = FrameTrimConfig::new(30, 1)
452            .add_range(TrimRange::frames(10, 19))
453            .add_range(TrimRange::frames(30, 39));
454
455        assert!(cfg.should_include_frame(10));
456        assert!(cfg.should_include_frame(15));
457        assert!(cfg.should_include_frame(19));
458        assert!(cfg.should_include_frame(30));
459        assert!(!cfg.should_include_frame(9));
460        assert!(!cfg.should_include_frame(20));
461        assert!(!cfg.should_include_frame(29));
462        assert!(!cfg.should_include_frame(40));
463    }
464
465    #[test]
466    fn test_total_output_duration_ms() {
467        // Two 1-second clips at 30 fps
468        let cfg = FrameTrimConfig::new(30, 1)
469            .add_range(TrimRange::milliseconds(0, 1000))
470            .add_range(TrimRange::milliseconds(2000, 3000));
471        let total_ms = cfg.total_output_duration_ms().expect("valid");
472        assert_eq!(total_ms, 2000);
473    }
474}