1use 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#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct CaptionCue {
20 start: Duration,
21 end: Duration,
22 text: String,
23}
24
25impl CaptionCue {
26 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 pub fn start(&self) -> Duration {
50 self.start
51 }
52
53 pub fn end(&self) -> Duration {
55 self.end
56 }
57
58 pub fn text(&self) -> &str {
60 &self.text
61 }
62}
63
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
83pub struct CaptionTrack {
84 cues: Vec<CaptionCue>,
85}
86
87impl CaptionTrack {
88 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 pub fn cues(&self) -> &[CaptionCue] {
107 &self.cues
108 }
109
110 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 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 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 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}