Skip to main content

oximedia_timecode/
tc_offset_table.rs

1#![allow(dead_code)]
2//! Pre-computed offset look-up tables for fast timecode-to-frame conversion.
3//!
4//! Converting timecodes to absolute frame numbers (especially with drop-frame
5//! adjustments) involves repeated arithmetic.  For performance-critical paths
6//! such as real-time LTC decoders, this module provides a pre-built table that
7//! maps every minute boundary to its absolute frame offset, enabling O(1)
8//! look-ups plus a small linear interpolation.
9
10use crate::{FrameRate, Timecode, TimecodeError};
11
12/// Maximum number of minutes in a 24-hour day.
13const MAX_MINUTES: usize = 24 * 60;
14
15/// Pre-computed offset table for a particular frame rate.
16#[derive(Debug, Clone)]
17pub struct OffsetTable {
18    /// Frame rate this table was built for.
19    rate: FrameRate,
20    /// `minute_offsets[m]` = absolute frame number at hour:min = m/60 : m%60, second=0, frame=0.
21    minute_offsets: Vec<u64>,
22    /// Frames per second (rounded integer).
23    fps: u64,
24}
25
26impl OffsetTable {
27    /// Build the offset table for the given frame rate.
28    ///
29    /// The table covers the full 24-hour range (1440 minute entries).
30    #[allow(clippy::cast_precision_loss)]
31    pub fn build(rate: FrameRate) -> Self {
32        let fps = rate.frames_per_second() as u64;
33        let is_df = rate.is_drop_frame();
34        let mut offsets = Vec::with_capacity(MAX_MINUTES);
35
36        let mut cumulative: u64 = 0;
37        for m in 0..MAX_MINUTES {
38            offsets.push(cumulative);
39            // Frames in this minute
40            let frames_in_minute = fps * 60;
41            if is_df && m > 0 {
42                // Drop-frame: skip 2 frames at the start of every minute
43                // except multiples of 10.
44                let next_m = m + 1;
45                if next_m % 10 != 0 {
46                    cumulative += frames_in_minute - 2;
47                } else {
48                    cumulative += frames_in_minute;
49                }
50            } else {
51                cumulative += frames_in_minute;
52            }
53        }
54
55        Self {
56            rate,
57            minute_offsets: offsets,
58            fps,
59        }
60    }
61
62    /// Convert a timecode to its absolute frame offset using the table.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`TimecodeError::InvalidHours`] if the timecode is out of range.
67    pub fn timecode_to_frame(&self, tc: &Timecode) -> Result<u64, TimecodeError> {
68        let minute_idx = tc.hours as usize * 60 + tc.minutes as usize;
69        if minute_idx >= MAX_MINUTES {
70            return Err(TimecodeError::InvalidHours);
71        }
72        let base = self.minute_offsets[minute_idx];
73        let extra = tc.seconds as u64 * self.fps + tc.frames as u64;
74        Ok(base + extra)
75    }
76
77    /// Convert an absolute frame offset back to a timecode using the table.
78    ///
79    /// Binary-searches the minute table then linearly computes seconds/frames.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`TimecodeError::InvalidFrames`] if the frame number exceeds the
84    /// 24-hour range.
85    pub fn frame_to_timecode(&self, frame: u64) -> Result<Timecode, TimecodeError> {
86        // Binary search for the minute bucket
87        let mut lo: usize = 0;
88        let mut hi: usize = self.minute_offsets.len();
89        while lo + 1 < hi {
90            let mid = (lo + hi) / 2;
91            if self.minute_offsets[mid] <= frame {
92                lo = mid;
93            } else {
94                hi = mid;
95            }
96        }
97        let minute_idx = lo;
98        let remaining = frame - self.minute_offsets[minute_idx];
99
100        let hours = (minute_idx / 60) as u8;
101        let minutes = (minute_idx % 60) as u8;
102        let seconds = (remaining / self.fps) as u8;
103        let frames = (remaining % self.fps) as u8;
104
105        Timecode::new(hours, minutes, seconds, frames, self.rate)
106    }
107
108    /// Get the frame rate this table was built for.
109    pub fn rate(&self) -> FrameRate {
110        self.rate
111    }
112
113    /// Total number of frames in a full 24-hour day.
114    pub fn total_day_frames(&self) -> u64 {
115        let last_idx = MAX_MINUTES - 1;
116        let base = self.minute_offsets[last_idx];
117        // Add frames for the last minute
118        if self.rate.is_drop_frame() {
119            // Last minute (23:59) — minute 1439 — 1439 % 10 != 0 → drop
120            base + self.fps * 60 - 2
121        } else {
122            base + self.fps * 60
123        }
124    }
125
126    /// Look up the offset for a given minute index directly.
127    pub fn minute_offset(&self, minute: usize) -> Option<u64> {
128        self.minute_offsets.get(minute).copied()
129    }
130
131    /// Number of entries in the table.
132    pub fn len(&self) -> usize {
133        self.minute_offsets.len()
134    }
135
136    /// Returns `true` when the table is empty (should never happen after build).
137    pub fn is_empty(&self) -> bool {
138        self.minute_offsets.is_empty()
139    }
140}
141
142/// Compute the frame-accurate distance between two timecodes using the table.
143///
144/// The result is signed: positive when `b` is after `a`, negative otherwise.
145pub fn signed_frame_distance(
146    table: &OffsetTable,
147    a: &Timecode,
148    b: &Timecode,
149) -> Result<i64, TimecodeError> {
150    let fa = table.timecode_to_frame(a)?;
151    let fb = table.timecode_to_frame(b)?;
152    Ok(fb as i64 - fa as i64)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_build_25fps() {
161        let table = OffsetTable::build(FrameRate::Fps25);
162        assert_eq!(table.len(), MAX_MINUTES);
163        assert_eq!(table.minute_offset(0), Some(0));
164        // At minute 1: 25*60 = 1500
165        assert_eq!(table.minute_offset(1), Some(1500));
166    }
167
168    #[test]
169    fn test_roundtrip_ndf() {
170        let table = OffsetTable::build(FrameRate::Fps25);
171        let tc = Timecode::new(1, 30, 15, 12, FrameRate::Fps25).expect("valid timecode");
172        let frame = table.timecode_to_frame(&tc).expect("timecode should exist");
173        let tc2 = table
174            .frame_to_timecode(frame)
175            .expect("frame to timecode should succeed");
176        assert_eq!(tc.hours, tc2.hours);
177        assert_eq!(tc.minutes, tc2.minutes);
178        assert_eq!(tc.seconds, tc2.seconds);
179        assert_eq!(tc.frames, tc2.frames);
180    }
181
182    #[test]
183    fn test_roundtrip_30fps() {
184        let table = OffsetTable::build(FrameRate::Fps30);
185        let tc = Timecode::new(10, 45, 22, 18, FrameRate::Fps30).expect("valid timecode");
186        let frame = table.timecode_to_frame(&tc).expect("timecode should exist");
187        let tc2 = table
188            .frame_to_timecode(frame)
189            .expect("frame to timecode should succeed");
190        assert_eq!(tc.hours, tc2.hours);
191        assert_eq!(tc.minutes, tc2.minutes);
192        assert_eq!(tc.seconds, tc2.seconds);
193        assert_eq!(tc.frames, tc2.frames);
194    }
195
196    #[test]
197    fn test_zero_timecode() {
198        let table = OffsetTable::build(FrameRate::Fps25);
199        let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
200        assert_eq!(
201            table.timecode_to_frame(&tc).expect("timecode should exist"),
202            0
203        );
204    }
205
206    #[test]
207    fn test_total_day_frames_25() {
208        let table = OffsetTable::build(FrameRate::Fps25);
209        // 24h * 3600s * 25fps = 2_160_000
210        assert_eq!(table.total_day_frames(), 2_160_000);
211    }
212
213    #[test]
214    fn test_total_day_frames_30() {
215        let table = OffsetTable::build(FrameRate::Fps30);
216        // 24h * 3600s * 30fps = 2_592_000
217        assert_eq!(table.total_day_frames(), 2_592_000);
218    }
219
220    #[test]
221    fn test_signed_distance_positive() {
222        let table = OffsetTable::build(FrameRate::Fps25);
223        let a = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
224        let b = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
225        let dist = signed_frame_distance(&table, &a, &b).expect("signed distance should succeed");
226        assert_eq!(dist, 25);
227    }
228
229    #[test]
230    fn test_signed_distance_negative() {
231        let table = OffsetTable::build(FrameRate::Fps25);
232        let a = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
233        let b = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
234        let dist = signed_frame_distance(&table, &a, &b).expect("signed distance should succeed");
235        assert_eq!(dist, -25);
236    }
237
238    #[test]
239    fn test_table_is_not_empty() {
240        let table = OffsetTable::build(FrameRate::Fps24);
241        assert!(!table.is_empty());
242    }
243
244    #[test]
245    fn test_minute_offset_out_of_range() {
246        let table = OffsetTable::build(FrameRate::Fps25);
247        assert!(table.minute_offset(MAX_MINUTES).is_none());
248    }
249
250    #[test]
251    fn test_frame_to_timecode_minute_boundary() {
252        let table = OffsetTable::build(FrameRate::Fps25);
253        // Frame 1500 = minute 1 = 00:01:00:00
254        let tc = table
255            .frame_to_timecode(1500)
256            .expect("frame to timecode should succeed");
257        assert_eq!(tc.hours, 0);
258        assert_eq!(tc.minutes, 1);
259        assert_eq!(tc.seconds, 0);
260        assert_eq!(tc.frames, 0);
261    }
262
263    #[test]
264    fn test_rate_accessor() {
265        let table = OffsetTable::build(FrameRate::Fps50);
266        assert_eq!(table.rate(), FrameRate::Fps50);
267    }
268}