Skip to main content

oximedia_timecode/
duration.rs

1//! Timecode-based duration calculations
2//!
3//! Provides `TcDuration`, `DurationRange`, and helper functions for computing
4//! durations relative to a given frame rate.
5
6#[allow(dead_code)]
7/// A duration expressed as a frame count at a given frame rate
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct TcDuration {
10    /// Signed frame count (negative = before reference point)
11    pub frames: i64,
12    /// Frame rate numerator
13    pub frame_rate_num: u32,
14    /// Frame rate denominator
15    pub frame_rate_den: u32,
16}
17
18impl TcDuration {
19    /// Create a new `TcDuration`
20    #[must_use]
21    pub fn new(frames: i64, frame_rate_num: u32, frame_rate_den: u32) -> Self {
22        Self {
23            frames,
24            frame_rate_num,
25            frame_rate_den,
26        }
27    }
28
29    /// Convert to floating-point seconds
30    #[allow(clippy::cast_precision_loss)]
31    #[must_use]
32    pub fn to_seconds(&self) -> f64 {
33        if self.frame_rate_num == 0 {
34            return 0.0;
35        }
36        self.frames as f64 * self.frame_rate_den as f64 / self.frame_rate_num as f64
37    }
38
39    /// Convert to milliseconds
40    #[must_use]
41    pub fn to_milliseconds(&self) -> f64 {
42        self.to_seconds() * 1000.0
43    }
44
45    /// Add two durations (must share the same frame rate)
46    #[must_use]
47    pub fn add(&self, other: &TcDuration) -> TcDuration {
48        TcDuration {
49            frames: self.frames + other.frames,
50            frame_rate_num: self.frame_rate_num,
51            frame_rate_den: self.frame_rate_den,
52        }
53    }
54
55    /// Subtract `other` from `self`. Returns `None` if the result would be negative.
56    #[must_use]
57    pub fn subtract(&self, other: &TcDuration) -> Option<TcDuration> {
58        let result = self.frames - other.frames;
59        if result < 0 {
60            None
61        } else {
62            Some(TcDuration {
63                frames: result,
64                frame_rate_num: self.frame_rate_num,
65                frame_rate_den: self.frame_rate_den,
66            })
67        }
68    }
69
70    /// Returns `true` when the frame count is negative
71    #[must_use]
72    pub fn is_negative(&self) -> bool {
73        self.frames < 0
74    }
75}
76
77#[allow(dead_code)]
78/// A half-open frame range `[start_frames, end_frames)`
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct DurationRange {
81    /// First frame of the range (inclusive)
82    pub start_frames: i64,
83    /// Last frame of the range (exclusive)
84    pub end_frames: i64,
85}
86
87impl DurationRange {
88    /// Create a new `DurationRange`
89    #[must_use]
90    pub fn new(start_frames: i64, end_frames: i64) -> Self {
91        Self {
92            start_frames,
93            end_frames,
94        }
95    }
96
97    /// Return the total number of frames covered by the range
98    #[must_use]
99    pub fn duration_frames(&self) -> i64 {
100        self.end_frames - self.start_frames
101    }
102
103    /// Returns `true` when `frame` falls within `[start_frames, end_frames)`
104    #[must_use]
105    pub fn contains(&self, frame: i64) -> bool {
106        frame >= self.start_frames && frame < self.end_frames
107    }
108
109    /// Returns `true` when this range overlaps with `other`
110    #[must_use]
111    pub fn overlaps(&self, other: &DurationRange) -> bool {
112        self.start_frames < other.end_frames && other.start_frames < self.end_frames
113    }
114}
115
116/// Compute the signed difference `tc1_frames - tc2_frames` as a `TcDuration`
117#[must_use]
118pub fn timecode_subtract(
119    tc1_frames: i64,
120    tc2_frames: i64,
121    fps_num: u32,
122    fps_den: u32,
123) -> TcDuration {
124    TcDuration::new(tc1_frames - tc2_frames, fps_num, fps_den)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn dur(frames: i64) -> TcDuration {
132        TcDuration::new(frames, 25, 1)
133    }
134
135    #[test]
136    fn test_to_seconds_25fps() {
137        // 25 frames at 25 fps = 1 second
138        assert!((dur(25).to_seconds() - 1.0).abs() < f64::EPSILON);
139    }
140
141    #[test]
142    fn test_to_seconds_zero_fps() {
143        let d = TcDuration::new(100, 0, 1);
144        assert_eq!(d.to_seconds(), 0.0);
145    }
146
147    #[test]
148    fn test_to_milliseconds() {
149        // 25 frames at 25 fps = 1000 ms
150        assert!((dur(25).to_milliseconds() - 1000.0).abs() < 1e-9);
151    }
152
153    #[test]
154    fn test_add() {
155        let result = dur(10).add(&dur(15));
156        assert_eq!(result.frames, 25);
157    }
158
159    #[test]
160    fn test_subtract_positive() {
161        let result = dur(30).subtract(&dur(10));
162        assert!(result.is_some());
163        assert_eq!(result.unwrap().frames, 20);
164    }
165
166    #[test]
167    fn test_subtract_to_zero() {
168        let result = dur(10).subtract(&dur(10));
169        assert!(result.is_some());
170        assert_eq!(result.unwrap().frames, 0);
171    }
172
173    #[test]
174    fn test_subtract_negative_returns_none() {
175        let result = dur(5).subtract(&dur(10));
176        assert!(result.is_none());
177    }
178
179    #[test]
180    fn test_is_negative_false() {
181        assert!(!dur(10).is_negative());
182    }
183
184    #[test]
185    fn test_is_negative_true() {
186        assert!(TcDuration::new(-1, 25, 1).is_negative());
187    }
188
189    #[test]
190    fn test_duration_range_duration_frames() {
191        let r = DurationRange::new(100, 200);
192        assert_eq!(r.duration_frames(), 100);
193    }
194
195    #[test]
196    fn test_duration_range_contains_true() {
197        let r = DurationRange::new(100, 200);
198        assert!(r.contains(100));
199        assert!(r.contains(150));
200        assert!(r.contains(199));
201    }
202
203    #[test]
204    fn test_duration_range_contains_false() {
205        let r = DurationRange::new(100, 200);
206        assert!(!r.contains(99));
207        assert!(!r.contains(200));
208    }
209
210    #[test]
211    fn test_duration_range_overlaps_true() {
212        let a = DurationRange::new(0, 100);
213        let b = DurationRange::new(50, 150);
214        assert!(a.overlaps(&b));
215        assert!(b.overlaps(&a));
216    }
217
218    #[test]
219    fn test_duration_range_overlaps_false() {
220        let a = DurationRange::new(0, 50);
221        let b = DurationRange::new(50, 100);
222        assert!(!a.overlaps(&b));
223    }
224
225    #[test]
226    fn test_timecode_subtract_positive() {
227        let d = timecode_subtract(100, 40, 25, 1);
228        assert_eq!(d.frames, 60);
229        assert!(!d.is_negative());
230    }
231
232    #[test]
233    fn test_timecode_subtract_negative() {
234        let d = timecode_subtract(40, 100, 25, 1);
235        assert_eq!(d.frames, -60);
236        assert!(d.is_negative());
237    }
238}