Skip to main content

motion_content/captions/
track.rs

1//! Bounded SRT interchange and stateless, half-open caption sampling.
2
3use motion_core::ValidationError;
4use std::fmt::Write;
5use std::time::Duration;
6
7const MAX_SRT_BYTES: usize = 8 * 1024 * 1024;
8
9const MAX_CUES: usize = 16_384;
10
11const MAX_CUE_BYTES: usize = 64 * 1024;
12
13/// Immutable caption text active in the half-open interval `[start, end)`.
14///
15/// Times use `Duration` precision. Text uses LF-separated nonblank lines, without
16/// trailing LF, CR or control characters other than LF/tab. Markup is literal.
17/// This type supplies content, not font selection, layout or a playback clock.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct CaptionCue {
20    start: Duration,
21    end: Duration,
22    text: String,
23}
24
25impl CaptionCue {
26    /// Validates increasing times and nonblank text of at most 64 KiB in UTF-8.
27    /// Blank text lines and unsupported control characters are rejected.
28    pub fn new(
29        start: Duration,
30        end: Duration,
31        text: impl Into<String>,
32    ) -> Result<Self, ValidationError> {
33        let text = text.into();
34        if start >= end
35            || text.len() > MAX_CUE_BYTES
36            || text.split('\n').any(|line| line.trim().is_empty())
37            || text
38                .chars()
39                .any(|character| character.is_control() && character != '\n' && character != '\t')
40        {
41            return Err(invalid(
42                "Caption requires start < end and 1..=65536 bytes of nonblank LF text without unsupported controls",
43            ));
44        }
45        Ok(Self { start, end, text })
46    }
47
48    /// Returns the inclusive cue start relative to the sampled frame's clock.
49    pub fn start(&self) -> Duration {
50        self.start
51    }
52
53    /// Returns the exclusive cue end relative to the sampled frame's clock.
54    pub fn end(&self) -> Duration {
55        self.end
56    }
57
58    /// Returns literal UTF-8 text with LF-separated lines.
59    pub fn text(&self) -> &str {
60        &self.text
61    }
62}
63
64/// An immutable, bounded caption timeline with handwritten SRT interchange.
65///
66/// Build or parse outside frame callbacks. Sampling scans at most 16,384 cues,
67/// preserving input order and overlaps without sorting or previous-frame state.
68/// Layer construction and overlapping-label placement belong to the caller.
69///
70/// ```
71/// use motion_content::captions::*;
72/// use motion_core::prelude::*;
73/// let captions = CaptionTrack::from_srt("1\n00:00:01,000 --> 00:00:03,000\nLight takes time.\n")?;
74/// let time = Time::from_frame(30, FrameRate::from(30));
75/// assert_eq!(
76///     captions.active_at_time(time).next().unwrap().text(),
77///     "Light takes time."
78/// );
79/// assert!(captions.to_srt()?.starts_with("1\n00:00:01,000"));
80/// # Ok::<(), Box<dyn std::error::Error>>(())
81/// ```
82#[derive(Clone, Debug, Default, PartialEq, Eq)]
83pub struct CaptionTrack {
84    cues: Vec<CaptionCue>,
85}
86
87impl CaptionTrack {
88    /// Keeps input order, including overlaps and unsorted times; empty input is valid.
89    /// Rejects more than 16,384 cues or over 8 MiB of aggregate UTF-8 text.
90    /// Cloning a track clones its text; it does not share mutable playback state.
91    pub fn new(cues: impl IntoIterator<Item = CaptionCue>) -> Result<Self, ValidationError> {
92        let mut track = Self::default();
93        let mut bytes = 0;
94        for cue in cues {
95            bytes += cue.text.len();
96            if track.cues.len() == MAX_CUES || bytes > MAX_SRT_BYTES {
97                return Err(invalid("Caption track exceeds 16384 cues or 8 MiB of text"));
98            }
99            track.cues.try_reserve(1).map_err(|_| reserve_error())?;
100            track.cues.push(cue);
101        }
102        Ok(track)
103    }
104
105    /// Returns immutable cues in their original input order.
106    pub fn cues(&self) -> &[CaptionCue] {
107        &self.cues
108    }
109
110    /// Samples an explicit time in the same half-open interval and input order.
111    pub fn active_at(&self, time: Duration) -> impl Iterator<Item = &CaptionCue> {
112        self.cues
113            .iter()
114            .filter(move |cue| cue.start <= time && time < cue.end)
115    }
116
117    /// Samples signed ticks exactly, without rounding through nanoseconds.
118    /// Negative times contain no captions. Cue intervals remain half-open.
119    pub fn active_at_time(&self, time: motion_core::Time) -> impl Iterator<Item = &CaptionCue> {
120        let ticks = u128::try_from(time.ticks()).ok();
121        self.cues.iter().filter(move |cue| {
122            ticks.is_some_and(|ticks| {
123                let position = ticks * 1_000_000_000;
124                cue.start.as_nanos() * motion_core::TICKS_PER_SECOND as u128 <= position
125                    && position < cue.end.as_nanos() * motion_core::TICKS_PER_SECOND as u128
126            })
127        })
128    }
129
130    /// Parses at most 8 MiB of UTF-8 SRT, with optional leading BOM and LF/CRLF.
131    ///
132    /// Requires unsigned u64 cue identifiers and `HH:MM:SS,mmm --> HH:MM:SS,mmm`;
133    /// hours have at least two digits, minutes/seconds are below 60. Identifiers
134    /// need not be consecutive or unique. Blank lines separate cues; surrounding
135    /// blank lines and horizontal whitespace around identifiers/arrows are allowed.
136    /// Multiline bodies and literal markup are preserved; timing/layout extensions
137    /// are rejected. Syntax/limits return `InvalidData` with a one-based line number.
138    /// Empty input is a valid empty track. No files are read during sampling.
139    pub fn from_srt(source: &str) -> Result<Self, ValidationError> {
140        if source.len() > MAX_SRT_BYTES {
141            return Err(srt_error(1, "input exceeds 8 MiB"));
142        }
143        let source = source.strip_prefix('\u{feff}').unwrap_or(source);
144        let mut lines = source.lines().enumerate().peekable();
145        let mut cues = Vec::new();
146        while let Some((number, identifier)) = lines.next() {
147            if identifier.trim().is_empty() {
148                continue;
149            }
150            if cues.len() == MAX_CUES {
151                return Err(srt_error(number + 1, "more than 16384 cues"));
152            }
153            let identifier = identifier.trim_matches([' ', '\t']);
154            if identifier.is_empty()
155                || !identifier.bytes().all(|byte| byte.is_ascii_digit())
156                || identifier.parse::<u64>().is_err()
157            {
158                return Err(srt_error(number + 1, "expected unsigned cue identifier"));
159            }
160            let (timing_number, timing) = lines
161                .next()
162                .ok_or_else(|| srt_error(number + 2, "missing timing line"))?;
163            let (start, end) = timing
164                .split_once("-->")
165                .ok_or_else(|| srt_error(timing_number + 1, "missing --> separator"))?;
166            let start = parse_timestamp(start.trim_matches([' ', '\t']))
167                .ok_or_else(|| srt_error(timing_number + 1, "invalid start timestamp"))?;
168            let end = parse_timestamp(end.trim_matches([' ', '\t']))
169                .ok_or_else(|| srt_error(timing_number + 1, "invalid end timestamp"))?;
170            if start >= end {
171                return Err(srt_error(timing_number + 1, "start must precede end"));
172            }
173            let mut text = String::new();
174            while let Some((body_number, line)) = lines.peek().copied() {
175                if line.trim().is_empty() {
176                    break;
177                }
178                let additional = line.len() + usize::from(!text.is_empty());
179                if additional > MAX_CUE_BYTES - text.len() {
180                    return Err(srt_error(body_number + 1, "cue body exceeds 64 KiB"));
181                }
182                if line
183                    .chars()
184                    .any(|character| character.is_control() && character != '\t')
185                {
186                    return Err(srt_error(
187                        body_number + 1,
188                        "unsupported body control character",
189                    ));
190                }
191                text.try_reserve(additional).map_err(|_| reserve_error())?;
192                if !text.is_empty() {
193                    text.push('\n');
194                }
195                text.push_str(line);
196                lines.next();
197            }
198            let cue = CaptionCue::new(start, end, text)
199                .map_err(|error| srt_error(timing_number + 2, &error.to_string()))?;
200            cues.try_reserve(1).map_err(|_| reserve_error())?;
201            cues.push(cue);
202        }
203        Ok(Self { cues })
204    }
205
206    /// Serializes canonical SRT with sequential one-based identifiers and LF lines.
207    /// Preserves cue order/text/times; does not sort or retain original identifiers.
208    /// Rejects sub-millisecond times rather than rounding, and output over 8 MiB.
209    /// No file writes, subtitle muxing or audio mixing are performed.
210    pub fn to_srt(&self) -> Result<String, ValidationError> {
211        let mut output = String::new();
212        for (index, cue) in self.cues.iter().enumerate() {
213            let start = format_timestamp(cue.start)?;
214            let end = format_timestamp(cue.end)?;
215            let header = format!("{}\n{start} --> {end}\n", index + 1);
216            let additional = header.len() + cue.text.len() + 2;
217            if additional > MAX_SRT_BYTES - output.len() {
218                return Err(invalid("SRT output exceeds 8 MiB"));
219            }
220            output
221                .try_reserve(additional)
222                .map_err(|_| reserve_error())?;
223            output.push_str(&header);
224            output.push_str(&cue.text);
225            output.push_str("\n\n");
226        }
227        Ok(output)
228    }
229}
230
231fn parse_timestamp(value: &str) -> Option<Duration> {
232    let mut parts = value.split(':');
233    let hours = parts.next()?;
234    let minutes = parts.next()?;
235    let (seconds, millis) = parts.next()?.split_once(',')?;
236    if parts.next().is_some()
237        || hours.len() < 2
238        || minutes.len() != 2
239        || seconds.len() != 2
240        || millis.len() != 3
241        || [hours, minutes, seconds, millis]
242            .iter()
243            .any(|part| !part.bytes().all(|byte| byte.is_ascii_digit()))
244    {
245        return None;
246    }
247    let hours = hours.parse::<u64>().ok()?;
248    let minutes = minutes.parse::<u64>().ok()?;
249    let seconds = seconds.parse::<u64>().ok()?;
250    let millis = millis.parse::<u32>().ok()?;
251    if minutes >= 60 || seconds >= 60 {
252        return None;
253    }
254    let total = hours
255        .checked_mul(3600)?
256        .checked_add(minutes * 60)?
257        .checked_add(seconds)?;
258    Some(Duration::new(total, millis * 1_000_000))
259}
260
261fn format_timestamp(time: Duration) -> Result<String, ValidationError> {
262    if !time.subsec_nanos().is_multiple_of(1_000_000) {
263        return Err(invalid(
264            "SRT requires exact millisecond timestamps; round explicitly before constructing cues",
265        ));
266    }
267    let seconds = time.as_secs();
268    let mut timestamp = String::new();
269    write!(
270        timestamp,
271        "{:02}:{:02}:{:02},{:03}",
272        seconds / 3600,
273        seconds / 60 % 60,
274        seconds % 60,
275        time.subsec_millis()
276    )
277    .map_err(|_| invalid("Unable to format SRT timestamp"))?;
278    Ok(timestamp)
279}
280
281fn invalid(message: &str) -> ValidationError {
282    ValidationError::invalid("captions", "input", message)
283}
284
285fn reserve_error() -> ValidationError {
286    ValidationError::limit("captions", "max_bytes", "Could not reserve caption storage")
287}
288
289fn srt_error(line: usize, message: &str) -> ValidationError {
290    ValidationError::invalid("captions", "srt", format!("SRT line {line}: {message}"))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use motion_core::ValidationErrorKind;
297
298    fn cue(start: u64, end: u64, text: &str) -> CaptionCue {
299        CaptionCue::new(
300            Duration::from_millis(start),
301            Duration::from_millis(end),
302            text,
303        )
304        .unwrap()
305    }
306
307    #[test]
308    fn parses_bom_crlf_multiline_unicode_and_unsorted_overlapping_cues()
309    -> Result<(), ValidationError> {
310        let track = CaptionTrack::from_srt(
311            "\u{feff}\r\n 42 \r\n00:00:02,000\t-->\t00:00:04,000\r\n\u{5149}\u{5e74}\r\n<i>literal</i>\r\n\r\n7\n00:00:01,000 --> 00:00:03,000\nEarlier\n",
312        )?;
313        assert_eq!(track.cues()[0].text(), "\u{5149}\u{5e74}\n<i>literal</i>");
314        assert_eq!(track.cues()[0].start(), Duration::from_secs(2));
315        assert_eq!(track.cues()[0].end(), Duration::from_secs(4));
316        assert_eq!(
317            track
318                .active_at(Duration::from_millis(2500))
319                .map(CaptionCue::text)
320                .collect::<Vec<_>>(),
321            ["\u{5149}\u{5e74}\n<i>literal</i>", "Earlier"]
322        );
323        Ok(())
324    }
325
326    #[test]
327    fn canonical_srt_round_trips_order_body_and_empty_tracks() -> Result<(), ValidationError> {
328        let source = "9\n00:00:01,025 --> 00:00:02,999\n First\nsecond \n\n0\n00:00:01,000 --> 00:00:04,000\nLast";
329        let track = CaptionTrack::from_srt(source)?;
330        let serialized = track.to_srt()?;
331        assert_eq!(
332            serialized,
333            "1\n00:00:01,025 --> 00:00:02,999\n First\nsecond \n\n2\n00:00:01,000 --> 00:00:04,000\nLast\n\n"
334        );
335        assert_eq!(CaptionTrack::from_srt(&serialized)?, track);
336        assert_eq!(
337            CaptionTrack::from_srt("\u{feff}\r\n\n")?,
338            CaptionTrack::default()
339        );
340        assert_eq!(CaptionTrack::default().to_srt()?, "");
341        Ok(())
342    }
343
344    #[test]
345    fn frame_sampling_is_half_open_and_replays_out_of_order() -> motion_core::Result {
346        let track = CaptionTrack::new([cue(1000, 3000, "first"), cue(2000, 2500, "second")])?;
347        for (index, expected) in [
348            (60, vec!["first", "second"]),
349            (0, vec![]),
350            (29, vec![]),
351            (30, vec!["first"]),
352            (75, vec!["first"]),
353            (90, vec![]),
354            (60, vec!["first", "second"]),
355        ] {
356            assert_eq!(
357                track
358                    .active_at_time(motion_core::Time::from_frame(
359                        i64::from(index),
360                        motion_core::FrameRate::from(30)
361                    ))
362                    .map(CaptionCue::text)
363                    .collect::<Vec<_>>(),
364                expected
365            );
366        }
367        Ok(())
368    }
369
370    #[test]
371    fn fractional_frame_times_do_not_round_and_local_frames_use_local_clocks() -> motion_core::Result
372    {
373        let track = CaptionTrack::new([
374            CaptionCue::new(
375                Duration::from_nanos(333333333),
376                Duration::from_nanos(333333334),
377                "inside",
378            )?,
379            CaptionCue::new(Duration::ZERO, Duration::from_nanos(333333333), "before")?,
380        ])?;
381        let rate = motion_core::FrameRate::from(3);
382        for frame in [
383            motion_core::Time::from_frame(1, rate),
384            motion_core::Time::from_frame(4, rate) - motion_core::Time::from_frame(3, rate),
385            motion_core::Time::from_frame(1, rate),
386        ] {
387            assert_eq!(
388                track
389                    .active_at_time(frame)
390                    .map(CaptionCue::text)
391                    .collect::<Vec<_>>(),
392                ["inside"]
393            );
394        }
395        assert!(track.to_srt().is_err());
396        Ok(())
397    }
398
399    #[test]
400    fn malformed_srt_reports_invalid_data_and_a_line_number() {
401        for source in [
402            "one\n00:00:00,000 --> 00:00:01,000\ntext",
403            "18446744073709551616\n00:00:00,000 --> 00:00:01,000\ntext",
404            "1",
405            "1\n",
406            "1\n00:00:00,000 -> 00:00:01,000\ntext",
407            "1\n0:00:00,000 --> 00:00:01,000\ntext",
408            "1\n00:60:00,000 --> 01:00:01,000\ntext",
409            "1\n00:00:60,000 --> 00:01:01,000\ntext",
410            "1\n00:00:00.000 --> 00:00:01,000\ntext",
411            "1\n00:00:00,00 --> 00:00:01,000\ntext",
412            "1\n00:00:00,000 --> 00:00:01,000 X1:0\ntext",
413            "1\n00:00:01,000 --> 00:00:01,000\ntext",
414            "1\n00:00:02,000 --> 00:00:01,000\ntext",
415            "1\n9999999999999999999:00:00,000 --> 9999999999999999999:00:01,000\ntext",
416            "1\n00:00:00,000 --> 00:00:01,000\n\n",
417            "1\n00:00:00,000 --> 00:00:01,000\ntext\0",
418        ] {
419            let error = CaptionTrack::from_srt(source).unwrap_err();
420            assert_eq!(error.kind, ValidationErrorKind::InvalidValue);
421            assert_eq!(error.field, "srt");
422            assert!(error.message.starts_with("SRT line "));
423        }
424        let error = CaptionTrack::from_srt("1\ninvalid\ntext").unwrap_err();
425        assert!(error.message.starts_with("SRT line 2:"));
426    }
427
428    #[test]
429    fn cue_validation_rejects_empty_lines_controls_and_nonincreasing_times() {
430        for text in ["", " ", "line\n\nnext", "line\n", "line\r\nnext", "line\0"] {
431            assert!(CaptionCue::new(Duration::ZERO, Duration::from_secs(1), text).is_err());
432        }
433        assert!(CaptionCue::new(Duration::ZERO, Duration::ZERO, "text").is_err());
434        assert!(CaptionCue::new(Duration::from_secs(1), Duration::ZERO, "text").is_err());
435        assert!(CaptionCue::new(Duration::ZERO, Duration::from_secs(1), "line\ttext").is_ok());
436    }
437
438    #[test]
439    fn limits_bound_parsing_construction_and_serialization() -> Result<(), ValidationError> {
440        assert!(CaptionTrack::from_srt(&"x".repeat(MAX_SRT_BYTES + 1)).is_err());
441        assert!(
442            CaptionTrack::from_srt(&"1\n00:00:00,000 --> 00:00:01,000\nx\n\n".repeat(MAX_CUES + 1))
443                .is_err()
444        );
445        assert!(CaptionTrack::new(std::iter::repeat_n(cue(0, 1, "x"), MAX_CUES + 1)).is_err());
446        let large = "x".repeat(MAX_CUE_BYTES);
447        assert!(
448            CaptionCue::new(Duration::ZERO, Duration::from_secs(1), format!("{large}x")).is_err()
449        );
450        assert!(
451            CaptionTrack::from_srt(&format!("1\n00:00:00,000 --> 00:00:01,000\n{large}x")).is_err()
452        );
453        let large = cue(0, 1, &large);
454        assert!(
455            CaptionTrack::new(std::iter::repeat_n(
456                large.clone(),
457                MAX_SRT_BYTES / MAX_CUE_BYTES + 1
458            ))
459            .is_err()
460        );
461        let track = CaptionTrack::new(std::iter::repeat_n(large, MAX_SRT_BYTES / MAX_CUE_BYTES))?;
462        assert!(track.to_srt().is_err());
463        Ok(())
464    }
465
466    #[test]
467    fn very_large_durations_round_trip_without_timestamp_overflow() -> Result<(), ValidationError> {
468        let start = Duration::new(u64::MAX - 1, 0);
469        let end = Duration::new(u64::MAX, 999_000_000);
470        let track = CaptionTrack::new([CaptionCue::new(start, end, "far future")?])?;
471        assert_eq!(CaptionTrack::from_srt(&track.to_srt()?)?, track);
472        assert_eq!(track.active_at(start).count(), 1);
473        assert_eq!(track.active_at(end).count(), 0);
474        Ok(())
475    }
476}