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).unwrap();
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.to_timecode(FrameRate::Fps25).unwrap();
247        assert_eq!(tc.seconds, 1);
248        assert_eq!(tc.frames, 0);
249    }
250
251    #[test]
252    fn test_cross_rate_same_rate() {
253        let conv = CrossRateConverter::new(FrameRate::Fps25, FrameRate::Fps25);
254        let offset = FrameOffset::new(100);
255        let converted = conv.convert(offset);
256        assert_eq!(converted.as_frames(), 100);
257    }
258
259    #[test]
260    fn test_cross_rate_25_to_50() {
261        let conv = CrossRateConverter::new(FrameRate::Fps25, FrameRate::Fps50);
262        let offset = FrameOffset::new(25);
263        let converted = conv.convert(offset);
264        assert_eq!(converted.as_frames(), 50);
265    }
266
267    #[test]
268    fn test_cross_rate_50_to_25() {
269        let conv = CrossRateConverter::new(FrameRate::Fps50, FrameRate::Fps25);
270        let offset = FrameOffset::new(50);
271        let converted = conv.convert(offset);
272        assert_eq!(converted.as_frames(), 25);
273    }
274
275    #[test]
276    fn test_offset_table_lookup() {
277        let mut table = OffsetTable::new(FrameRate::Fps25);
278        table.add_entry(0, 0, EditType::Continuous);
279        table.add_entry(100, 200, EditType::Cut);
280        table.add_entry(300, 400, EditType::Dissolve);
281
282        assert!(table.lookup(0).is_some());
283        let entry = table.lookup(150).unwrap();
284        assert_eq!(entry.src_frame, 100);
285        assert_eq!(entry.dst_frame, 200);
286    }
287
288    #[test]
289    fn test_offset_table_offset_at() {
290        let mut table = OffsetTable::new(FrameRate::Fps25);
291        table.add_entry(0, 10, EditType::Continuous);
292        assert_eq!(table.offset_at(0), Some(10));
293        assert_eq!(table.offset_at(50), Some(10));
294    }
295
296    #[test]
297    fn test_offset_table_empty_lookup() {
298        let table = OffsetTable::new(FrameRate::Fps25);
299        assert!(table.lookup(0).is_none());
300        assert!(table.is_empty());
301    }
302
303    #[test]
304    fn test_frame_duration() {
305        let start = FrameOffset::new(0);
306        let end = FrameOffset::new(25);
307        let dur = frame_duration(start, end, FrameRate::Fps25);
308        assert!((dur - 1.0).abs() < 1e-9);
309    }
310
311    #[test]
312    fn test_frame_offset_sub_saturate() {
313        let a = FrameOffset::new(5);
314        let b = a.sub_frames(100);
315        assert_eq!(b.as_frames(), 0);
316    }
317}