omni_dev/transcript/format/
vtt.rs1use std::fmt::Write;
13
14use crate::transcript::cue::Cue;
15
16pub 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
35fn 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 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}