Skip to main content

oximedia_timecode/
tc_math.rs

1//! Timecode mathematical operations.
2//!
3//! Provides operations for multiplying and dividing timecode durations,
4//! computing midpoints, and performing percentage-based offset calculations.
5//! All operations respect drop-frame rules and 24-hour boundaries.
6
7#![allow(dead_code)]
8
9use crate::{FrameRate, Timecode, TimecodeError};
10
11// -- TcDuration --------------------------------------------------------------
12
13/// A duration expressed in timecode frames.
14///
15/// Unlike [`Timecode`] this is not anchored to a time-of-day and can exceed
16/// 24 hours.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub struct TcDuration {
19    /// Total frames in this duration.
20    pub frames: u64,
21    /// Frames per second (rounded integer).
22    pub fps: u8,
23}
24
25impl TcDuration {
26    /// Create a duration from a frame count at a given fps.
27    pub fn from_frames(frames: u64, fps: u8) -> Self {
28        Self { frames, fps }
29    }
30
31    /// Create a duration from hours, minutes, seconds, and frames.
32    pub fn from_hmsf(hours: u32, minutes: u32, seconds: u32, frames: u32, fps: u8) -> Self {
33        let total = hours as u64 * 3600 * fps as u64
34            + minutes as u64 * 60 * fps as u64
35            + seconds as u64 * fps as u64
36            + frames as u64;
37        Self { frames: total, fps }
38    }
39
40    /// Convert to (hours, minutes, seconds, frames) tuple.
41    pub fn to_hmsf(&self) -> (u32, u32, u32, u32) {
42        let fps = self.fps as u64;
43        let total = self.frames;
44        let hours = (total / (fps * 3600)) as u32;
45        let rem = total % (fps * 3600);
46        let minutes = (rem / (fps * 60)) as u32;
47        let rem = rem % (fps * 60);
48        let seconds = (rem / fps) as u32;
49        let frames = (rem % fps) as u32;
50        (hours, minutes, seconds, frames)
51    }
52
53    /// Duration in seconds (floating point).
54    #[allow(clippy::cast_precision_loss)]
55    pub fn as_seconds(&self) -> f64 {
56        self.frames as f64 / self.fps as f64
57    }
58
59    /// Multiply the duration by an integer factor.
60    pub fn multiply(&self, factor: u64) -> Self {
61        Self {
62            frames: self.frames * factor,
63            fps: self.fps,
64        }
65    }
66
67    /// Divide the duration by an integer divisor.
68    /// Returns `None` if divisor is zero.
69    pub fn divide(&self, divisor: u64) -> Option<Self> {
70        if divisor == 0 {
71            return None;
72        }
73        Some(Self {
74            frames: self.frames / divisor,
75            fps: self.fps,
76        })
77    }
78
79    /// Scale the duration by a floating-point factor (e.g. speed change).
80    #[allow(clippy::cast_precision_loss)]
81    #[allow(clippy::cast_possible_truncation)]
82    #[allow(clippy::cast_sign_loss)]
83    pub fn scale(&self, factor: f64) -> Self {
84        let scaled = (self.frames as f64 * factor).round() as u64;
85        Self {
86            frames: scaled,
87            fps: self.fps,
88        }
89    }
90
91    /// Compute the midpoint between zero and this duration.
92    pub fn midpoint(&self) -> Self {
93        Self {
94            frames: self.frames / 2,
95            fps: self.fps,
96        }
97    }
98
99    /// Add two durations together.
100    pub fn add(&self, other: &TcDuration) -> Self {
101        Self {
102            frames: self.frames + other.frames,
103            fps: self.fps,
104        }
105    }
106
107    /// Subtract another duration (saturating at zero).
108    pub fn subtract(&self, other: &TcDuration) -> Self {
109        Self {
110            frames: self.frames.saturating_sub(other.frames),
111            fps: self.fps,
112        }
113    }
114
115    /// Return `true` if the duration is zero.
116    pub fn is_zero(&self) -> bool {
117        self.frames == 0
118    }
119}
120
121impl std::fmt::Display for TcDuration {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        let (h, m, s, fr) = self.to_hmsf();
124        write!(f, "{h:02}:{m:02}:{s:02}:{fr:02}")
125    }
126}
127
128// -- TcMath ------------------------------------------------------------------
129
130/// Stateless utility for timecode mathematical operations.
131///
132/// # Example
133/// ```
134/// use oximedia_timecode::tc_math::{TcMath, TcDuration};
135///
136/// let dur = TcDuration::from_hmsf(0, 1, 0, 0, 25); // 1 minute
137/// let mid = TcMath::midpoint_between_durations(&TcDuration::from_frames(0, 25), &dur);
138/// assert_eq!(mid.frames, 750); // 30 seconds at 25fps
139/// ```
140pub struct TcMath;
141
142impl TcMath {
143    /// Compute the duration between two timecodes (absolute value).
144    pub fn duration_between(a: &Timecode, b: &Timecode) -> TcDuration {
145        let fa = a.to_frames();
146        let fb = b.to_frames();
147        let diff = fa.abs_diff(fb);
148        TcDuration::from_frames(diff, a.frame_rate.fps)
149    }
150
151    /// Compute the midpoint timecode between two timecodes.
152    pub fn midpoint(
153        a: &Timecode,
154        b: &Timecode,
155        rate: FrameRate,
156    ) -> Result<Timecode, TimecodeError> {
157        let fa = a.to_frames();
158        let fb = b.to_frames();
159        let mid = (fa + fb) / 2;
160        Timecode::from_frames(mid, rate)
161    }
162
163    /// Offset a timecode by a percentage of a duration.
164    #[allow(clippy::cast_precision_loss)]
165    #[allow(clippy::cast_possible_truncation)]
166    #[allow(clippy::cast_sign_loss)]
167    pub fn offset_by_percentage(
168        tc: &Timecode,
169        duration: &TcDuration,
170        pct: f64,
171        rate: FrameRate,
172    ) -> Result<Timecode, TimecodeError> {
173        let offset_frames = (duration.frames as f64 * pct / 100.0).round() as u64;
174        let target = tc.to_frames() + offset_frames;
175        Timecode::from_frames(target, rate)
176    }
177
178    /// Compute the midpoint between two durations (not anchored to a TOD).
179    pub fn midpoint_between_durations(a: &TcDuration, b: &TcDuration) -> TcDuration {
180        TcDuration::from_frames((a.frames + b.frames) / 2, a.fps)
181    }
182
183    /// Compute a percentage position of a timecode within a range.
184    #[allow(clippy::cast_precision_loss)]
185    pub fn position_percentage(tc: &Timecode, start: &Timecode, end: &Timecode) -> f64 {
186        let pos = tc.to_frames();
187        let s = start.to_frames();
188        let e = end.to_frames();
189        if e <= s {
190            return 0.0;
191        }
192        ((pos - s) as f64 / (e - s) as f64) * 100.0
193    }
194
195    /// Compute the frame rate conversion factor between two rates.
196    #[allow(clippy::cast_precision_loss)]
197    pub fn rate_conversion_factor(from: FrameRate, to: FrameRate) -> f64 {
198        to.as_float() / from.as_float()
199    }
200
201    /// Convert a frame count from one frame rate to another.
202    #[allow(clippy::cast_precision_loss)]
203    #[allow(clippy::cast_possible_truncation)]
204    #[allow(clippy::cast_sign_loss)]
205    pub fn convert_frame_count(frames: u64, from: FrameRate, to: FrameRate) -> u64 {
206        let factor = Self::rate_conversion_factor(from, to);
207        (frames as f64 * factor).round() as u64
208    }
209}
210
211// -- Tests -------------------------------------------------------------------
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn tc25(h: u8, m: u8, s: u8, f: u8) -> Timecode {
218        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid timecode")
219    }
220
221    #[test]
222    fn test_duration_from_hmsf() {
223        let d = TcDuration::from_hmsf(1, 0, 0, 0, 25);
224        assert_eq!(d.frames, 90000); // 1h * 3600s * 25fps
225    }
226
227    #[test]
228    fn test_duration_to_hmsf() {
229        let d = TcDuration::from_frames(90000, 25);
230        let (h, m, s, f) = d.to_hmsf();
231        assert_eq!((h, m, s, f), (1, 0, 0, 0));
232    }
233
234    #[test]
235    fn test_duration_as_seconds() {
236        let d = TcDuration::from_frames(50, 25);
237        let secs = d.as_seconds();
238        assert!((secs - 2.0).abs() < 1e-6);
239    }
240
241    #[test]
242    fn test_duration_multiply() {
243        let d = TcDuration::from_frames(100, 25);
244        let result = d.multiply(3);
245        assert_eq!(result.frames, 300);
246    }
247
248    #[test]
249    fn test_duration_divide() {
250        let d = TcDuration::from_frames(300, 25);
251        let result = d.divide(3).expect("divide should succeed");
252        assert_eq!(result.frames, 100);
253    }
254
255    #[test]
256    fn test_duration_divide_by_zero() {
257        let d = TcDuration::from_frames(100, 25);
258        assert!(d.divide(0).is_none());
259    }
260
261    #[test]
262    fn test_duration_scale() {
263        let d = TcDuration::from_frames(100, 25);
264        let result = d.scale(1.5);
265        assert_eq!(result.frames, 150);
266    }
267
268    #[test]
269    fn test_duration_midpoint() {
270        let d = TcDuration::from_frames(200, 25);
271        assert_eq!(d.midpoint().frames, 100);
272    }
273
274    #[test]
275    fn test_duration_add_subtract() {
276        let a = TcDuration::from_frames(100, 25);
277        let b = TcDuration::from_frames(50, 25);
278        assert_eq!(a.add(&b).frames, 150);
279        assert_eq!(a.subtract(&b).frames, 50);
280        assert_eq!(b.subtract(&a).frames, 0); // saturates
281    }
282
283    #[test]
284    fn test_duration_display() {
285        let d = TcDuration::from_hmsf(1, 2, 3, 4, 25);
286        assert_eq!(d.to_string(), "01:02:03:04");
287    }
288
289    #[test]
290    fn test_math_duration_between() {
291        let a = tc25(0, 0, 0, 0);
292        let b = tc25(0, 0, 2, 0);
293        let dur = TcMath::duration_between(&a, &b);
294        assert_eq!(dur.frames, 50);
295    }
296
297    #[test]
298    fn test_math_midpoint() {
299        let a = tc25(0, 0, 0, 0);
300        let b = tc25(0, 0, 4, 0);
301        let mid = TcMath::midpoint(&a, &b, FrameRate::Fps25).expect("midpoint should succeed");
302        assert_eq!(mid.seconds, 2);
303        assert_eq!(mid.frames, 0);
304    }
305
306    #[test]
307    fn test_math_offset_by_percentage() {
308        let tc = tc25(0, 0, 0, 0);
309        let dur = TcDuration::from_frames(100, 25);
310        let result = TcMath::offset_by_percentage(&tc, &dur, 50.0, FrameRate::Fps25)
311            .expect("offset by percentage should succeed");
312        assert_eq!(result.to_frames(), 50);
313    }
314
315    #[test]
316    fn test_math_position_percentage() {
317        let start = tc25(0, 0, 0, 0);
318        let end = tc25(0, 0, 4, 0); // 100 frames
319        let pos = tc25(0, 0, 2, 0); // 50 frames
320        let pct = TcMath::position_percentage(&pos, &start, &end);
321        assert!((pct - 50.0).abs() < 1e-6);
322    }
323
324    #[test]
325    fn test_math_rate_conversion_factor() {
326        let factor = TcMath::rate_conversion_factor(FrameRate::Fps25, FrameRate::Fps50);
327        assert!((factor - 2.0).abs() < 1e-6);
328    }
329
330    #[test]
331    fn test_math_convert_frame_count() {
332        let result = TcMath::convert_frame_count(100, FrameRate::Fps25, FrameRate::Fps50);
333        assert_eq!(result, 200);
334    }
335
336    #[test]
337    fn test_duration_is_zero() {
338        assert!(TcDuration::from_frames(0, 25).is_zero());
339        assert!(!TcDuration::from_frames(1, 25).is_zero());
340    }
341}