Skip to main content

oximedia_timecode/
frame_offset.rs

1//! Frame offset calculation and conversion utilities.
2//!
3//! Handles absolute frame offsets, cross-rate conversions, and timestamp arithmetic.
4
5#![allow(dead_code)]
6#![allow(clippy::cast_precision_loss)]
7
8use crate::{FrameRate, Timecode, TimecodeError};
9
10/// Absolute frame offset from the epoch (midnight).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub struct FrameOffset {
13    frames: u64,
14}
15
16impl FrameOffset {
17    /// Create a new frame offset.
18    pub fn new(frames: u64) -> Self {
19        Self { frames }
20    }
21
22    /// Get the raw frame count.
23    pub fn as_frames(&self) -> u64 {
24        self.frames
25    }
26
27    /// Add a number of frames.
28    pub fn add_frames(self, n: u64) -> Self {
29        Self {
30            frames: self.frames + n,
31        }
32    }
33
34    /// Subtract frames, saturating at zero.
35    pub fn sub_frames(self, n: u64) -> Self {
36        Self {
37            frames: self.frames.saturating_sub(n),
38        }
39    }
40
41    /// Compute the difference in frames.
42    pub fn diff(self, other: FrameOffset) -> i64 {
43        self.frames as i64 - other.frames as i64
44    }
45
46    /// Convert to a timecode at the given frame rate.
47    pub fn to_timecode(self, frame_rate: FrameRate) -> Result<Timecode, TimecodeError> {
48        Timecode::from_frames(self.frames, frame_rate)
49    }
50
51    /// Convert to wall-clock time in seconds.
52    pub fn to_seconds(self, frame_rate: FrameRate) -> f64 {
53        let (num, den) = frame_rate.as_rational();
54        self.frames as f64 * den as f64 / num as f64
55    }
56
57    /// Create from wall-clock time in seconds.
58    pub fn from_seconds(seconds: f64, frame_rate: FrameRate) -> Self {
59        let (num, den) = frame_rate.as_rational();
60        let frames = (seconds * num as f64 / den as f64).round() as u64;
61        Self { frames }
62    }
63
64    /// Create from a timecode.
65    pub fn from_timecode(tc: &Timecode) -> Self {
66        Self {
67            frames: tc.to_frames(),
68        }
69    }
70}
71
72impl From<u64> for FrameOffset {
73    fn from(n: u64) -> Self {
74        Self::new(n)
75    }
76}
77
78/// Cross-rate frame conversion.
79///
80/// Converts frame offsets between different frame rates.
81#[derive(Debug, Clone)]
82pub struct CrossRateConverter {
83    src_rate: FrameRate,
84    dst_rate: FrameRate,
85}
86
87impl CrossRateConverter {
88    /// Create a new converter.
89    pub fn new(src_rate: FrameRate, dst_rate: FrameRate) -> Self {
90        Self { src_rate, dst_rate }
91    }
92
93    /// Convert a frame offset from source rate to destination rate.
94    pub fn convert(&self, offset: FrameOffset) -> FrameOffset {
95        // Use rational arithmetic to avoid floating-point drift
96        let (src_num, src_den) = self.src_rate.as_rational();
97        let (dst_num, dst_den) = self.dst_rate.as_rational();
98        // dst_frames = src_frames * (src_den/src_num) * (dst_num/dst_den)
99        //            = src_frames * src_den * dst_num / (src_num * dst_den)
100        let numerator = offset.frames as u128 * src_den as u128 * dst_num as u128;
101        let denominator = src_num as u128 * dst_den as u128;
102        let dst_frames = (numerator + denominator / 2) / denominator;
103        FrameOffset::new(dst_frames as u64)
104    }
105
106    /// Convert seconds to frame offset at destination rate.
107    pub fn seconds_to_offset(&self, seconds: f64) -> FrameOffset {
108        FrameOffset::from_seconds(seconds, self.dst_rate)
109    }
110}
111
112/// Timecode offset table for fast lookup.
113#[derive(Debug, Clone)]
114pub struct OffsetTable {
115    frame_rate: FrameRate,
116    entries: Vec<OffsetEntry>,
117}
118
119/// A single entry in an offset table.
120#[derive(Debug, Clone, Copy)]
121pub struct OffsetEntry {
122    /// Source timecode frame position.
123    pub src_frame: u64,
124    /// Destination/record timecode frame position.
125    pub dst_frame: u64,
126    /// Edit type for this region.
127    pub edit_type: EditType,
128}
129
130/// Type of edit at a timecode offset entry.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum EditType {
133    /// Normal continuous recording.
134    Continuous,
135    /// Cut edit.
136    Cut,
137    /// Dissolve transition.
138    Dissolve,
139    /// Discontinuity in source.
140    Discontinuity,
141}
142
143impl OffsetTable {
144    /// Create a new empty offset table.
145    pub fn new(frame_rate: FrameRate) -> Self {
146        Self {
147            frame_rate,
148            entries: Vec::new(),
149        }
150    }
151
152    /// Add an offset entry.
153    pub fn add_entry(&mut self, src_frame: u64, dst_frame: u64, edit_type: EditType) {
154        self.entries.push(OffsetEntry {
155            src_frame,
156            dst_frame,
157            edit_type,
158        });
159        self.entries.sort_by_key(|e| e.src_frame);
160    }
161
162    /// Look up the destination frame for a source frame (nearest-lower match).
163    pub fn lookup(&self, src_frame: u64) -> Option<&OffsetEntry> {
164        // Binary search for the last entry where src_frame <= query
165        let pos = self.entries.partition_point(|e| e.src_frame <= src_frame);
166        if pos == 0 {
167            None
168        } else {
169            Some(&self.entries[pos - 1])
170        }
171    }
172
173    /// Get the number of entries.
174    pub fn len(&self) -> usize {
175        self.entries.len()
176    }
177
178    /// Check if the table is empty.
179    pub fn is_empty(&self) -> bool {
180        self.entries.is_empty()
181    }
182
183    /// Get the frame rate.
184    pub fn frame_rate(&self) -> FrameRate {
185        self.frame_rate
186    }
187
188    /// Compute the offset (dst - src) at a given source frame.
189    pub fn offset_at(&self, src_frame: u64) -> Option<i64> {
190        let entry = self.lookup(src_frame)?;
191        Some(entry.dst_frame as i64 - entry.src_frame as i64)
192    }
193}
194
195/// Duration arithmetic between two frame offsets.
196pub fn frame_duration(start: FrameOffset, end: FrameOffset, frame_rate: FrameRate) -> f64 {
197    let frames = end.as_frames().saturating_sub(start.as_frames());
198    let (num, den) = frame_rate.as_rational();
199    frames as f64 * den as f64 / num as f64
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_frame_offset_arithmetic() {
208        let a = FrameOffset::new(100);
209        let b = a.add_frames(50);
210        assert_eq!(b.as_frames(), 150);
211        let c = b.sub_frames(30);
212        assert_eq!(c.as_frames(), 120);
213    }
214
215    #[test]
216    fn test_frame_offset_diff() {
217        let a = FrameOffset::new(200);
218        let b = FrameOffset::new(150);
219        assert_eq!(a.diff(b), 50);
220        assert_eq!(b.diff(a), -50);
221    }
222
223    #[test]
224    fn test_frame_offset_to_seconds_25fps() {
225        let offset = FrameOffset::new(25);
226        let secs = offset.to_seconds(FrameRate::Fps25);
227        assert!((secs - 1.0).abs() < 1e-9);
228    }
229
230    #[test]
231    fn test_frame_offset_from_seconds_25fps() {
232        let offset = FrameOffset::from_seconds(2.0, FrameRate::Fps25);
233        assert_eq!(offset.as_frames(), 50);
234    }
235
236    #[test]
237    fn test_frame_offset_from_timecode() {
238        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
239        let offset = FrameOffset::from_timecode(&tc);
240        assert_eq!(offset.as_frames(), 25);
241    }
242
243    #[test]
244    fn test_frame_offset_to_timecode() {
245        let offset = FrameOffset::new(25);
246        let tc = offset
247            .to_timecode(FrameRate::Fps25)
248            .expect("to_timecode should succeed");
249        assert_eq!(tc.seconds, 1);
250        assert_eq!(tc.frames, 0);
251    }
252
253    #[test]
254    fn test_cross_rate_same_rate() {
255        let conv = CrossRateConverter::new(FrameRate::Fps25, FrameRate::Fps25);
256        let offset = FrameOffset::new(100);
257        let converted = conv.convert(offset);
258        assert_eq!(converted.as_frames(), 100);
259    }
260
261    #[test]
262    fn test_cross_rate_25_to_50() {
263        let conv = CrossRateConverter::new(FrameRate::Fps25, FrameRate::Fps50);
264        let offset = FrameOffset::new(25);
265        let converted = conv.convert(offset);
266        assert_eq!(converted.as_frames(), 50);
267    }
268
269    #[test]
270    fn test_cross_rate_50_to_25() {
271        let conv = CrossRateConverter::new(FrameRate::Fps50, FrameRate::Fps25);
272        let offset = FrameOffset::new(50);
273        let converted = conv.convert(offset);
274        assert_eq!(converted.as_frames(), 25);
275    }
276
277    #[test]
278    fn test_offset_table_lookup() {
279        let mut table = OffsetTable::new(FrameRate::Fps25);
280        table.add_entry(0, 0, EditType::Continuous);
281        table.add_entry(100, 200, EditType::Cut);
282        table.add_entry(300, 400, EditType::Dissolve);
283
284        assert!(table.lookup(0).is_some());
285        let entry = table.lookup(150).expect("lookup should succeed");
286        assert_eq!(entry.src_frame, 100);
287        assert_eq!(entry.dst_frame, 200);
288    }
289
290    #[test]
291    fn test_offset_table_offset_at() {
292        let mut table = OffsetTable::new(FrameRate::Fps25);
293        table.add_entry(0, 10, EditType::Continuous);
294        assert_eq!(table.offset_at(0), Some(10));
295        assert_eq!(table.offset_at(50), Some(10));
296    }
297
298    #[test]
299    fn test_offset_table_empty_lookup() {
300        let table = OffsetTable::new(FrameRate::Fps25);
301        assert!(table.lookup(0).is_none());
302        assert!(table.is_empty());
303    }
304
305    #[test]
306    fn test_frame_duration() {
307        let start = FrameOffset::new(0);
308        let end = FrameOffset::new(25);
309        let dur = frame_duration(start, end, FrameRate::Fps25);
310        assert!((dur - 1.0).abs() < 1e-9);
311    }
312
313    #[test]
314    fn test_frame_offset_sub_saturate() {
315        let a = FrameOffset::new(5);
316        let b = a.sub_frames(100);
317        assert_eq!(b.as_frames(), 0);
318    }
319}