Skip to main content

timed_metadata/webvtt/
writer.rs

1//! WebVTT cue serialization: cue-block formatting, the `WEBVTT` document
2//! header, and HLS segmented output with `X-TIMESTAMP-MAP` (RFC 8216 §3.5).
3//!
4//! Cites `specs/rules/webvtt-rules.md` (curated W3C WebVTT §4 + RFC 8216
5//! §3.5). First-pass writer: plain-cue-only (no `STYLE`/`REGION`/`NOTE`
6//! header blocks, no cue-identifier lines, no cue settings) — see
7//! `crate::webvtt` module docs for the full list of documented losses.
8use crate::event::MediaTime;
9use crate::timeline::PTS_WRAP;
10use crate::webvtt::Cue;
11use alloc::format;
12use alloc::string::String;
13
14/// 90 kHz ticks per millisecond (`crate::PTS_HZ` / 1000): the ratio used to
15/// render a [`MediaTime`] as a WebVTT timestamp.
16const TICKS_PER_MS: u64 = crate::PTS_HZ / 1000;
17/// Milliseconds per hour / minute / second, for timestamp field extraction.
18const MS_PER_HOUR: u64 = 3_600_000;
19const MS_PER_MIN: u64 = 60_000;
20const MS_PER_SEC: u64 = 1_000;
21
22/// Format a [`MediaTime`] as a WebVTT timestamp `hh:mm:ss.ttt` (W3C WebVTT
23/// §4.3.1). Hours are always emitted: the grammar `(hh:)?mm:ss.ttt` makes
24/// hours *optional*, not forbidden, and always including them keeps this
25/// function total and monotonic without a >=1h special case.
26#[must_use]
27pub fn format_timestamp(t: MediaTime) -> String {
28    let total_ms = t.0 / TICKS_PER_MS;
29    let hours = total_ms / MS_PER_HOUR;
30    let mins = (total_ms % MS_PER_HOUR) / MS_PER_MIN;
31    let secs = (total_ms % MS_PER_MIN) / MS_PER_SEC;
32    let ms = total_ms % MS_PER_SEC;
33    format!("{hours:02}:{mins:02}:{secs:02}.{ms:03}")
34}
35
36/// Escape a cue payload line per W3C WebVTT §6.4: `&` -> `&`,
37/// `<` -> `&lt;`, `>` -> `&gt;` (order is immaterial here: this is a single
38/// left-to-right character scan, not a sequence of whole-string
39/// find-and-replace passes, so an inserted `&amp;` is never re-scanned and
40/// cannot be corrupted by a later `<`/`>` substitution).
41#[must_use]
42pub fn escape_payload(text: &str) -> String {
43    let mut out = String::with_capacity(text.len());
44    for c in text.chars() {
45        match c {
46            '&' => out.push_str("&amp;"),
47            '<' => out.push_str("&lt;"),
48            '>' => out.push_str("&gt;"),
49            _ => out.push(c),
50        }
51    }
52    out
53}
54
55/// Render one cue block: the timings line (`start --> end`) followed by the
56/// escaped payload lines, terminated with a single trailing newline. No cue
57/// identifier and no cue settings are emitted (first-pass simplification).
58#[must_use]
59pub fn cue_block(cue: &Cue) -> String {
60    let mut out = format!(
61        "{} --> {}\n",
62        format_timestamp(cue.start),
63        format_timestamp(cue.end)
64    );
65    for (i, line) in cue.text.lines().enumerate() {
66        if i > 0 {
67            out.push('\n');
68        }
69        out.push_str(&escape_payload(line));
70    }
71    out.push('\n');
72    out
73}
74
75/// Render a standalone WebVTT document: the `WEBVTT` signature, a blank
76/// line, then each cue block separated by a blank line (W3C WebVTT §4).
77#[must_use]
78pub fn write_document(cues: &[Cue]) -> String {
79    let mut out = String::from("WEBVTT\n\n");
80    for cue in cues {
81        out.push_str(&cue_block(cue));
82        out.push('\n');
83    }
84    out
85}
86
87/// Render one HLS **segment** of WebVTT (RFC 8216 §3.5): the `WEBVTT`
88/// signature, an `X-TIMESTAMP-MAP=MPEGTS:<n>,LOCAL:00:00:00.000` header
89/// mapping this segment's local WebVTT clock to the shared MPEG-2 TS (PES)
90/// timeline, then each cue rendered with times **relative to
91/// `segment_start`** (so cue timestamps stay small and segment-local, per
92/// the RFC 8216 convention of `LOCAL:00:00:00.000`).
93///
94/// `segment_start` is the carrying media segment's first PES PTS (already
95/// wrap-unrolled, e.g. via [`crate::timeline::Timeline`]); it is reduced
96/// modulo 2^33 for the `MPEGTS:` field, matching the 33-bit PTS the value
97/// represents on the wire. Cues with `start`/`end` before `segment_start`
98/// saturate to zero (should not occur for cues that genuinely belong to this
99/// segment).
100#[must_use]
101pub fn write_segment(cues: &[Cue], segment_start: MediaTime) -> String {
102    let mpegts = segment_start.0 % PTS_WRAP;
103    let mut out = format!("WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:{mpegts},LOCAL:00:00:00.000\n\n");
104    for cue in cues {
105        let local = Cue {
106            start: MediaTime(cue.start.0.saturating_sub(segment_start.0)),
107            end: MediaTime(cue.end.0.saturating_sub(segment_start.0)),
108            text: cue.text.clone(),
109        };
110        out.push_str(&cue_block(&local));
111        out.push('\n');
112    }
113    out
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use alloc::string::ToString;
120
121    fn cue(start: u64, end: u64, text: &str) -> Cue {
122        Cue {
123            start: MediaTime(start),
124            end: MediaTime(end),
125            text: text.to_string(),
126        }
127    }
128
129    #[test]
130    fn timestamp_formatting() {
131        assert_eq!(format_timestamp(MediaTime(0)), "00:00:00.000");
132        // 90_000 ticks = 1.000s
133        assert_eq!(format_timestamp(MediaTime(90_000)), "00:00:01.000");
134        // 1 minute = 60 * 90_000 ticks
135        assert_eq!(format_timestamp(MediaTime(60 * 90_000)), "00:01:00.000");
136        // 1 hour
137        assert_eq!(format_timestamp(MediaTime(3_600 * 90_000)), "01:00:00.000");
138        // sub-second: 90 ticks = 1 ms at the 90 kHz clock.
139        assert_eq!(format_timestamp(MediaTime(90)), "00:00:00.001");
140    }
141
142    #[test]
143    fn escape_order_is_safe() {
144        // A literal "&lt;" in the source text must not become "&amp;lt;" —
145        // single-pass char scanning only ever escapes the original '&'.
146        assert_eq!(escape_payload("&lt;"), "&amp;lt;");
147        assert_eq!(escape_payload("a < b & c > d"), "a &lt; b &amp; c &gt; d");
148    }
149
150    #[test]
151    fn cue_block_multiline() {
152        let c = cue(90_000, 180_000, "line one\nline two");
153        let block = cue_block(&c);
154        assert_eq!(block, "00:00:01.000 --> 00:00:02.000\nline one\nline two\n");
155    }
156
157    #[test]
158    fn write_document_signature_and_blank_separation() {
159        let cues = [cue(0, 1_000, "a"), cue(2_000, 3_000, "b")];
160        let doc = write_document(&cues);
161        assert!(doc.starts_with("WEBVTT\n\n"));
162        // Exactly one blank line between the two cue blocks.
163        assert!(doc.contains("a\n\n00:00:00"));
164    }
165
166    #[test]
167    fn write_segment_timestamp_map_and_local_times() {
168        let cues = [cue(9_090_000, 9_180_000, "hi")]; // 101.0s .. 102.0s absolute
169        let seg = write_segment(&cues, MediaTime(9_000_000)); // segment starts at 100.0s
170        let mut lines = seg.lines();
171        assert_eq!(lines.next(), Some("WEBVTT"));
172        assert_eq!(
173            lines.next(),
174            Some("X-TIMESTAMP-MAP=MPEGTS:9000000,LOCAL:00:00:00.000")
175        );
176        assert_eq!(lines.next(), Some(""));
177        // cue-local time = 9_090_000 - 9_000_000 = 90_000 ticks = 1.000s
178        assert_eq!(lines.next(), Some("00:00:01.000 --> 00:00:02.000"));
179        assert_eq!(lines.next(), Some("hi"));
180    }
181
182    #[test]
183    fn write_segment_mpegts_wraps_at_33_bits() {
184        let wrap = PTS_WRAP; // 2^33
185        let seg = write_segment(&[], MediaTime(wrap + 5));
186        assert!(seg.contains("X-TIMESTAMP-MAP=MPEGTS:5,LOCAL:00:00:00.000"));
187    }
188}