omni_dev/transcript/format/
srt.rs1use std::fmt::Write;
13
14use crate::transcript::cue::Cue;
15
16pub fn render(cues: &[Cue]) -> String {
19 let mut out = String::new();
20 for (idx, cue) in cues.iter().enumerate() {
21 let _ = writeln!(out, "{}", idx + 1);
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_yields_empty_string() {
50 assert_eq!(render(&[]), "");
51 }
52
53 #[test]
54 fn single_cue_basic() {
55 let cues = vec![Cue::new(1_000, 4_000, "Hello, world.")];
56 let out = render(&cues);
57 assert_eq!(out, "1\n00:00:01,000 --> 00:00:04,000\nHello, world.\n\n");
58 }
59
60 #[test]
61 fn multiple_cues_are_sequentially_numbered() {
62 let cues = vec![
63 Cue::new(0, 1_000, "one"),
64 Cue::new(1_000, 2_000, "two"),
65 Cue::new(2_000, 3_000, "three"),
66 ];
67 let out = render(&cues);
68 assert!(out.starts_with("1\n"));
69 assert!(out.contains("\n2\n"));
70 assert!(out.contains("\n3\n"));
71 assert!(out.contains("one"));
72 assert!(out.contains("two"));
73 assert!(out.contains("three"));
74 }
75
76 #[test]
77 fn timestamps_pad_zero() {
78 assert_eq!(format_timestamp(0), "00:00:00,000");
79 }
80
81 #[test]
82 fn timestamps_handle_subsecond_millis() {
83 assert_eq!(format_timestamp(1), "00:00:00,001");
84 assert_eq!(format_timestamp(999), "00:00:00,999");
85 }
86
87 #[test]
88 fn timestamps_handle_minute_boundary() {
89 assert_eq!(format_timestamp(60_000), "00:01:00,000");
90 assert_eq!(format_timestamp(59_999), "00:00:59,999");
91 }
92
93 #[test]
94 fn timestamps_handle_hour_boundary() {
95 assert_eq!(format_timestamp(3_600_000), "01:00:00,000");
96 assert_eq!(format_timestamp(3_599_999), "00:59:59,999");
97 }
98
99 #[test]
100 fn timestamps_handle_multi_hour() {
101 assert_eq!(
102 format_timestamp(2 * 3_600_000 + 30 * 60_000 + 45 * 1_000 + 678),
103 "02:30:45,678"
104 );
105 }
106
107 #[test]
108 fn timestamps_use_comma_separator_not_period() {
109 let ts = format_timestamp(1_500);
110 assert!(ts.contains(','));
111 assert!(!ts.contains('.'));
112 }
113
114 #[test]
115 fn cue_text_with_newlines_is_preserved() {
116 let cues = vec![Cue::new(0, 1_000, "line one\nline two")];
117 let out = render(&cues);
118 assert!(out.contains("line one\nline two\n\n"));
119 }
120
121 #[test]
122 fn zero_length_cue_is_emitted() {
123 let cues = vec![Cue::new(500, 500, "instant")];
124 let out = render(&cues);
125 assert!(out.contains("00:00:00,500 --> 00:00:00,500"));
126 assert!(out.contains("instant"));
127 }
128
129 #[test]
130 fn empty_text_cue_still_has_blank_line() {
131 let cues = vec![Cue::new(0, 1_000, "")];
132 let out = render(&cues);
133 assert_eq!(out, "1\n00:00:00,000 --> 00:00:01,000\n\n\n");
135 }
136
137 #[test]
138 fn output_ends_with_blank_line_separator() {
139 let cues = vec![Cue::new(0, 1_000, "x")];
140 let out = render(&cues);
141 assert!(out.ends_with("\n\n"));
142 }
143
144 #[test]
145 fn special_characters_pass_through() {
146 let cues = vec![Cue::new(0, 1_000, "<i>italic</i> & \"quoted\"")];
147 let out = render(&cues);
148 assert!(out.contains("<i>italic</i> & \"quoted\""));
149 }
150}