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 last_pts: Option<u64>,
35 epoch: u64,
36 open: Option<(u64, String)>,
37}
38
39#[cfg(any(feature = "cc-data", feature = "teletext"))]
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/// Extracts [`Cue`]s from an EBU Teletext (ETSI EN 300 706) subtitle page,
244/// feature `teletext`, layered on `dvb-vbi`'s carriage-only
245/// [`dvb_vbi::TeletextDataField`] (ETSI EN 301 775 §4.5).
246///
247/// `dvb-vbi` deliberately does not decode EN 300 706 (a large, separate spec
248/// covering FEC, character sets, and page composition — not carriage; see its
249/// own module docs). This crate owns that decode
250/// (`crate::webvtt::teletext`): Hamming-8/4 + odd-parity FEC, the English
251/// Latin G0 national option table, and basic Level-1 page composition
252/// (header + rows 1-24). See `crate::webvtt::teletext` module docs for the
253/// full list of documented losses (no enhancement packets, no styling, no
254/// sub-code-based multi-page selection, only the English national option's
255/// character substitutions).
256///
257/// Tracks a single `(magazine, page)` — the caller supplies these (typically
258/// known from the carrying service's SI, e.g. a DVB VBI/teletext descriptor's
259/// page association). Feed every [`dvb_vbi::TeletextDataField`] carried by
260/// one access unit at a time via [`push_frame`], tagged with that access
261/// unit's raw (non-unrolled) 33-bit PTS; call [`finalize`] at end of stream.
262///
263/// Cue boundaries are derived the same way as the CEA extractors (see
264/// `crate::webvtt` module docs): a diff on the currently-displayed text
265/// (rows 1-24, non-empty, joined with `\n`) after each fed frame. A page's
266/// C4 (erase page) control bit clears the row buffer, which — combined with
267/// the diff — naturally produces a cue boundary when a subtitle disappears.
268///
269/// [`push_frame`]: TeletextCueExtractor::push_frame
270/// [`finalize`]: TeletextCueExtractor::finalize
271///
272/// ```
273/// use timed_metadata::webvtt::TeletextCueExtractor;
274/// use dvb_vbi::{TeletextDataField, LineHeader, FRAMING_CODE_EBU};
275///
276/// // Build one page-header packet (magazine 8, page 0x88, subtitle+erase
277/// // set) and one row-1 packet ("HI"), using the crate's own verified
278/// // Hamming-8/4 / odd-parity encoders (see `webvtt::teletext` tests).
279/// # fn hamming(n: u8) -> u8 {
280/// # let (d1,d2,d3,d4) = (n&1,(n>>1)&1,(n>>2)&1,(n>>3)&1);
281/// # let p1=1^d1^d3^d4; let p2=1^d1^d2^d4; let p3=1^d1^d2^d3;
282/// # let p4=1^p1^d1^p2^d2^p3^d3^d4;
283/// # p1|(d1<<1)|(p2<<2)|(d2<<3)|(p3<<4)|(d3<<5)|(p4<<6)|(d4<<7)
284/// # }
285/// # fn parity(d7: u8) -> u8 { let d=d7&0x7F; if d.count_ones()%2==0 {d|0x80} else {d} }
286/// let mut header = [0u8; 42];
287/// header[0] = hamming(0); // magazine field 0 -> magazine 8, row 0
288/// header[1] = hamming(0);
289/// header[2] = hamming(8); // page units
290/// header[3] = hamming(8); // page tens -> page 0x88
291/// header[5] = hamming(0b1000); // C4 erase_page
292/// header[7] = hamming(0b1000); // C6 subtitle
293/// for b in header.iter_mut().skip(10) { *b = parity(0x20); }
294///
295/// let mut row1 = [0u8; 42];
296/// row1[0] = hamming(0 | (1 << 3)); // magazine 8, Y bit0 = 1 (row 1)
297/// row1[1] = hamming(0); // Y bits 1..4 = 0
298/// row1[2] = parity(b'H');
299/// row1[3] = parity(b'I');
300/// for b in row1.iter_mut().skip(4) { *b = parity(0x20); }
301///
302/// let field = |block| TeletextDataField {
303/// header: LineHeader::new(true, 0),
304/// framing_code: FRAMING_CODE_EBU,
305/// txt_data_block: block,
306/// };
307///
308/// let mut ex = TeletextCueExtractor::new(8, 0x88);
309/// ex.push_frame(0, &[field(header)]);
310/// ex.push_frame(1, &[field(row1)]);
311/// ex.finalize(2);
312/// assert_eq!(ex.cues().len(), 1);
313/// assert_eq!(ex.cues()[0].text, "HI");
314/// ```
315#[cfg(feature = "teletext")]
316#[cfg_attr(docsrs, doc(cfg(feature = "teletext")))]
317pub struct TeletextCueExtractor {
318 assembler: crate::webvtt::teletext::PageAssembler,
319 state: DiffState,
320 cues: Vec<Cue>,
321}
322
323#[cfg(feature = "teletext")]
324#[cfg_attr(docsrs, doc(cfg(feature = "teletext")))]
325impl TeletextCueExtractor {
326 /// A new extractor tracking the given `(magazine, page)` (magazine
327 /// `1..=8`; page `Pt << 4 | Pu`, e.g. `0x88` for the common "page 888"
328 /// subtitle convention).
329 #[must_use]
330 pub fn new(magazine: u8, page: u8) -> Self {
331 TeletextCueExtractor {
332 assembler: crate::webvtt::teletext::PageAssembler::new(magazine, page),
333 state: DiffState::new(),
334 cues: Vec::new(),
335 }
336 }
337
338 /// Feed every [`dvb_vbi::TeletextDataField`] carried by one access unit,
339 /// tagged with that access unit's raw 33-bit PTS. Fields for other
340 /// magazines/pages are ignored.
341 pub fn push_frame(&mut self, pts33: u64, fields: &[dvb_vbi::TeletextDataField]) {
342 let ticks = self.state.unroll(pts33);
343 for field in fields {
344 self.assembler.push(field);
345 }
346 let text = self.assembler.display_text();
347 self.state.observe(ticks, text, &mut self.cues);
348 }
349
350 /// Close any still-open cue at end of stream, at `end_pts33`.
351 pub fn finalize(&mut self, end_pts33: u64) {
352 let ticks = self.state.unroll(end_pts33);
353 self.state.finalize(ticks, &mut self.cues);
354 }
355
356 /// The cues extracted so far, in order.
357 #[must_use]
358 pub fn cues(&self) -> &[Cue] {
359 &self.cues
360 }
361
362 /// Consume the extractor, returning the extracted cues.
363 #[must_use]
364 pub fn into_cues(self) -> Vec<Cue> {
365 self.cues
366 }
367}
368
369#[cfg(all(test, feature = "cc-data"))]
370mod tests {
371 use super::*;
372 use cc_data::{CcTriplet, CcType};
373
374 fn t608(b1: u8, b2: u8) -> CcTriplet {
375 CcTriplet {
376 cc_valid: true,
377 cc_type: CcType::Ntsc608Field1,
378 cc_data_1: b1,
379 cc_data_2: b2,
380 }
381 }
382
383 /// Pop-on: RCL/PAC/chars produce no cue until EOC; EDM closes it.
384 #[test]
385 fn pop_on_boundary_is_eoc_and_edm() {
386 let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
387 ex.push_frame(0, &[t608(0x14, 0x20)]); // RCL
388 ex.push_frame(1, &[t608(0x14, 0x70)]); // PAC row 15
389 assert!(ex.cues().is_empty(), "composing must not yet emit a cue");
390 ex.push_frame(2, &[t608(b'H', b'I')]);
391 assert!(
392 ex.cues().is_empty(),
393 "non-displayed writes must not emit a cue"
394 );
395 ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC -> opens a cue (not yet closed)
396 assert!(
397 ex.cues().is_empty(),
398 "the cue is open, not yet closed by an erase/replace"
399 );
400 ex.push_frame(4, &[t608(0x14, 0x2C)]); // EDM -> closes it
401 assert_eq!(ex.cues().len(), 1);
402 assert_eq!(ex.cues()[0].start, MediaTime(3));
403 assert_eq!(ex.cues()[0].end, MediaTime(4));
404 assert_eq!(ex.cues()[0].text, "HI");
405 }
406
407 /// An unclosed cue is closed by `finalize` at end of stream.
408 #[test]
409 fn finalize_closes_open_cue() {
410 let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
411 ex.push_frame(0, &[t608(0x14, 0x20)]);
412 ex.push_frame(1, &[t608(0x14, 0x70)]);
413 ex.push_frame(2, &[t608(b'O', b'K')]);
414 ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC
415 assert!(ex.cues().is_empty());
416 ex.finalize(10);
417 assert_eq!(ex.cues().len(), 1);
418 assert_eq!(ex.cues()[0].start, MediaTime(3));
419 assert_eq!(ex.cues()[0].end, MediaTime(10));
420 assert_eq!(ex.cues()[0].text, "OK");
421 }
422
423 /// 708 extractor: DefineWindow + text + ToggleWindows(visible) commits a
424 /// cue; a subsequent hide closes it. Worked-example CCP bytes adapted
425 /// from `cc-data`'s own `Cea708Decoder` doctest.
426 #[test]
427 fn cea708_service1_basic_window() {
428 let mut ex = Cea708CueExtractor::new(1);
429 // DefineWindow window 0, then draw via a raw packet push through the
430 // extractor's triplet path (start + data triplets forming one CCP).
431 // header 0x05 (seq=0,size=5*2-1=9? use decoder's own doctest packet).
432 let packet: [u8; 9] = [0x05, 0x27, 0x9A, 0x38, 0x4A, 0xD1, 0x8B, 0x0F, 0x11];
433 let t0 = CcTriplet {
434 cc_valid: true,
435 cc_type: CcType::Dtvcc708Start,
436 cc_data_1: packet[0],
437 cc_data_2: packet[1],
438 };
439 let rest: alloc::vec::Vec<CcTriplet> = packet[2..]
440 .chunks(2)
441 .map(|c| CcTriplet {
442 cc_valid: true,
443 cc_type: CcType::Dtvcc708Data,
444 cc_data_1: c[0],
445 cc_data_2: *c.get(1).unwrap_or(&0),
446 })
447 .collect();
448 let mut triplets = alloc::vec![t0];
449 triplets.extend(rest);
450 ex.push_frame(0, &triplets);
451 // This packet only defines window 2 on service 1 (from cc-data's own
452 // doctest) -- it does not make it visible or paint text, so no cue
453 // should be produced yet (documents that DefineWindow alone doesn't
454 // commit a cue).
455 assert!(ex.cues().is_empty());
456 }
457}