Skip to main content

oximedia_timecode/
sync_map.rs

1//! Timecode sync/offset mapping
2//!
3//! Provides `TcOffset`, `TcSyncPoint`, and `SyncMap` for translating frame
4//! positions between two timecode domains using linear interpolation.
5
6#[allow(dead_code)]
7/// A fixed frame offset with an optional description
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct TcOffset {
10    /// Signed offset in frames
11    pub offset_frames: i64,
12    /// Human-readable description of this offset
13    pub description: String,
14}
15
16impl TcOffset {
17    /// Create a new `TcOffset`
18    #[must_use]
19    pub fn new(offset_frames: i64, description: String) -> Self {
20        Self {
21            offset_frames,
22            description,
23        }
24    }
25
26    /// Returns `true` when the offset is exactly zero
27    #[must_use]
28    pub fn is_zero(&self) -> bool {
29        self.offset_frames == 0
30    }
31
32    /// Return a new `TcOffset` with the sign negated
33    #[must_use]
34    pub fn negate(&self) -> Self {
35        Self {
36            offset_frames: -self.offset_frames,
37            description: self.description.clone(),
38        }
39    }
40}
41
42#[allow(dead_code)]
43/// A correspondence between a source frame and a target frame
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct TcSyncPoint {
46    /// Frame number in the source domain
47    pub source_frame: i64,
48    /// Corresponding frame number in the target domain
49    pub target_frame: i64,
50}
51
52impl TcSyncPoint {
53    /// Create a new sync point
54    #[must_use]
55    pub fn new(source_frame: i64, target_frame: i64) -> Self {
56        Self {
57            source_frame,
58            target_frame,
59        }
60    }
61
62    /// Returns `target_frame - source_frame`
63    #[must_use]
64    pub fn offset(&self) -> i64 {
65        self.target_frame - self.source_frame
66    }
67}
68
69#[allow(dead_code)]
70/// A collection of sync points that defines a piecewise-linear mapping between
71/// source and target frame domains.
72#[derive(Debug, Clone, Default)]
73pub struct SyncMap {
74    /// Ordered list of sync points (sorted by source_frame)
75    pub sync_points: Vec<TcSyncPoint>,
76}
77
78impl SyncMap {
79    /// Create an empty `SyncMap`
80    #[must_use]
81    pub fn new() -> Self {
82        Self {
83            sync_points: Vec::new(),
84        }
85    }
86
87    /// Add a sync point. The internal list is kept sorted by `source_frame`.
88    pub fn add_sync_point(&mut self, point: TcSyncPoint) {
89        self.sync_points.push(point);
90        self.sync_points.sort_by_key(|p| p.source_frame);
91    }
92
93    /// Return the number of sync points
94    #[must_use]
95    pub fn point_count(&self) -> usize {
96        self.sync_points.len()
97    }
98
99    /// Convert a source frame to a target frame using linear interpolation.
100    ///
101    /// - If there are no sync points, returns `source_frame` unchanged.
102    /// - If `source_frame` is before the first sync point or after the last,
103    ///   the nearest endpoint's offset is extrapolated.
104    /// - Otherwise, interpolates linearly between the bounding sync points.
105    #[allow(clippy::cast_precision_loss)]
106    #[must_use]
107    pub fn source_to_target(&self, frame: i64) -> i64 {
108        if self.sync_points.is_empty() {
109            return frame;
110        }
111        if self.sync_points.len() == 1 {
112            return frame + self.sync_points[0].offset();
113        }
114
115        let first = &self.sync_points[0];
116        let last = &self.sync_points[self.sync_points.len() - 1];
117
118        if frame <= first.source_frame {
119            return frame + first.offset();
120        }
121        if frame >= last.source_frame {
122            return frame + last.offset();
123        }
124
125        // Binary search for the segment
126        let idx = self
127            .sync_points
128            .partition_point(|p| p.source_frame <= frame);
129        let lo = &self.sync_points[idx - 1];
130        let hi = &self.sync_points[idx];
131
132        let span = (hi.source_frame - lo.source_frame) as f64;
133        let t = (frame - lo.source_frame) as f64 / span;
134        let interp_target = lo.target_frame as f64 + t * (hi.target_frame - lo.target_frame) as f64;
135        interp_target.round() as i64
136    }
137
138    /// Convert a target frame to a source frame using linear interpolation.
139    ///
140    /// Uses the same piecewise-linear logic but operates on the target axis.
141    #[allow(clippy::cast_precision_loss)]
142    #[must_use]
143    pub fn target_to_source(&self, frame: i64) -> i64 {
144        if self.sync_points.is_empty() {
145            return frame;
146        }
147        if self.sync_points.len() == 1 {
148            return frame - self.sync_points[0].offset();
149        }
150
151        // Sort by target_frame for this lookup
152        let mut by_target = self.sync_points.clone();
153        by_target.sort_by_key(|p| p.target_frame);
154
155        let first = &by_target[0];
156        let last = &by_target[by_target.len() - 1];
157
158        if frame <= first.target_frame {
159            return frame - first.offset();
160        }
161        if frame >= last.target_frame {
162            return frame - last.offset();
163        }
164
165        let idx = by_target.partition_point(|p| p.target_frame <= frame);
166        let lo = &by_target[idx - 1];
167        let hi = &by_target[idx];
168
169        let span = (hi.target_frame - lo.target_frame) as f64;
170        let t = (frame - lo.target_frame) as f64 / span;
171        let interp_source = lo.source_frame as f64 + t * (hi.source_frame - lo.source_frame) as f64;
172        interp_source.round() as i64
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_tc_offset_is_zero_true() {
182        let o = TcOffset::new(0, "zero".into());
183        assert!(o.is_zero());
184    }
185
186    #[test]
187    fn test_tc_offset_is_zero_false() {
188        let o = TcOffset::new(10, "shift".into());
189        assert!(!o.is_zero());
190    }
191
192    #[test]
193    fn test_tc_offset_negate() {
194        let o = TcOffset::new(25, "shift".into());
195        let neg = o.negate();
196        assert_eq!(neg.offset_frames, -25);
197    }
198
199    #[test]
200    fn test_tc_offset_negate_zero() {
201        let o = TcOffset::new(0, "zero".into());
202        assert_eq!(o.negate().offset_frames, 0);
203    }
204
205    #[test]
206    fn test_tc_sync_point_offset() {
207        let p = TcSyncPoint::new(100, 150);
208        assert_eq!(p.offset(), 50);
209    }
210
211    #[test]
212    fn test_tc_sync_point_negative_offset() {
213        let p = TcSyncPoint::new(200, 100);
214        assert_eq!(p.offset(), -100);
215    }
216
217    #[test]
218    fn test_sync_map_empty_passthrough() {
219        let map = SyncMap::new();
220        assert_eq!(map.source_to_target(42), 42);
221        assert_eq!(map.target_to_source(42), 42);
222    }
223
224    #[test]
225    fn test_sync_map_single_point() {
226        let mut map = SyncMap::new();
227        map.add_sync_point(TcSyncPoint::new(0, 100));
228        // source frame 0 maps to target 100; frame 50 maps to 150
229        assert_eq!(map.source_to_target(0), 100);
230        assert_eq!(map.source_to_target(50), 150);
231    }
232
233    #[test]
234    fn test_sync_map_two_points_interpolation() {
235        let mut map = SyncMap::new();
236        map.add_sync_point(TcSyncPoint::new(0, 0));
237        map.add_sync_point(TcSyncPoint::new(100, 200));
238        // At midpoint source=50, target should be 100
239        assert_eq!(map.source_to_target(50), 100);
240    }
241
242    #[test]
243    fn test_sync_map_extrapolate_before_first() {
244        let mut map = SyncMap::new();
245        map.add_sync_point(TcSyncPoint::new(100, 110));
246        map.add_sync_point(TcSyncPoint::new(200, 220));
247        // Before first point: uses first offset (+10)
248        assert_eq!(map.source_to_target(50), 60);
249    }
250
251    #[test]
252    fn test_sync_map_extrapolate_after_last() {
253        let mut map = SyncMap::new();
254        map.add_sync_point(TcSyncPoint::new(0, 0));
255        map.add_sync_point(TcSyncPoint::new(100, 200));
256        // After last point: uses last offset (+100)
257        assert_eq!(map.source_to_target(150), 250);
258    }
259
260    #[test]
261    fn test_sync_map_target_to_source_single_point() {
262        let mut map = SyncMap::new();
263        map.add_sync_point(TcSyncPoint::new(0, 100));
264        assert_eq!(map.target_to_source(100), 0);
265        assert_eq!(map.target_to_source(150), 50);
266    }
267
268    #[test]
269    fn test_sync_map_point_count() {
270        let mut map = SyncMap::new();
271        assert_eq!(map.point_count(), 0);
272        map.add_sync_point(TcSyncPoint::new(0, 0));
273        assert_eq!(map.point_count(), 1);
274        map.add_sync_point(TcSyncPoint::new(100, 100));
275        assert_eq!(map.point_count(), 2);
276    }
277
278    #[test]
279    fn test_sync_map_sorted_after_add() {
280        let mut map = SyncMap::new();
281        map.add_sync_point(TcSyncPoint::new(200, 200));
282        map.add_sync_point(TcSyncPoint::new(0, 0));
283        map.add_sync_point(TcSyncPoint::new(100, 100));
284        assert_eq!(map.sync_points[0].source_frame, 0);
285        assert_eq!(map.sync_points[1].source_frame, 100);
286        assert_eq!(map.sync_points[2].source_frame, 200);
287    }
288}