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(feature = "cc-data")]
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 both the 608 and 708
30/// extractors: unrolls the 33-bit PTS and turns "displayed text changed"
31/// transitions into completed [`Cue`]s.
32#[cfg(feature = "cc-data")]
33struct DiffState {
34    last_pts: Option<u64>,
35    epoch: u64,
36    open: Option<(u64, String)>,
37}
38
39#[cfg(feature = "cc-data")]
40impl DiffState {
41    fn new() -> Self {
42        DiffState {
43            last_pts: None,
44            epoch: 0,
45            open: None,
46        }
47    }
48
49    fn unroll(&mut self, pts33: u64) -> u64 {
50        crate::timeline::unroll_pts(&mut self.last_pts, &mut self.epoch, pts33)
51    }
52
53    /// Observe the decoded text at `ticks`; push a completed cue into `cues`
54    /// when the text differs from the currently open cue (or, if none is
55    /// open, when the new text is non-empty).
56    fn observe(&mut self, ticks: u64, text: String, cues: &mut Vec<Cue>) {
57        let changed = match &self.open {
58            Some((_, cur)) => *cur != text,
59            None => !text.is_empty(),
60        };
61        if !changed {
62            return;
63        }
64        if let Some((start, prev)) = self.open.take() {
65            if !prev.is_empty() {
66                cues.push(Cue {
67                    start: MediaTime(start),
68                    end: MediaTime(ticks),
69                    text: prev,
70                });
71            }
72        }
73        if !text.is_empty() {
74            self.open = Some((ticks, text));
75        }
76    }
77
78    /// Close any still-open cue at end of stream (or a channel/service reset).
79    fn finalize(&mut self, ticks: u64, cues: &mut Vec<Cue>) {
80        if let Some((start, prev)) = self.open.take() {
81            if !prev.is_empty() {
82                cues.push(Cue {
83                    start: MediaTime(start),
84                    end: MediaTime(ticks),
85                    text: prev,
86                });
87            }
88        }
89    }
90}
91
92/// Extracts [`Cue`]s from a single CEA-608 data channel (CTA-608-E),
93/// wrapping a `cc-data` [`cc_data::decode::Cea608Decoder`].
94///
95/// Feed it one access unit's CEA-608 triplets at a time, tagged with that
96/// access unit's raw (non-unrolled) 33-bit PTS, via [`push_frame`]; call
97/// [`finalize`] at end of stream to close any still-open cue.
98///
99/// [`push_frame`]: Cea608CueExtractor::push_frame
100/// [`finalize`]: Cea608CueExtractor::finalize
101///
102/// ```
103/// use timed_metadata::webvtt::Cea608CueExtractor;
104/// use cc_data::{CcTriplet, CcType};
105/// use cc_data::decode::Cea608Channel;
106///
107/// fn pair(pts: u64, b1: u8, b2: u8) -> (u64, CcTriplet) {
108///     (
109///         pts,
110///         CcTriplet { cc_valid: true, cc_type: CcType::Ntsc608Field1, cc_data_1: b1, cc_data_2: b2 },
111///     )
112/// }
113///
114/// let mut ex = Cea608CueExtractor::new(Cea608Channel::Cc1);
115/// let frames = [
116///     pair(0, 0x14, 0x20),       // RCL
117///     pair(1, 0x14, 0x70),       // PAC row 15
118///     pair(2, b'H', b'I'),       // "HI"
119///     pair(3, 0x14, 0x2F),       // EOC -> commits "HI"
120///     pair(4, 0x14, 0x2C),       // EDM -> erases
121/// ];
122/// for (pts, t) in frames {
123///     ex.push_frame(pts, core::slice::from_ref(&t));
124/// }
125/// let cues = ex.cues();
126/// assert_eq!(cues.len(), 1);
127/// assert_eq!(cues[0].text, "HI");
128/// ```
129#[cfg(feature = "cc-data")]
130#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
131pub struct Cea608CueExtractor {
132    decoder: cc_data::decode::Cea608Decoder,
133    channel: cc_data::decode::Cea608Channel,
134    state: DiffState,
135    cues: Vec<Cue>,
136}
137
138#[cfg(feature = "cc-data")]
139#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
140impl Cea608CueExtractor {
141    /// A new extractor tracking the given CEA-608 data channel (e.g. `Cc1`).
142    #[must_use]
143    pub fn new(channel: cc_data::decode::Cea608Channel) -> Self {
144        Cea608CueExtractor {
145            decoder: cc_data::decode::Cea608Decoder::new(),
146            channel,
147            state: DiffState::new(),
148            cues: Vec::new(),
149        }
150    }
151
152    /// Feed one access unit's CEA-608 triplets (already demuxed from its
153    /// `cc_data()`), tagged with that access unit's raw 33-bit PTS. Non-608
154    /// triplets in `triplets` are ignored.
155    pub fn push_frame(&mut self, pts33: u64, triplets: &[cc_data::CcTriplet]) {
156        let ticks = self.state.unroll(pts33);
157        self.decoder
158            .push_triplets(triplets.iter().filter(|t| t.cc_type.is_cea608()));
159        let text = self.decoder.channel_text(self.channel);
160        self.state.observe(ticks, text, &mut self.cues);
161    }
162
163    /// Close any still-open cue at end of stream, at `end_pts33`.
164    pub fn finalize(&mut self, end_pts33: u64) {
165        let ticks = self.state.unroll(end_pts33);
166        self.state.finalize(ticks, &mut self.cues);
167    }
168
169    /// The cues extracted so far, in order.
170    #[must_use]
171    pub fn cues(&self) -> &[Cue] {
172        &self.cues
173    }
174
175    /// Consume the extractor, returning the extracted cues.
176    #[must_use]
177    pub fn into_cues(self) -> Vec<Cue> {
178        self.cues
179    }
180}
181
182/// Extracts [`Cue`]s from a single CEA-708 (DTVCC) service (CTA-708-E),
183/// wrapping a `cc-data` [`cc_data::decode::Cea708Decoder`].
184///
185/// Reads [`cc_data::decode::Cea708Decoder::service_text`] for the configured
186/// service number (`1`-`6`; service 1 is the primary caption service) after
187/// each fed frame — see `crate::webvtt` module docs for the diff-based
188/// boundary design and its documented losses (styling, window geometry,
189/// cross-service merging).
190#[cfg(feature = "cc-data")]
191#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
192pub struct Cea708CueExtractor {
193    decoder: cc_data::decode::Cea708Decoder,
194    service_number: usize,
195    state: DiffState,
196    cues: Vec<Cue>,
197}
198
199#[cfg(feature = "cc-data")]
200#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
201impl Cea708CueExtractor {
202    /// A new extractor tracking the given CEA-708 service number (`1`-`6`).
203    #[must_use]
204    pub fn new(service_number: usize) -> Self {
205        Cea708CueExtractor {
206            decoder: cc_data::decode::Cea708Decoder::new(),
207            service_number,
208            state: DiffState::new(),
209            cues: Vec::new(),
210        }
211    }
212
213    /// Feed one access unit's CEA-708 triplets (already demuxed from its
214    /// `cc_data()`), tagged with that access unit's raw 33-bit PTS. Non-708
215    /// triplets in `triplets` are ignored.
216    pub fn push_frame(&mut self, pts33: u64, triplets: &[cc_data::CcTriplet]) {
217        let ticks = self.state.unroll(pts33);
218        self.decoder
219            .push_triplets(triplets.iter().filter(|t| t.cc_type.is_cea708()));
220        let text = self.decoder.service_text(self.service_number);
221        self.state.observe(ticks, text, &mut self.cues);
222    }
223
224    /// Close any still-open cue at end of stream, at `end_pts33`.
225    pub fn finalize(&mut self, end_pts33: u64) {
226        let ticks = self.state.unroll(end_pts33);
227        self.state.finalize(ticks, &mut self.cues);
228    }
229
230    /// The cues extracted so far, in order.
231    #[must_use]
232    pub fn cues(&self) -> &[Cue] {
233        &self.cues
234    }
235
236    /// Consume the extractor, returning the extracted cues.
237    #[must_use]
238    pub fn into_cues(self) -> Vec<Cue> {
239        self.cues
240    }
241}
242
243#[cfg(all(test, feature = "cc-data"))]
244mod tests {
245    use super::*;
246    use cc_data::{CcTriplet, CcType};
247
248    fn t608(b1: u8, b2: u8) -> CcTriplet {
249        CcTriplet {
250            cc_valid: true,
251            cc_type: CcType::Ntsc608Field1,
252            cc_data_1: b1,
253            cc_data_2: b2,
254        }
255    }
256
257    /// Pop-on: RCL/PAC/chars produce no cue until EOC; EDM closes it.
258    #[test]
259    fn pop_on_boundary_is_eoc_and_edm() {
260        let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
261        ex.push_frame(0, &[t608(0x14, 0x20)]); // RCL
262        ex.push_frame(1, &[t608(0x14, 0x70)]); // PAC row 15
263        assert!(ex.cues().is_empty(), "composing must not yet emit a cue");
264        ex.push_frame(2, &[t608(b'H', b'I')]);
265        assert!(
266            ex.cues().is_empty(),
267            "non-displayed writes must not emit a cue"
268        );
269        ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC -> opens a cue (not yet closed)
270        assert!(
271            ex.cues().is_empty(),
272            "the cue is open, not yet closed by an erase/replace"
273        );
274        ex.push_frame(4, &[t608(0x14, 0x2C)]); // EDM -> closes it
275        assert_eq!(ex.cues().len(), 1);
276        assert_eq!(ex.cues()[0].start, MediaTime(3));
277        assert_eq!(ex.cues()[0].end, MediaTime(4));
278        assert_eq!(ex.cues()[0].text, "HI");
279    }
280
281    /// An unclosed cue is closed by `finalize` at end of stream.
282    #[test]
283    fn finalize_closes_open_cue() {
284        let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
285        ex.push_frame(0, &[t608(0x14, 0x20)]);
286        ex.push_frame(1, &[t608(0x14, 0x70)]);
287        ex.push_frame(2, &[t608(b'O', b'K')]);
288        ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC
289        assert!(ex.cues().is_empty());
290        ex.finalize(10);
291        assert_eq!(ex.cues().len(), 1);
292        assert_eq!(ex.cues()[0].start, MediaTime(3));
293        assert_eq!(ex.cues()[0].end, MediaTime(10));
294        assert_eq!(ex.cues()[0].text, "OK");
295    }
296
297    /// 708 extractor: DefineWindow + text + ToggleWindows(visible) commits a
298    /// cue; a subsequent hide closes it. Worked-example CCP bytes adapted
299    /// from `cc-data`'s own `Cea708Decoder` doctest.
300    #[test]
301    fn cea708_service1_basic_window() {
302        let mut ex = Cea708CueExtractor::new(1);
303        // DefineWindow window 0, then draw via a raw packet push through the
304        // extractor's triplet path (start + data triplets forming one CCP).
305        // header 0x05 (seq=0,size=5*2-1=9? use decoder's own doctest packet).
306        let packet: [u8; 9] = [0x05, 0x27, 0x9A, 0x38, 0x4A, 0xD1, 0x8B, 0x0F, 0x11];
307        let t0 = CcTriplet {
308            cc_valid: true,
309            cc_type: CcType::Dtvcc708Start,
310            cc_data_1: packet[0],
311            cc_data_2: packet[1],
312        };
313        let rest: alloc::vec::Vec<CcTriplet> = packet[2..]
314            .chunks(2)
315            .map(|c| CcTriplet {
316                cc_valid: true,
317                cc_type: CcType::Dtvcc708Data,
318                cc_data_1: c[0],
319                cc_data_2: *c.get(1).unwrap_or(&0),
320            })
321            .collect();
322        let mut triplets = alloc::vec![t0];
323        triplets.extend(rest);
324        ex.push_frame(0, &triplets);
325        // This packet only defines window 2 on service 1 (from cc-data's own
326        // doctest) -- it does not make it visible or paint text, so no cue
327        // should be produced yet (documents that DefineWindow alone doesn't
328        // commit a cue).
329        assert!(ex.cues().is_empty());
330    }
331}