Skip to main content

timed_metadata/webvtt/
cue.rs

1//! Extracted caption cue + the CEA-608/708 cue extractors.
2//!
3//! See `crate::webvtt` module docs for the diff-based boundary-detection
4//! design and the documented losses.
5use crate::event::MediaTime;
6use alloc::string::String;
7#[cfg(any(feature = "cc-data", feature = "teletext"))]
8use alloc::vec::Vec;
9
10/// A single extracted caption cue: display text plus its media-timeline span.
11///
12/// `start`/`end` are wrap-unrolled 90 kHz [`MediaTime`] instants (see
13/// [`crate::timeline`]). `text` may contain embedded `\n` for multi-line
14/// captions (e.g. a 2-row CEA-608 roll-up window); [`crate::webvtt::cue_block`]
15/// emits each line of `text` as its own WebVTT payload line.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct Cue {
19    /// Cue start: the commit-event PTS (CEA-608 EOC / roll-up row reveal /
20    /// CEA-708 window-visible transition) — see `crate::webvtt` module docs.
21    pub start: MediaTime,
22    /// Cue end: the PTS of the next erase/replace event.
23    pub end: MediaTime,
24    /// Cue display text: plain, unescaped, unstyled (see `crate::webvtt`
25    /// module docs on documented losses).
26    pub text: String,
27}
28
29/// Shared diff-based boundary tracker used by the 608, 708, and Teletext
30/// extractors: unrolls the 33-bit PTS and turns "displayed text changed"
31/// transitions into completed [`Cue`]s.
32#[cfg(any(feature = "cc-data", feature = "teletext"))]
33struct DiffState {
34    unroller: crate::timeline::PtsUnroller,
35    open: Option<(u64, String)>,
36}
37
38#[cfg(any(feature = "cc-data", feature = "teletext"))]
39impl DiffState {
40    fn new() -> Self {
41        DiffState {
42            unroller: crate::timeline::PtsUnroller::default(),
43            open: None,
44        }
45    }
46
47    fn unroll(&mut self, pts33: u64) -> u64 {
48        self.unroller.unroll(pts33)
49    }
50
51    /// Observe the decoded text at `ticks`; push a completed cue into `cues`
52    /// when the text differs from the currently open cue (or, if none is
53    /// open, when the new text is non-empty).
54    fn observe(&mut self, ticks: u64, text: String, cues: &mut Vec<Cue>) {
55        let changed = match &self.open {
56            Some((_, cur)) => *cur != text,
57            None => !text.is_empty(),
58        };
59        if !changed {
60            return;
61        }
62        if let Some((start, prev)) = self.open.take()
63            && !prev.is_empty()
64        {
65            cues.push(Cue {
66                start: MediaTime(start),
67                end: MediaTime(ticks),
68                text: prev,
69            });
70        }
71        if !text.is_empty() {
72            self.open = Some((ticks, text));
73        }
74    }
75
76    /// Close any still-open cue at end of stream (or a channel/service reset).
77    fn finalize(&mut self, ticks: u64, cues: &mut Vec<Cue>) {
78        if let Some((start, prev)) = self.open.take()
79            && !prev.is_empty()
80        {
81            cues.push(Cue {
82                start: MediaTime(start),
83                end: MediaTime(ticks),
84                text: prev,
85            });
86        }
87    }
88}
89
90/// Extracts [`Cue`]s from a single CEA-608 data channel (CTA-608-E),
91/// wrapping a `cc-data` [`cc_data::decode::Cea608Decoder`].
92///
93/// Feed it one access unit's CEA-608 triplets at a time, tagged with that
94/// access unit's raw (non-unrolled) 33-bit PTS, via [`push_frame`]; call
95/// [`finalize`] at end of stream to close any still-open cue.
96///
97/// [`push_frame`]: Cea608CueExtractor::push_frame
98/// [`finalize`]: Cea608CueExtractor::finalize
99///
100/// ```
101/// use timed_metadata::webvtt::Cea608CueExtractor;
102/// use cc_data::{CcTriplet, CcType};
103/// use cc_data::decode::Cea608Channel;
104///
105/// fn pair(pts: u64, b1: u8, b2: u8) -> (u64, CcTriplet) {
106///     (
107///         pts,
108///         CcTriplet { cc_valid: true, cc_type: CcType::Ntsc608Field1, cc_data_1: b1, cc_data_2: b2 },
109///     )
110/// }
111///
112/// let mut ex = Cea608CueExtractor::new(Cea608Channel::Cc1);
113/// let frames = [
114///     pair(0, 0x14, 0x20),       // RCL
115///     pair(1, 0x14, 0x70),       // PAC row 15
116///     pair(2, b'H', b'I'),       // "HI"
117///     pair(3, 0x14, 0x2F),       // EOC -> commits "HI"
118///     pair(4, 0x14, 0x2C),       // EDM -> erases
119/// ];
120/// for (pts, t) in frames {
121///     ex.push_frame(pts, core::slice::from_ref(&t));
122/// }
123/// let cues = ex.cues();
124/// assert_eq!(cues.len(), 1);
125/// assert_eq!(cues[0].text, "HI");
126/// ```
127#[cfg(feature = "cc-data")]
128#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
129pub struct Cea608CueExtractor {
130    decoder: cc_data::decode::Cea608Decoder,
131    channel: cc_data::decode::Cea608Channel,
132    state: DiffState,
133    cues: Vec<Cue>,
134}
135
136#[cfg(feature = "cc-data")]
137#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
138impl Cea608CueExtractor {
139    /// A new extractor tracking the given CEA-608 data channel (e.g. `Cc1`).
140    #[must_use]
141    pub fn new(channel: cc_data::decode::Cea608Channel) -> Self {
142        Cea608CueExtractor {
143            decoder: cc_data::decode::Cea608Decoder::new(),
144            channel,
145            state: DiffState::new(),
146            cues: Vec::new(),
147        }
148    }
149
150    /// Feed one access unit's CEA-608 triplets (already demuxed from its
151    /// `cc_data()`), tagged with that access unit's raw 33-bit PTS. Non-608
152    /// triplets in `triplets` are ignored.
153    pub fn push_frame(&mut self, pts33: u64, triplets: &[cc_data::CcTriplet]) {
154        let ticks = self.state.unroll(pts33);
155        self.decoder
156            .push_triplets(triplets.iter().filter(|t| t.cc_type.is_cea608()));
157        let text = self.decoder.channel_text(self.channel);
158        self.state.observe(ticks, text, &mut self.cues);
159    }
160
161    /// Close any still-open cue at end of stream, at `end_pts33`.
162    pub fn finalize(&mut self, end_pts33: u64) {
163        let ticks = self.state.unroll(end_pts33);
164        self.state.finalize(ticks, &mut self.cues);
165    }
166
167    /// The cues extracted so far, in order.
168    #[must_use]
169    pub fn cues(&self) -> &[Cue] {
170        &self.cues
171    }
172
173    /// Consume the extractor, returning the extracted cues.
174    #[must_use]
175    pub fn into_cues(self) -> Vec<Cue> {
176        self.cues
177    }
178}
179
180/// Extracts [`Cue`]s from a single CEA-708 (DTVCC) service (CTA-708-E),
181/// wrapping a `cc-data` [`cc_data::decode::Cea708Decoder`].
182///
183/// Reads [`cc_data::decode::Cea708Decoder::service_text`] for the configured
184/// service number (`1`-`6`; service 1 is the primary caption service) after
185/// each fed frame — see `crate::webvtt` module docs for the diff-based
186/// boundary design and its documented losses (styling, window geometry,
187/// cross-service merging).
188#[cfg(feature = "cc-data")]
189#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
190pub struct Cea708CueExtractor {
191    decoder: cc_data::decode::Cea708Decoder,
192    service_number: usize,
193    state: DiffState,
194    cues: Vec<Cue>,
195}
196
197#[cfg(feature = "cc-data")]
198#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
199impl Cea708CueExtractor {
200    /// A new extractor tracking the given CEA-708 service number (`1`-`6`).
201    #[must_use]
202    pub fn new(service_number: usize) -> Self {
203        Cea708CueExtractor {
204            decoder: cc_data::decode::Cea708Decoder::new(),
205            service_number,
206            state: DiffState::new(),
207            cues: Vec::new(),
208        }
209    }
210
211    /// Feed one access unit's CEA-708 triplets (already demuxed from its
212    /// `cc_data()`), tagged with that access unit's raw 33-bit PTS. Non-708
213    /// triplets in `triplets` are ignored.
214    pub fn push_frame(&mut self, pts33: u64, triplets: &[cc_data::CcTriplet]) {
215        let ticks = self.state.unroll(pts33);
216        self.decoder
217            .push_triplets(triplets.iter().filter(|t| t.cc_type.is_cea708()));
218        let text = self.decoder.service_text(self.service_number);
219        self.state.observe(ticks, text, &mut self.cues);
220    }
221
222    /// Close any still-open cue at end of stream, at `end_pts33`.
223    pub fn finalize(&mut self, end_pts33: u64) {
224        let ticks = self.state.unroll(end_pts33);
225        self.state.finalize(ticks, &mut self.cues);
226    }
227
228    /// The cues extracted so far, in order.
229    #[must_use]
230    pub fn cues(&self) -> &[Cue] {
231        &self.cues
232    }
233
234    /// Consume the extractor, returning the extracted cues.
235    #[must_use]
236    pub fn into_cues(self) -> Vec<Cue> {
237        self.cues
238    }
239}
240
241/// Extracts [`Cue`]s from an EBU Teletext (ETSI EN 300 706) subtitle page,
242/// feature `teletext`, layered on `dvb-vbi`'s carriage-only
243/// [`dvb_vbi::TeletextDataField`] (ETSI EN 301 775 §4.5).
244///
245/// `dvb-vbi` deliberately does not decode EN 300 706 (a large, separate spec
246/// covering FEC, character sets, and page composition — not carriage; see its
247/// own module docs). This crate owns that decode
248/// (`crate::webvtt::teletext`): Hamming-8/4 + odd-parity FEC, the English
249/// Latin G0 national option table, and basic Level-1 page composition
250/// (header + rows 1-24). See `crate::webvtt::teletext` module docs for the
251/// full list of documented losses (no enhancement packets, no styling, no
252/// sub-code-based multi-page selection, only the English national option's
253/// character substitutions).
254///
255/// Tracks a single `(magazine, page)` — the caller supplies these (typically
256/// known from the carrying service's SI, e.g. a DVB VBI/teletext descriptor's
257/// page association). Feed every [`dvb_vbi::TeletextDataField`] carried by
258/// one access unit at a time via [`push_frame`], tagged with that access
259/// unit's raw (non-unrolled) 33-bit PTS; call [`finalize`] at end of stream.
260///
261/// Cue boundaries are derived the same way as the CEA extractors (see
262/// `crate::webvtt` module docs): a diff on the currently-displayed text
263/// (rows 1-24, non-empty, joined with `\n`) after each fed frame. A page's
264/// C4 (erase page) control bit clears the row buffer, which — combined with
265/// the diff — naturally produces a cue boundary when a subtitle disappears.
266///
267/// [`push_frame`]: TeletextCueExtractor::push_frame
268/// [`finalize`]: TeletextCueExtractor::finalize
269///
270/// ```
271/// use timed_metadata::webvtt::TeletextCueExtractor;
272/// use dvb_vbi::{TeletextDataField, LineHeader, FRAMING_CODE_EBU};
273///
274/// // Build one page-header packet (magazine 8, page 0x88, subtitle+erase
275/// // set) and one row-1 packet ("HI"), using the crate's own verified
276/// // Hamming-8/4 / odd-parity encoders (see `webvtt::teletext` tests).
277/// # fn hamming(n: u8) -> u8 {
278/// #     let (d1,d2,d3,d4) = (n&1,(n>>1)&1,(n>>2)&1,(n>>3)&1);
279/// #     let p1=1^d1^d3^d4; let p2=1^d1^d2^d4; let p3=1^d1^d2^d3;
280/// #     let p4=1^p1^d1^p2^d2^p3^d3^d4;
281/// #     p1|(d1<<1)|(p2<<2)|(d2<<3)|(p3<<4)|(d3<<5)|(p4<<6)|(d4<<7)
282/// # }
283/// # fn parity(d7: u8) -> u8 { let d=d7&0x7F; if d.count_ones()%2==0 {d|0x80} else {d} }
284/// let mut header = [0u8; 42];
285/// header[0] = hamming(0); // magazine field 0 -> magazine 8, row 0
286/// header[1] = hamming(0);
287/// header[2] = hamming(8); // page units
288/// header[3] = hamming(8); // page tens -> page 0x88
289/// header[5] = hamming(0b1000); // C4 erase_page
290/// header[7] = hamming(0b1000); // C6 subtitle
291/// for b in header.iter_mut().skip(10) { *b = parity(0x20); }
292///
293/// let mut row1 = [0u8; 42];
294/// row1[0] = hamming(0 | (1 << 3)); // magazine 8, Y bit0 = 1 (row 1)
295/// row1[1] = hamming(0);            // Y bits 1..4 = 0
296/// row1[2] = parity(b'H');
297/// row1[3] = parity(b'I');
298/// for b in row1.iter_mut().skip(4) { *b = parity(0x20); }
299///
300/// let field = |block| TeletextDataField {
301///     header: LineHeader::new(true, 0),
302///     framing_code: FRAMING_CODE_EBU,
303///     txt_data_block: block,
304/// };
305///
306/// let mut ex = TeletextCueExtractor::new(8, 0x88);
307/// ex.push_frame(0, &[field(header)]);
308/// ex.push_frame(1, &[field(row1)]);
309/// ex.finalize(2);
310/// assert_eq!(ex.cues().len(), 1);
311/// assert_eq!(ex.cues()[0].text, "HI");
312/// ```
313#[cfg(feature = "teletext")]
314#[cfg_attr(docsrs, doc(cfg(feature = "teletext")))]
315pub struct TeletextCueExtractor {
316    assembler: crate::webvtt::teletext::PageAssembler,
317    state: DiffState,
318    cues: Vec<Cue>,
319}
320
321#[cfg(feature = "teletext")]
322#[cfg_attr(docsrs, doc(cfg(feature = "teletext")))]
323impl TeletextCueExtractor {
324    /// A new extractor tracking the given `(magazine, page)` (magazine
325    /// `1..=8`; page `Pt << 4 | Pu`, e.g. `0x88` for the common "page 888"
326    /// subtitle convention).
327    #[must_use]
328    pub fn new(magazine: u8, page: u8) -> Self {
329        TeletextCueExtractor {
330            assembler: crate::webvtt::teletext::PageAssembler::new(magazine, page),
331            state: DiffState::new(),
332            cues: Vec::new(),
333        }
334    }
335
336    /// Feed every [`dvb_vbi::TeletextDataField`] carried by one access unit,
337    /// tagged with that access unit's raw 33-bit PTS. Fields for other
338    /// magazines/pages are ignored.
339    pub fn push_frame(&mut self, pts33: u64, fields: &[dvb_vbi::TeletextDataField]) {
340        let ticks = self.state.unroll(pts33);
341        for field in fields {
342            self.assembler.push(field);
343        }
344        let text = self.assembler.display_text();
345        self.state.observe(ticks, text, &mut self.cues);
346    }
347
348    /// Close any still-open cue at end of stream, at `end_pts33`.
349    pub fn finalize(&mut self, end_pts33: u64) {
350        let ticks = self.state.unroll(end_pts33);
351        self.state.finalize(ticks, &mut self.cues);
352    }
353
354    /// The cues extracted so far, in order.
355    #[must_use]
356    pub fn cues(&self) -> &[Cue] {
357        &self.cues
358    }
359
360    /// Consume the extractor, returning the extracted cues.
361    #[must_use]
362    pub fn into_cues(self) -> Vec<Cue> {
363        self.cues
364    }
365}
366
367#[cfg(all(test, feature = "cc-data"))]
368mod tests {
369    use super::*;
370    use cc_data::{CcTriplet, CcType};
371
372    fn t608(b1: u8, b2: u8) -> CcTriplet {
373        CcTriplet {
374            cc_valid: true,
375            cc_type: CcType::Ntsc608Field1,
376            cc_data_1: b1,
377            cc_data_2: b2,
378        }
379    }
380
381    /// Pop-on: RCL/PAC/chars produce no cue until EOC; EDM closes it.
382    #[test]
383    fn pop_on_boundary_is_eoc_and_edm() {
384        let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
385        ex.push_frame(0, &[t608(0x14, 0x20)]); // RCL
386        ex.push_frame(1, &[t608(0x14, 0x70)]); // PAC row 15
387        assert!(ex.cues().is_empty(), "composing must not yet emit a cue");
388        ex.push_frame(2, &[t608(b'H', b'I')]);
389        assert!(
390            ex.cues().is_empty(),
391            "non-displayed writes must not emit a cue"
392        );
393        ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC -> opens a cue (not yet closed)
394        assert!(
395            ex.cues().is_empty(),
396            "the cue is open, not yet closed by an erase/replace"
397        );
398        ex.push_frame(4, &[t608(0x14, 0x2C)]); // EDM -> closes it
399        assert_eq!(ex.cues().len(), 1);
400        assert_eq!(ex.cues()[0].start, MediaTime(3));
401        assert_eq!(ex.cues()[0].end, MediaTime(4));
402        assert_eq!(ex.cues()[0].text, "HI");
403    }
404
405    /// An unclosed cue is closed by `finalize` at end of stream.
406    #[test]
407    fn finalize_closes_open_cue() {
408        let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
409        ex.push_frame(0, &[t608(0x14, 0x20)]);
410        ex.push_frame(1, &[t608(0x14, 0x70)]);
411        ex.push_frame(2, &[t608(b'O', b'K')]);
412        ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC
413        assert!(ex.cues().is_empty());
414        ex.finalize(10);
415        assert_eq!(ex.cues().len(), 1);
416        assert_eq!(ex.cues()[0].start, MediaTime(3));
417        assert_eq!(ex.cues()[0].end, MediaTime(10));
418        assert_eq!(ex.cues()[0].text, "OK");
419    }
420
421    /// 708 extractor: DefineWindow + text + ToggleWindows(visible) commits a
422    /// cue; a subsequent hide closes it. Worked-example CCP bytes adapted
423    /// from `cc-data`'s own `Cea708Decoder` doctest.
424    #[test]
425    fn cea708_service1_basic_window() {
426        let mut ex = Cea708CueExtractor::new(1);
427        // DefineWindow window 0, then draw via a raw packet push through the
428        // extractor's triplet path (start + data triplets forming one CCP).
429        // header 0x05 (seq=0,size=5*2-1=9? use decoder's own doctest packet).
430        let packet: [u8; 9] = [0x05, 0x27, 0x9A, 0x38, 0x4A, 0xD1, 0x8B, 0x0F, 0x11];
431        let t0 = CcTriplet {
432            cc_valid: true,
433            cc_type: CcType::Dtvcc708Start,
434            cc_data_1: packet[0],
435            cc_data_2: packet[1],
436        };
437        let rest: alloc::vec::Vec<CcTriplet> = packet[2..]
438            .chunks(2)
439            .map(|c| CcTriplet {
440                cc_valid: true,
441                cc_type: CcType::Dtvcc708Data,
442                cc_data_1: c[0],
443                cc_data_2: *c.get(1).unwrap_or(&0),
444            })
445            .collect();
446        let mut triplets = alloc::vec![t0];
447        triplets.extend(rest);
448        ex.push_frame(0, &triplets);
449        // This packet only defines window 2 on service 1 (from cc-data's own
450        // doctest) -- it does not make it visible or paint text, so no cue
451        // should be produced yet (documents that DefineWindow alone doesn't
452        // commit a cue).
453        assert!(ex.cues().is_empty());
454    }
455}