Skip to main content

oximedia_timecode/
tc_calculator.rs

1//! Timecode arithmetic: add/subtract frame counts with overflow handling.
2//!
3//! `TcCalculator` converts between the display timecode and an absolute
4//! frame count so that arithmetic on timecodes always respects drop-frame
5//! rules and 24-hour wrap-around.
6
7#![allow(dead_code)]
8
9use crate::{FrameRate, Timecode, TimecodeError};
10
11// ── Operation enum ────────────────────────────────────────────────────────────
12
13/// An arithmetic operation to apply to a timecode.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TcOperation {
16    /// Add a positive number of frames.
17    AddFrames(u64),
18    /// Subtract frames, clamping at zero (no underflow panic).
19    SubtractFrames(u64),
20    /// Add whole seconds.
21    AddSeconds(u32),
22    /// Subtract whole seconds, clamping at zero.
23    SubtractSeconds(u32),
24}
25
26impl std::fmt::Display for TcOperation {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::AddFrames(n) => write!(f, "+{n} frames"),
30            Self::SubtractFrames(n) => write!(f, "-{n} frames"),
31            Self::AddSeconds(s) => write!(f, "+{s} seconds"),
32            Self::SubtractSeconds(s) => write!(f, "-{s} seconds"),
33        }
34    }
35}
36
37// ── Result type ───────────────────────────────────────────────────────────────
38
39/// The result of a timecode calculation.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct TcResult {
42    /// The resulting timecode.
43    pub timecode: Timecode,
44    /// Whether 24-hour wrap-around occurred.
45    pub wrapped: bool,
46    /// How many full 24-hour periods were crossed (useful for multi-day spans).
47    pub days_wrapped: u32,
48}
49
50impl TcResult {
51    /// Convenience: return the inner `Timecode`.
52    pub fn tc(&self) -> &Timecode {
53        &self.timecode
54    }
55}
56
57// ── Calculator ────────────────────────────────────────────────────────────────
58
59/// Performs timecode arithmetic with drop-frame awareness.
60///
61/// # Example
62/// ```
63/// use oximedia_timecode::{Timecode, FrameRate};
64/// use oximedia_timecode::tc_calculator::{TcCalculator, TcOperation};
65///
66/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
67/// let calc = TcCalculator::new(FrameRate::Fps25);
68/// let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25)?;
69/// let result = calc.apply(&tc, TcOperation::AddFrames(50))?;
70/// assert_eq!(result.timecode.seconds, 2);
71/// # Ok(())
72/// # }
73/// ```
74#[derive(Debug, Clone, Copy)]
75pub struct TcCalculator {
76    frame_rate: FrameRate,
77}
78
79impl TcCalculator {
80    /// Create a new calculator for the given frame rate.
81    pub fn new(frame_rate: FrameRate) -> Self {
82        Self { frame_rate }
83    }
84
85    /// Total number of frames in a 24-hour day for this frame rate.
86    pub fn frames_per_day(&self) -> u64 {
87        let fps = self.frame_rate.frames_per_second() as u64;
88        if self.frame_rate.is_drop_frame() {
89            // 29.97 DF: 24*60*60*30 - 2*(24*60 - 24*6) = 2589408 frames/day (exact)
90            // General formula: (fps * 3600 * 24) - 2 * (24*60 - 24*60/10)
91            let total_minutes = 24u64 * 60;
92            let non_tenth_minutes = total_minutes - total_minutes / 10;
93            fps * 3600 * 24 - 2 * non_tenth_minutes
94        } else {
95            fps * 3600 * 24
96        }
97    }
98
99    /// Apply an operation to a timecode.
100    pub fn apply(&self, tc: &Timecode, op: TcOperation) -> Result<TcResult, TimecodeError> {
101        let fpd = self.frames_per_day();
102        let current = tc.to_frames();
103
104        let (raw_target, wrapped, days_wrapped) = match op {
105            TcOperation::AddFrames(n) => {
106                let total = current + n;
107                let days = (total / fpd) as u32;
108                let pos = total % fpd;
109                (pos, days > 0, days)
110            }
111            TcOperation::SubtractFrames(n) => {
112                if n <= current {
113                    (current - n, false, 0)
114                } else {
115                    // Wrap backwards
116                    let deficit = n - current;
117                    let days = deficit.div_ceil(fpd) as u32;
118                    let pos = fpd - (deficit % fpd);
119                    let pos = if pos == fpd { 0 } else { pos };
120                    (pos, true, days)
121                }
122            }
123            TcOperation::AddSeconds(s) => {
124                let fps = self.frame_rate.frames_per_second() as u64;
125                self.apply(tc, TcOperation::AddFrames(s as u64 * fps))?;
126                // Re-route through AddFrames
127                return self.apply(tc, TcOperation::AddFrames(s as u64 * fps));
128            }
129            TcOperation::SubtractSeconds(s) => {
130                let fps = self.frame_rate.frames_per_second() as u64;
131                return self.apply(tc, TcOperation::SubtractFrames(s as u64 * fps));
132            }
133        };
134
135        let result_tc = Timecode::from_frames(raw_target, self.frame_rate)?;
136        Ok(TcResult {
137            timecode: result_tc,
138            wrapped,
139            days_wrapped,
140        })
141    }
142
143    /// Compute the signed frame difference `b - a`.
144    /// Positive means `b` is later; negative means `b` is earlier (wrap is ignored).
145    pub fn difference(&self, a: &Timecode, b: &Timecode) -> i64 {
146        b.to_frames() as i64 - a.to_frames() as i64
147    }
148
149    /// Return the later of two timecodes.
150    pub fn max_tc<'a>(&self, a: &'a Timecode, b: &'a Timecode) -> &'a Timecode {
151        if a.to_frames() >= b.to_frames() {
152            a
153        } else {
154            b
155        }
156    }
157
158    /// Return the earlier of two timecodes.
159    pub fn min_tc<'a>(&self, a: &'a Timecode, b: &'a Timecode) -> &'a Timecode {
160        if a.to_frames() <= b.to_frames() {
161            a
162        } else {
163            b
164        }
165    }
166}
167
168// ── Tests ─────────────────────────────────────────────────────────────────────
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn tc(h: u8, m: u8, s: u8, f: u8) -> Timecode {
175        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid timecode")
176    }
177
178    fn calc() -> TcCalculator {
179        TcCalculator::new(FrameRate::Fps25)
180    }
181
182    #[test]
183    fn test_add_frames_basic() {
184        let result = calc()
185            .apply(&tc(0, 0, 0, 0), TcOperation::AddFrames(25))
186            .expect("should succeed");
187        assert_eq!(result.timecode.seconds, 1);
188        assert_eq!(result.timecode.frames, 0);
189        assert!(!result.wrapped);
190    }
191
192    #[test]
193    fn test_add_frames_wraps_hour() {
194        let result = calc()
195            .apply(&tc(23, 59, 59, 24), TcOperation::AddFrames(1))
196            .expect("should succeed");
197        // Should wrap to midnight
198        assert_eq!(result.timecode.hours, 0);
199        assert!(result.wrapped);
200        assert_eq!(result.days_wrapped, 1);
201    }
202
203    #[test]
204    fn test_subtract_frames_basic() {
205        let result = calc()
206            .apply(&tc(0, 0, 2, 0), TcOperation::SubtractFrames(25))
207            .expect("should succeed");
208        assert_eq!(result.timecode.seconds, 1);
209        assert!(!result.wrapped);
210    }
211
212    #[test]
213    fn test_subtract_frames_wraps_backwards() {
214        let result = calc()
215            .apply(&tc(0, 0, 0, 0), TcOperation::SubtractFrames(25))
216            .expect("should succeed");
217        // Should wrap to 23:59:59:00
218        assert_eq!(result.timecode.hours, 23);
219        assert!(result.wrapped);
220    }
221
222    #[test]
223    fn test_add_seconds() {
224        let result = calc()
225            .apply(&tc(0, 0, 0, 0), TcOperation::AddSeconds(3))
226            .expect("should succeed");
227        assert_eq!(result.timecode.seconds, 3);
228    }
229
230    #[test]
231    fn test_subtract_seconds() {
232        let result = calc()
233            .apply(&tc(0, 0, 5, 0), TcOperation::SubtractSeconds(3))
234            .expect("should succeed");
235        assert_eq!(result.timecode.seconds, 2);
236    }
237
238    #[test]
239    fn test_difference_positive() {
240        let a = tc(0, 0, 0, 0);
241        let b = tc(0, 0, 1, 0);
242        assert_eq!(calc().difference(&a, &b), 25);
243    }
244
245    #[test]
246    fn test_difference_negative() {
247        let a = tc(0, 0, 1, 0);
248        let b = tc(0, 0, 0, 0);
249        assert_eq!(calc().difference(&a, &b), -25);
250    }
251
252    #[test]
253    fn test_difference_zero() {
254        let a = tc(1, 2, 3, 4);
255        let b = tc(1, 2, 3, 4);
256        assert_eq!(calc().difference(&a, &b), 0);
257    }
258
259    #[test]
260    fn test_max_tc() {
261        let a = tc(0, 0, 0, 0);
262        let b = tc(0, 0, 1, 0);
263        let m = calc().max_tc(&a, &b);
264        assert_eq!(m.seconds, 1);
265    }
266
267    #[test]
268    fn test_min_tc() {
269        let a = tc(0, 0, 0, 0);
270        let b = tc(0, 0, 1, 0);
271        let m = calc().min_tc(&a, &b);
272        assert_eq!(m.seconds, 0);
273    }
274
275    #[test]
276    fn test_frames_per_day_25fps() {
277        let c = TcCalculator::new(FrameRate::Fps25);
278        assert_eq!(c.frames_per_day(), 25 * 3600 * 24);
279    }
280
281    #[test]
282    fn test_frames_per_day_30fps() {
283        let c = TcCalculator::new(FrameRate::Fps30);
284        assert_eq!(c.frames_per_day(), 30 * 3600 * 24);
285    }
286
287    #[test]
288    fn test_tc_result_tc_accessor() {
289        let result = calc()
290            .apply(&tc(0, 0, 1, 0), TcOperation::AddFrames(0))
291            .expect("should succeed");
292        assert_eq!(result.tc().seconds, 1);
293    }
294
295    #[test]
296    fn test_operation_display() {
297        assert_eq!(TcOperation::AddFrames(10).to_string(), "+10 frames");
298        assert_eq!(TcOperation::SubtractSeconds(5).to_string(), "-5 seconds");
299    }
300
301    #[test]
302    fn test_add_zero_frames() {
303        let original = tc(1, 2, 3, 4);
304        let result = calc()
305            .apply(&original, TcOperation::AddFrames(0))
306            .expect("operation should succeed");
307        assert_eq!(result.timecode, original);
308        assert!(!result.wrapped);
309    }
310}