Skip to main content

oximedia_timecode/
tc_sequence.rs

1#![allow(dead_code)]
2//! Timecode sequence management for contiguous and non-contiguous frame runs.
3//!
4//! Provides utilities for building, iterating, and validating ordered sequences
5//! of timecodes that represent edit lists, playlists, or continuous recordings.
6
7use crate::{FrameRate, Timecode, TimecodeError};
8
9/// A contiguous run of timecodes sharing the same frame rate.
10#[derive(Debug, Clone, PartialEq)]
11pub struct TimecodeRun {
12    /// Starting timecode of the run.
13    pub start: Timecode,
14    /// Number of frames in this run (inclusive of the start frame).
15    pub frame_count: u64,
16}
17
18impl TimecodeRun {
19    /// Create a new timecode run beginning at `start` spanning `frame_count` frames.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`TimecodeError::InvalidConfiguration`] when `frame_count` is zero.
24    pub fn new(start: Timecode, frame_count: u64) -> Result<Self, TimecodeError> {
25        if frame_count == 0 {
26            return Err(TimecodeError::InvalidConfiguration);
27        }
28        Ok(Self { start, frame_count })
29    }
30
31    /// Duration of the run in seconds (approximate for non-integer frame rates).
32    #[allow(clippy::cast_precision_loss)]
33    pub fn duration_secs(&self) -> f64 {
34        let fps = self.start.frame_rate.fps as f64;
35        self.frame_count as f64 / fps
36    }
37
38    /// Returns `true` if `tc` falls within this run.
39    pub fn contains(&self, tc: &Timecode) -> bool {
40        if tc.frame_rate != self.start.frame_rate {
41            return false;
42        }
43        let start_f = self.start.to_frames();
44        let tc_f = tc.to_frames();
45        tc_f >= start_f && tc_f < start_f + self.frame_count
46    }
47
48    /// Compute the end timecode (last frame in the run).
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the resulting timecode exceeds 24-hour bounds.
53    pub fn end_timecode(&self, rate: FrameRate) -> Result<Timecode, TimecodeError> {
54        let end_frame = self.start.to_frames() + self.frame_count - 1;
55        Timecode::from_frames(end_frame, rate)
56    }
57}
58
59/// An ordered sequence of [`TimecodeRun`] entries representing a playlist or
60/// edit decision list.
61#[derive(Debug, Clone)]
62pub struct TimecodeSequence {
63    /// Runs in this sequence.
64    runs: Vec<TimecodeRun>,
65    /// Cached total frame count.
66    total_frames: u64,
67}
68
69impl TimecodeSequence {
70    /// Create an empty sequence.
71    pub fn new() -> Self {
72        Self {
73            runs: Vec::new(),
74            total_frames: 0,
75        }
76    }
77
78    /// Append a run to the sequence.
79    pub fn push(&mut self, run: TimecodeRun) {
80        self.total_frames += run.frame_count;
81        self.runs.push(run);
82    }
83
84    /// Total number of frames across all runs.
85    pub fn total_frames(&self) -> u64 {
86        self.total_frames
87    }
88
89    /// Number of runs in the sequence.
90    pub fn run_count(&self) -> usize {
91        self.runs.len()
92    }
93
94    /// Get a run by index.
95    pub fn get(&self, index: usize) -> Option<&TimecodeRun> {
96        self.runs.get(index)
97    }
98
99    /// Check whether two adjacent runs are contiguous (end of run N + 1 ==
100    /// start of run N+1).
101    pub fn is_contiguous(&self, a_idx: usize, b_idx: usize) -> bool {
102        let (Some(a), Some(b)) = (self.runs.get(a_idx), self.runs.get(b_idx)) else {
103            return false;
104        };
105        if a.start.frame_rate != b.start.frame_rate {
106            return false;
107        }
108        let a_end = a.start.to_frames() + a.frame_count;
109        let b_start = b.start.to_frames();
110        a_end == b_start
111    }
112
113    /// Return the total duration (seconds) of the whole sequence.
114    #[allow(clippy::cast_precision_loss)]
115    pub fn total_duration_secs(&self) -> f64 {
116        self.runs.iter().map(TimecodeRun::duration_secs).sum()
117    }
118
119    /// Find the run containing a given absolute frame offset into the sequence.
120    /// Returns `(run_index, offset_within_run)`.
121    pub fn find_run_at_offset(&self, offset: u64) -> Option<(usize, u64)> {
122        let mut cumulative = 0u64;
123        for (i, run) in self.runs.iter().enumerate() {
124            if offset < cumulative + run.frame_count {
125                return Some((i, offset - cumulative));
126            }
127            cumulative += run.frame_count;
128        }
129        None
130    }
131
132    /// Merge adjacent contiguous runs into a single run where possible.
133    pub fn compact(&mut self) {
134        if self.runs.len() < 2 {
135            return;
136        }
137        let mut merged: Vec<TimecodeRun> = Vec::with_capacity(self.runs.len());
138        merged.push(self.runs[0].clone());
139        for run in self.runs.iter().skip(1) {
140            let last = merged
141                .last_mut()
142                .expect("merged is non-empty: initial element was pushed before this loop");
143            if last.start.frame_rate == run.start.frame_rate {
144                let last_end = last.start.to_frames() + last.frame_count;
145                if last_end == run.start.to_frames() {
146                    last.frame_count += run.frame_count;
147                    continue;
148                }
149            }
150            merged.push(run.clone());
151        }
152        self.runs = merged;
153    }
154
155    /// Return an iterator over all runs.
156    pub fn iter(&self) -> std::slice::Iter<'_, TimecodeRun> {
157        self.runs.iter()
158    }
159
160    /// Detect gaps between adjacent runs and return the gap durations in frames.
161    pub fn detect_gaps(&self) -> Vec<(usize, i64)> {
162        let mut gaps = Vec::new();
163        for i in 0..self.runs.len().saturating_sub(1) {
164            let a = &self.runs[i];
165            let b = &self.runs[i + 1];
166            if a.start.frame_rate == b.start.frame_rate {
167                let a_end = a.start.to_frames() + a.frame_count;
168                let b_start = b.start.to_frames();
169                let gap = b_start as i64 - a_end as i64;
170                if gap != 0 {
171                    gaps.push((i, gap));
172                }
173            }
174        }
175        gaps
176    }
177}
178
179impl Default for TimecodeSequence {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Build a [`TimecodeSequence`] from an iterator of `(start_tc, frame_count)` pairs.
186pub fn build_sequence(items: &[(Timecode, u64)]) -> Result<TimecodeSequence, TimecodeError> {
187    let mut seq = TimecodeSequence::new();
188    for (tc, count) in items {
189        let run = TimecodeRun::new(*tc, *count)?;
190        seq.push(run);
191    }
192    Ok(seq)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn tc(h: u8, m: u8, s: u8, f: u8) -> Timecode {
200        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid timecode")
201    }
202
203    #[test]
204    fn test_run_creation() {
205        let run = TimecodeRun::new(tc(1, 0, 0, 0), 100).expect("valid timecode run");
206        assert_eq!(run.frame_count, 100);
207    }
208
209    #[test]
210    fn test_run_zero_frames_error() {
211        let result = TimecodeRun::new(tc(0, 0, 0, 0), 0);
212        assert!(result.is_err());
213    }
214
215    #[test]
216    fn test_run_duration_secs() {
217        let run = TimecodeRun::new(tc(0, 0, 0, 0), 50).expect("valid timecode run");
218        let dur = run.duration_secs();
219        assert!((dur - 2.0).abs() < 1e-9);
220    }
221
222    #[test]
223    fn test_run_contains() {
224        let run = TimecodeRun::new(tc(0, 0, 0, 0), 25).expect("valid timecode run");
225        assert!(run.contains(&tc(0, 0, 0, 10)));
226        assert!(run.contains(&tc(0, 0, 0, 24)));
227        assert!(!run.contains(&tc(0, 0, 1, 0)));
228    }
229
230    #[test]
231    fn test_sequence_push_and_total() {
232        let mut seq = TimecodeSequence::new();
233        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 25).expect("valid timecode run"));
234        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 25).expect("valid timecode run"));
235        assert_eq!(seq.total_frames(), 50);
236        assert_eq!(seq.run_count(), 2);
237    }
238
239    #[test]
240    fn test_sequence_is_contiguous() {
241        let mut seq = TimecodeSequence::new();
242        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 25).expect("valid timecode run"));
243        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 25).expect("valid timecode run"));
244        assert!(seq.is_contiguous(0, 1));
245    }
246
247    #[test]
248    fn test_sequence_not_contiguous() {
249        let mut seq = TimecodeSequence::new();
250        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 10).expect("valid timecode run"));
251        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 10).expect("valid timecode run"));
252        assert!(!seq.is_contiguous(0, 1));
253    }
254
255    #[test]
256    fn test_find_run_at_offset() {
257        let mut seq = TimecodeSequence::new();
258        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 25).expect("valid timecode run"));
259        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 25).expect("valid timecode run"));
260        assert_eq!(seq.find_run_at_offset(0), Some((0, 0)));
261        assert_eq!(seq.find_run_at_offset(24), Some((0, 24)));
262        assert_eq!(seq.find_run_at_offset(25), Some((1, 0)));
263        assert_eq!(seq.find_run_at_offset(50), None);
264    }
265
266    #[test]
267    fn test_compact_merges_contiguous() {
268        let mut seq = TimecodeSequence::new();
269        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 25).expect("valid timecode run"));
270        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 25).expect("valid timecode run"));
271        assert_eq!(seq.run_count(), 2);
272        seq.compact();
273        assert_eq!(seq.run_count(), 1);
274        assert_eq!(seq.total_frames(), 50);
275    }
276
277    #[test]
278    fn test_compact_preserves_gaps() {
279        let mut seq = TimecodeSequence::new();
280        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 10).expect("valid timecode run"));
281        seq.push(TimecodeRun::new(tc(0, 0, 2, 0), 10).expect("valid timecode run"));
282        seq.compact();
283        assert_eq!(seq.run_count(), 2);
284    }
285
286    #[test]
287    fn test_detect_gaps() {
288        let mut seq = TimecodeSequence::new();
289        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 10).expect("valid timecode run"));
290        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 10).expect("valid timecode run"));
291        let gaps = seq.detect_gaps();
292        assert_eq!(gaps.len(), 1);
293        assert_eq!(gaps[0].0, 0);
294        assert_eq!(gaps[0].1, 15); // gap of 15 frames
295    }
296
297    #[test]
298    fn test_build_sequence() {
299        let items = vec![(tc(0, 0, 0, 0), 25u64), (tc(0, 0, 1, 0), 50)];
300        let seq = build_sequence(&items).expect("build sequence should succeed");
301        assert_eq!(seq.run_count(), 2);
302        assert_eq!(seq.total_frames(), 75);
303    }
304
305    #[test]
306    fn test_total_duration_secs() {
307        let mut seq = TimecodeSequence::new();
308        seq.push(TimecodeRun::new(tc(0, 0, 0, 0), 25).expect("valid timecode run"));
309        seq.push(TimecodeRun::new(tc(0, 0, 1, 0), 50).expect("valid timecode run"));
310        let dur = seq.total_duration_secs();
311        assert!((dur - 3.0).abs() < 1e-9);
312    }
313
314    #[test]
315    fn test_end_timecode() {
316        let run = TimecodeRun::new(tc(0, 0, 0, 0), 26).expect("valid timecode run");
317        let end = run
318            .end_timecode(FrameRate::Fps25)
319            .expect("end timecode should succeed");
320        assert_eq!(end.hours, 0);
321        assert_eq!(end.minutes, 0);
322        assert_eq!(end.seconds, 1);
323        assert_eq!(end.frames, 0);
324    }
325}