Skip to main content

oximedia_timecode/
tc_subtitle_sync.rs

1#![allow(dead_code)]
2//! Subtitle-to-timecode synchronization utilities.
3//!
4//! Provides tools for aligning subtitle cue timestamps with SMPTE timecodes,
5//! applying linear and offset corrections, and detecting drift between subtitle
6//! streams and the master timecode.
7
8use crate::{FrameRate, Timecode, TimecodeError};
9
10/// A single subtitle cue with in/out timecodes.
11#[derive(Debug, Clone, PartialEq)]
12pub struct SubtitleCue {
13    /// Cue identifier (sequential number or label).
14    pub id: u32,
15    /// Start timecode.
16    pub tc_in: Timecode,
17    /// End timecode.
18    pub tc_out: Timecode,
19    /// Text content of the cue.
20    pub text: String,
21}
22
23impl SubtitleCue {
24    /// Create a new subtitle cue.
25    pub fn new(id: u32, tc_in: Timecode, tc_out: Timecode, text: String) -> Self {
26        Self {
27            id,
28            tc_in,
29            tc_out,
30            text,
31        }
32    }
33
34    /// Duration of this cue in frames.
35    pub fn duration_frames(&self) -> u64 {
36        let f_in = self.tc_in.to_frames();
37        let f_out = self.tc_out.to_frames();
38        f_out.saturating_sub(f_in)
39    }
40
41    /// Duration of this cue in seconds (approximate for non-integer rates).
42    #[allow(clippy::cast_precision_loss)]
43    pub fn duration_secs(&self) -> f64 {
44        let fps = self.tc_in.frame_rate.fps as f64;
45        self.duration_frames() as f64 / fps
46    }
47
48    /// Check if this cue overlaps another cue.
49    pub fn overlaps(&self, other: &SubtitleCue) -> bool {
50        let a_in = self.tc_in.to_frames();
51        let a_out = self.tc_out.to_frames();
52        let b_in = other.tc_in.to_frames();
53        let b_out = other.tc_out.to_frames();
54        a_in < b_out && b_in < a_out
55    }
56}
57
58/// Offset correction: shift all cues by a fixed number of frames.
59pub fn apply_frame_offset(
60    cues: &[SubtitleCue],
61    offset: i64,
62    rate: FrameRate,
63) -> Result<Vec<SubtitleCue>, TimecodeError> {
64    let mut result = Vec::with_capacity(cues.len());
65    for cue in cues {
66        let f_in = cue.tc_in.to_frames() as i64 + offset;
67        let f_out = cue.tc_out.to_frames() as i64 + offset;
68        if f_in < 0 || f_out < 0 {
69            return Err(TimecodeError::InvalidFrames);
70        }
71        let new_in = Timecode::from_frames(f_in as u64, rate)?;
72        let new_out = Timecode::from_frames(f_out as u64, rate)?;
73        result.push(SubtitleCue::new(cue.id, new_in, new_out, cue.text.clone()));
74    }
75    Ok(result)
76}
77
78/// Linear time-stretch: scale all cue times relative to `anchor_frame` by
79/// `factor`.
80///
81/// A factor of 1.0 is identity. Factor > 1.0 stretches, < 1.0 compresses.
82///
83/// # Errors
84///
85/// Returns an error if any resulting timecode is invalid.
86#[allow(
87    clippy::cast_precision_loss,
88    clippy::cast_possible_truncation,
89    clippy::cast_sign_loss
90)]
91pub fn apply_linear_stretch(
92    cues: &[SubtitleCue],
93    anchor_frame: u64,
94    factor: f64,
95    rate: FrameRate,
96) -> Result<Vec<SubtitleCue>, TimecodeError> {
97    let mut result = Vec::with_capacity(cues.len());
98    let anchor = anchor_frame as f64;
99    for cue in cues {
100        let f_in = cue.tc_in.to_frames() as f64;
101        let f_out = cue.tc_out.to_frames() as f64;
102        let new_in = anchor + (f_in - anchor) * factor;
103        let new_out = anchor + (f_out - anchor) * factor;
104        if new_in < 0.0 || new_out < 0.0 {
105            return Err(TimecodeError::InvalidFrames);
106        }
107        let tc_in = Timecode::from_frames(new_in.round() as u64, rate)?;
108        let tc_out = Timecode::from_frames(new_out.round() as u64, rate)?;
109        result.push(SubtitleCue::new(cue.id, tc_in, tc_out, cue.text.clone()));
110    }
111    Ok(result)
112}
113
114/// Compute the average drift (in frames) between subtitle cue-in times and a
115/// set of expected reference timecodes.
116///
117/// Both slices must have the same length. Returns `None` if empty.
118#[allow(clippy::cast_precision_loss)]
119pub fn compute_average_drift(cues: &[SubtitleCue], reference_in_frames: &[u64]) -> Option<f64> {
120    if cues.is_empty() || cues.len() != reference_in_frames.len() {
121        return None;
122    }
123    let total: f64 = cues
124        .iter()
125        .zip(reference_in_frames.iter())
126        .map(|(cue, &ref_f)| cue.tc_in.to_frames() as f64 - ref_f as f64)
127        .sum();
128    Some(total / cues.len() as f64)
129}
130
131/// Sort a list of cues by their in-timecode.
132pub fn sort_cues_by_in(cues: &mut [SubtitleCue]) {
133    cues.sort_by_key(|c| c.tc_in.to_frames());
134}
135
136/// Detect overlapping cues and return pairs of indices.
137pub fn find_overlaps(cues: &[SubtitleCue]) -> Vec<(usize, usize)> {
138    let mut overlaps = Vec::new();
139    for i in 0..cues.len() {
140        for j in (i + 1)..cues.len() {
141            if cues[i].overlaps(&cues[j]) {
142                overlaps.push((i, j));
143            }
144        }
145    }
146    overlaps
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn tc(h: u8, m: u8, s: u8, f: u8) -> Timecode {
154        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid timecode")
155    }
156
157    fn make_cue(id: u32, s_in: u8, f_in: u8, s_out: u8, f_out: u8) -> SubtitleCue {
158        SubtitleCue::new(
159            id,
160            tc(0, 0, s_in, f_in),
161            tc(0, 0, s_out, f_out),
162            format!("cue {id}"),
163        )
164    }
165
166    #[test]
167    fn test_cue_duration_frames() {
168        let cue = make_cue(1, 0, 0, 1, 0);
169        assert_eq!(cue.duration_frames(), 25);
170    }
171
172    #[test]
173    fn test_cue_duration_secs() {
174        let cue = make_cue(1, 0, 0, 2, 0);
175        let d = cue.duration_secs();
176        assert!((d - 2.0).abs() < 1e-9);
177    }
178
179    #[test]
180    fn test_cue_overlap() {
181        let a = make_cue(1, 0, 0, 2, 0); // 0..50
182        let b = make_cue(2, 1, 0, 3, 0); // 25..75
183        assert!(a.overlaps(&b));
184    }
185
186    #[test]
187    fn test_cue_no_overlap() {
188        let a = make_cue(1, 0, 0, 1, 0); // 0..25
189        let b = make_cue(2, 1, 0, 2, 0); // 25..50
190        assert!(!a.overlaps(&b));
191    }
192
193    #[test]
194    fn test_apply_frame_offset_positive() {
195        let cues = vec![make_cue(1, 0, 0, 1, 0)];
196        let shifted =
197            apply_frame_offset(&cues, 10, FrameRate::Fps25).expect("frame offset should succeed");
198        assert_eq!(shifted[0].tc_in.to_frames(), 10);
199        assert_eq!(shifted[0].tc_out.to_frames(), 35);
200    }
201
202    #[test]
203    fn test_apply_frame_offset_negative_clamp() {
204        let cues = vec![make_cue(1, 0, 0, 1, 0)];
205        let result = apply_frame_offset(&cues, -100, FrameRate::Fps25);
206        assert!(result.is_err());
207    }
208
209    #[test]
210    fn test_linear_stretch_identity() {
211        let cues = vec![make_cue(1, 1, 0, 2, 0)];
212        let stretched = apply_linear_stretch(&cues, 0, 1.0, FrameRate::Fps25)
213            .expect("linear stretch should succeed");
214        assert_eq!(stretched[0].tc_in.to_frames(), cues[0].tc_in.to_frames());
215        assert_eq!(stretched[0].tc_out.to_frames(), cues[0].tc_out.to_frames());
216    }
217
218    #[test]
219    fn test_linear_stretch_double() {
220        let cues = vec![make_cue(1, 1, 0, 2, 0)]; // frames 25..50
221        let stretched = apply_linear_stretch(&cues, 0, 2.0, FrameRate::Fps25)
222            .expect("linear stretch should succeed");
223        assert_eq!(stretched[0].tc_in.to_frames(), 50);
224        assert_eq!(stretched[0].tc_out.to_frames(), 100);
225    }
226
227    #[test]
228    fn test_compute_average_drift_zero() {
229        let cues = vec![make_cue(1, 1, 0, 2, 0)];
230        let refs = vec![25];
231        let drift = compute_average_drift(&cues, &refs).expect("drift computation should succeed");
232        assert!((drift).abs() < 1e-9);
233    }
234
235    #[test]
236    fn test_compute_average_drift_some() {
237        let cues = vec![make_cue(1, 1, 0, 2, 0), make_cue(2, 2, 0, 3, 0)];
238        let refs = vec![20, 45];
239        let drift = compute_average_drift(&cues, &refs).expect("drift computation should succeed");
240        // cue1: 25-20=5, cue2: 50-45=5, avg=5
241        assert!((drift - 5.0).abs() < 1e-9);
242    }
243
244    #[test]
245    fn test_sort_cues_by_in() {
246        let mut cues = vec![make_cue(2, 2, 0, 3, 0), make_cue(1, 0, 0, 1, 0)];
247        sort_cues_by_in(&mut cues);
248        assert_eq!(cues[0].id, 1);
249        assert_eq!(cues[1].id, 2);
250    }
251
252    #[test]
253    fn test_find_overlaps() {
254        let cues = vec![
255            make_cue(1, 0, 0, 2, 0),
256            make_cue(2, 1, 0, 3, 0),
257            make_cue(3, 5, 0, 6, 0),
258        ];
259        let overlaps = find_overlaps(&cues);
260        assert_eq!(overlaps.len(), 1);
261        assert_eq!(overlaps[0], (0, 1));
262    }
263
264    #[test]
265    fn test_compute_average_drift_empty() {
266        let drift = compute_average_drift(&[], &[]);
267        assert!(drift.is_none());
268    }
269}