Skip to main content

omni_dev/transcript/format/
vtt.rs

1//! WebVTT (`.vtt`) renderer.
2//!
3//! ```text
4//! WEBVTT
5//!
6//! 00:00:01.000 --> 00:00:04.000
7//! Subtitle text
8//!
9//! ...
10//! ```
11
12use std::fmt::Write;
13
14use crate::transcript::cue::Cue;
15
16/// Render `cues` to a WebVTT-formatted string. Always emits the `WEBVTT`
17/// header even for an empty cue list, so the output is a valid WebVTT
18/// document.
19pub fn render(cues: &[Cue]) -> String {
20    let mut out = String::from("WEBVTT\n\n");
21    for cue in cues {
22        let _ = writeln!(
23            out,
24            "{} --> {}",
25            format_timestamp(cue.start_ms),
26            format_timestamp(cue.end_ms)
27        );
28        out.push_str(&cue.text);
29        out.push('\n');
30        out.push('\n');
31    }
32    out
33}
34
35/// Format `ms` as `HH:MM:SS.mmm` (WebVTT's period decimal separator).
36fn format_timestamp(ms: u64) -> String {
37    let hours = ms / 3_600_000;
38    let minutes = (ms % 3_600_000) / 60_000;
39    let seconds = (ms % 60_000) / 1_000;
40    let millis = ms % 1_000;
41    format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn empty_input_emits_header_only() {
50        assert_eq!(render(&[]), "WEBVTT\n\n");
51    }
52
53    #[test]
54    fn header_precedes_cues() {
55        let out = render(&[Cue::new(0, 1_000, "x")]);
56        assert!(out.starts_with("WEBVTT\n\n"));
57    }
58
59    #[test]
60    fn single_cue_basic() {
61        let cues = vec![Cue::new(1_000, 4_000, "Hello, world.")];
62        let out = render(&cues);
63        assert_eq!(
64            out,
65            "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHello, world.\n\n"
66        );
67    }
68
69    #[test]
70    fn cues_are_not_numbered() {
71        let cues = vec![Cue::new(0, 1_000, "one"), Cue::new(1_000, 2_000, "two")];
72        let out = render(&cues);
73        // No leading "1\n" before the timecode (unlike SRT).
74        assert!(!out.contains("\n1\n00:"));
75        assert!(!out.contains("\n2\n00:"));
76    }
77
78    #[test]
79    fn timestamps_use_period_separator_not_comma() {
80        let ts = format_timestamp(1_500);
81        assert!(ts.contains('.'));
82        assert!(!ts.contains(','));
83    }
84
85    #[test]
86    fn timestamps_pad_zero() {
87        assert_eq!(format_timestamp(0), "00:00:00.000");
88    }
89
90    #[test]
91    fn timestamps_handle_subsecond_millis() {
92        assert_eq!(format_timestamp(1), "00:00:00.001");
93        assert_eq!(format_timestamp(999), "00:00:00.999");
94    }
95
96    #[test]
97    fn timestamps_handle_hour_boundary() {
98        assert_eq!(format_timestamp(3_600_000), "01:00:00.000");
99        assert_eq!(format_timestamp(3_599_999), "00:59:59.999");
100    }
101
102    #[test]
103    fn timestamps_handle_multi_hour() {
104        assert_eq!(
105            format_timestamp(2 * 3_600_000 + 30 * 60_000 + 45 * 1_000 + 678),
106            "02:30:45.678"
107        );
108    }
109
110    #[test]
111    fn cue_text_with_newlines_is_preserved() {
112        let cues = vec![Cue::new(0, 1_000, "line one\nline two")];
113        let out = render(&cues);
114        assert!(out.contains("line one\nline two\n\n"));
115    }
116
117    #[test]
118    fn zero_length_cue_is_emitted() {
119        let cues = vec![Cue::new(500, 500, "instant")];
120        let out = render(&cues);
121        assert!(out.contains("00:00:00.500 --> 00:00:00.500"));
122        assert!(out.contains("instant"));
123    }
124
125    #[test]
126    fn empty_text_cue_yields_blank_content_line() {
127        let cues = vec![Cue::new(0, 1_000, "")];
128        let out = render(&cues);
129        assert_eq!(out, "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\n\n\n");
130    }
131
132    #[test]
133    fn output_ends_with_blank_line_separator() {
134        let cues = vec![Cue::new(0, 1_000, "x")];
135        let out = render(&cues);
136        assert!(out.ends_with("\n\n"));
137    }
138}