timed-metadata 0.4.0

Convert DPI / timed-metadata signalling between SCTE-35, HLS EXT-X-DATERANGE and DASH emsg. no_std.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Extracted caption cue + the CEA-608/708 cue extractors.
//!
//! See `crate::webvtt` module docs for the diff-based boundary-detection
//! design and the documented losses.
use crate::event::MediaTime;
use alloc::string::String;
#[cfg(any(feature = "cc-data", feature = "teletext"))]
use alloc::vec::Vec;

/// A single extracted caption cue: display text plus its media-timeline span.
///
/// `start`/`end` are wrap-unrolled 90 kHz [`MediaTime`] instants (see
/// [`crate::timeline`]). `text` may contain embedded `\n` for multi-line
/// captions (e.g. a 2-row CEA-608 roll-up window); [`crate::webvtt::cue_block`]
/// emits each line of `text` as its own WebVTT payload line.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Cue {
    /// Cue start: the commit-event PTS (CEA-608 EOC / roll-up row reveal /
    /// CEA-708 window-visible transition) — see `crate::webvtt` module docs.
    pub start: MediaTime,
    /// Cue end: the PTS of the next erase/replace event.
    pub end: MediaTime,
    /// Cue display text: plain, unescaped, unstyled (see `crate::webvtt`
    /// module docs on documented losses).
    pub text: String,
}

/// Shared diff-based boundary tracker used by the 608, 708, and Teletext
/// extractors: unrolls the 33-bit PTS and turns "displayed text changed"
/// transitions into completed [`Cue`]s.
#[cfg(any(feature = "cc-data", feature = "teletext"))]
struct DiffState {
    last_pts: Option<u64>,
    epoch: u64,
    open: Option<(u64, String)>,
}

#[cfg(any(feature = "cc-data", feature = "teletext"))]
impl DiffState {
    fn new() -> Self {
        DiffState {
            last_pts: None,
            epoch: 0,
            open: None,
        }
    }

    fn unroll(&mut self, pts33: u64) -> u64 {
        crate::timeline::unroll_pts(&mut self.last_pts, &mut self.epoch, pts33)
    }

    /// Observe the decoded text at `ticks`; push a completed cue into `cues`
    /// when the text differs from the currently open cue (or, if none is
    /// open, when the new text is non-empty).
    fn observe(&mut self, ticks: u64, text: String, cues: &mut Vec<Cue>) {
        let changed = match &self.open {
            Some((_, cur)) => *cur != text,
            None => !text.is_empty(),
        };
        if !changed {
            return;
        }
        if let Some((start, prev)) = self.open.take() {
            if !prev.is_empty() {
                cues.push(Cue {
                    start: MediaTime(start),
                    end: MediaTime(ticks),
                    text: prev,
                });
            }
        }
        if !text.is_empty() {
            self.open = Some((ticks, text));
        }
    }

    /// Close any still-open cue at end of stream (or a channel/service reset).
    fn finalize(&mut self, ticks: u64, cues: &mut Vec<Cue>) {
        if let Some((start, prev)) = self.open.take() {
            if !prev.is_empty() {
                cues.push(Cue {
                    start: MediaTime(start),
                    end: MediaTime(ticks),
                    text: prev,
                });
            }
        }
    }
}

/// Extracts [`Cue`]s from a single CEA-608 data channel (CTA-608-E),
/// wrapping a `cc-data` [`cc_data::decode::Cea608Decoder`].
///
/// Feed it one access unit's CEA-608 triplets at a time, tagged with that
/// access unit's raw (non-unrolled) 33-bit PTS, via [`push_frame`]; call
/// [`finalize`] at end of stream to close any still-open cue.
///
/// [`push_frame`]: Cea608CueExtractor::push_frame
/// [`finalize`]: Cea608CueExtractor::finalize
///
/// ```
/// use timed_metadata::webvtt::Cea608CueExtractor;
/// use cc_data::{CcTriplet, CcType};
/// use cc_data::decode::Cea608Channel;
///
/// fn pair(pts: u64, b1: u8, b2: u8) -> (u64, CcTriplet) {
///     (
///         pts,
///         CcTriplet { cc_valid: true, cc_type: CcType::Ntsc608Field1, cc_data_1: b1, cc_data_2: b2 },
///     )
/// }
///
/// let mut ex = Cea608CueExtractor::new(Cea608Channel::Cc1);
/// let frames = [
///     pair(0, 0x14, 0x20),       // RCL
///     pair(1, 0x14, 0x70),       // PAC row 15
///     pair(2, b'H', b'I'),       // "HI"
///     pair(3, 0x14, 0x2F),       // EOC -> commits "HI"
///     pair(4, 0x14, 0x2C),       // EDM -> erases
/// ];
/// for (pts, t) in frames {
///     ex.push_frame(pts, core::slice::from_ref(&t));
/// }
/// let cues = ex.cues();
/// assert_eq!(cues.len(), 1);
/// assert_eq!(cues[0].text, "HI");
/// ```
#[cfg(feature = "cc-data")]
#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
pub struct Cea608CueExtractor {
    decoder: cc_data::decode::Cea608Decoder,
    channel: cc_data::decode::Cea608Channel,
    state: DiffState,
    cues: Vec<Cue>,
}

#[cfg(feature = "cc-data")]
#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
impl Cea608CueExtractor {
    /// A new extractor tracking the given CEA-608 data channel (e.g. `Cc1`).
    #[must_use]
    pub fn new(channel: cc_data::decode::Cea608Channel) -> Self {
        Cea608CueExtractor {
            decoder: cc_data::decode::Cea608Decoder::new(),
            channel,
            state: DiffState::new(),
            cues: Vec::new(),
        }
    }

    /// Feed one access unit's CEA-608 triplets (already demuxed from its
    /// `cc_data()`), tagged with that access unit's raw 33-bit PTS. Non-608
    /// triplets in `triplets` are ignored.
    pub fn push_frame(&mut self, pts33: u64, triplets: &[cc_data::CcTriplet]) {
        let ticks = self.state.unroll(pts33);
        self.decoder
            .push_triplets(triplets.iter().filter(|t| t.cc_type.is_cea608()));
        let text = self.decoder.channel_text(self.channel);
        self.state.observe(ticks, text, &mut self.cues);
    }

    /// Close any still-open cue at end of stream, at `end_pts33`.
    pub fn finalize(&mut self, end_pts33: u64) {
        let ticks = self.state.unroll(end_pts33);
        self.state.finalize(ticks, &mut self.cues);
    }

    /// The cues extracted so far, in order.
    #[must_use]
    pub fn cues(&self) -> &[Cue] {
        &self.cues
    }

    /// Consume the extractor, returning the extracted cues.
    #[must_use]
    pub fn into_cues(self) -> Vec<Cue> {
        self.cues
    }
}

/// Extracts [`Cue`]s from a single CEA-708 (DTVCC) service (CTA-708-E),
/// wrapping a `cc-data` [`cc_data::decode::Cea708Decoder`].
///
/// Reads [`cc_data::decode::Cea708Decoder::service_text`] for the configured
/// service number (`1`-`6`; service 1 is the primary caption service) after
/// each fed frame — see `crate::webvtt` module docs for the diff-based
/// boundary design and its documented losses (styling, window geometry,
/// cross-service merging).
#[cfg(feature = "cc-data")]
#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
pub struct Cea708CueExtractor {
    decoder: cc_data::decode::Cea708Decoder,
    service_number: usize,
    state: DiffState,
    cues: Vec<Cue>,
}

#[cfg(feature = "cc-data")]
#[cfg_attr(docsrs, doc(cfg(feature = "cc-data")))]
impl Cea708CueExtractor {
    /// A new extractor tracking the given CEA-708 service number (`1`-`6`).
    #[must_use]
    pub fn new(service_number: usize) -> Self {
        Cea708CueExtractor {
            decoder: cc_data::decode::Cea708Decoder::new(),
            service_number,
            state: DiffState::new(),
            cues: Vec::new(),
        }
    }

    /// Feed one access unit's CEA-708 triplets (already demuxed from its
    /// `cc_data()`), tagged with that access unit's raw 33-bit PTS. Non-708
    /// triplets in `triplets` are ignored.
    pub fn push_frame(&mut self, pts33: u64, triplets: &[cc_data::CcTriplet]) {
        let ticks = self.state.unroll(pts33);
        self.decoder
            .push_triplets(triplets.iter().filter(|t| t.cc_type.is_cea708()));
        let text = self.decoder.service_text(self.service_number);
        self.state.observe(ticks, text, &mut self.cues);
    }

    /// Close any still-open cue at end of stream, at `end_pts33`.
    pub fn finalize(&mut self, end_pts33: u64) {
        let ticks = self.state.unroll(end_pts33);
        self.state.finalize(ticks, &mut self.cues);
    }

    /// The cues extracted so far, in order.
    #[must_use]
    pub fn cues(&self) -> &[Cue] {
        &self.cues
    }

    /// Consume the extractor, returning the extracted cues.
    #[must_use]
    pub fn into_cues(self) -> Vec<Cue> {
        self.cues
    }
}

/// Extracts [`Cue`]s from an EBU Teletext (ETSI EN 300 706) subtitle page,
/// feature `teletext`, layered on `dvb-vbi`'s carriage-only
/// [`dvb_vbi::TeletextDataField`] (ETSI EN 301 775 §4.5).
///
/// `dvb-vbi` deliberately does not decode EN 300 706 (a large, separate spec
/// covering FEC, character sets, and page composition — not carriage; see its
/// own module docs). This crate owns that decode
/// (`crate::webvtt::teletext`): Hamming-8/4 + odd-parity FEC, the English
/// Latin G0 national option table, and basic Level-1 page composition
/// (header + rows 1-24). See `crate::webvtt::teletext` module docs for the
/// full list of documented losses (no enhancement packets, no styling, no
/// sub-code-based multi-page selection, only the English national option's
/// character substitutions).
///
/// Tracks a single `(magazine, page)` — the caller supplies these (typically
/// known from the carrying service's SI, e.g. a DVB VBI/teletext descriptor's
/// page association). Feed every [`dvb_vbi::TeletextDataField`] carried by
/// one access unit at a time via [`push_frame`], tagged with that access
/// unit's raw (non-unrolled) 33-bit PTS; call [`finalize`] at end of stream.
///
/// Cue boundaries are derived the same way as the CEA extractors (see
/// `crate::webvtt` module docs): a diff on the currently-displayed text
/// (rows 1-24, non-empty, joined with `\n`) after each fed frame. A page's
/// C4 (erase page) control bit clears the row buffer, which — combined with
/// the diff — naturally produces a cue boundary when a subtitle disappears.
///
/// [`push_frame`]: TeletextCueExtractor::push_frame
/// [`finalize`]: TeletextCueExtractor::finalize
///
/// ```
/// use timed_metadata::webvtt::TeletextCueExtractor;
/// use dvb_vbi::{TeletextDataField, LineHeader, FRAMING_CODE_EBU};
///
/// // Build one page-header packet (magazine 8, page 0x88, subtitle+erase
/// // set) and one row-1 packet ("HI"), using the crate's own verified
/// // Hamming-8/4 / odd-parity encoders (see `webvtt::teletext` tests).
/// # fn hamming(n: u8) -> u8 {
/// #     let (d1,d2,d3,d4) = (n&1,(n>>1)&1,(n>>2)&1,(n>>3)&1);
/// #     let p1=1^d1^d3^d4; let p2=1^d1^d2^d4; let p3=1^d1^d2^d3;
/// #     let p4=1^p1^d1^p2^d2^p3^d3^d4;
/// #     p1|(d1<<1)|(p2<<2)|(d2<<3)|(p3<<4)|(d3<<5)|(p4<<6)|(d4<<7)
/// # }
/// # fn parity(d7: u8) -> u8 { let d=d7&0x7F; if d.count_ones()%2==0 {d|0x80} else {d} }
/// let mut header = [0u8; 42];
/// header[0] = hamming(0); // magazine field 0 -> magazine 8, row 0
/// header[1] = hamming(0);
/// header[2] = hamming(8); // page units
/// header[3] = hamming(8); // page tens -> page 0x88
/// header[5] = hamming(0b1000); // C4 erase_page
/// header[7] = hamming(0b1000); // C6 subtitle
/// for b in header.iter_mut().skip(10) { *b = parity(0x20); }
///
/// let mut row1 = [0u8; 42];
/// row1[0] = hamming(0 | (1 << 3)); // magazine 8, Y bit0 = 1 (row 1)
/// row1[1] = hamming(0);            // Y bits 1..4 = 0
/// row1[2] = parity(b'H');
/// row1[3] = parity(b'I');
/// for b in row1.iter_mut().skip(4) { *b = parity(0x20); }
///
/// let field = |block| TeletextDataField {
///     header: LineHeader::new(true, 0),
///     framing_code: FRAMING_CODE_EBU,
///     txt_data_block: block,
/// };
///
/// let mut ex = TeletextCueExtractor::new(8, 0x88);
/// ex.push_frame(0, &[field(header)]);
/// ex.push_frame(1, &[field(row1)]);
/// ex.finalize(2);
/// assert_eq!(ex.cues().len(), 1);
/// assert_eq!(ex.cues()[0].text, "HI");
/// ```
#[cfg(feature = "teletext")]
#[cfg_attr(docsrs, doc(cfg(feature = "teletext")))]
pub struct TeletextCueExtractor {
    assembler: crate::webvtt::teletext::PageAssembler,
    state: DiffState,
    cues: Vec<Cue>,
}

#[cfg(feature = "teletext")]
#[cfg_attr(docsrs, doc(cfg(feature = "teletext")))]
impl TeletextCueExtractor {
    /// A new extractor tracking the given `(magazine, page)` (magazine
    /// `1..=8`; page `Pt << 4 | Pu`, e.g. `0x88` for the common "page 888"
    /// subtitle convention).
    #[must_use]
    pub fn new(magazine: u8, page: u8) -> Self {
        TeletextCueExtractor {
            assembler: crate::webvtt::teletext::PageAssembler::new(magazine, page),
            state: DiffState::new(),
            cues: Vec::new(),
        }
    }

    /// Feed every [`dvb_vbi::TeletextDataField`] carried by one access unit,
    /// tagged with that access unit's raw 33-bit PTS. Fields for other
    /// magazines/pages are ignored.
    pub fn push_frame(&mut self, pts33: u64, fields: &[dvb_vbi::TeletextDataField]) {
        let ticks = self.state.unroll(pts33);
        for field in fields {
            self.assembler.push(field);
        }
        let text = self.assembler.display_text();
        self.state.observe(ticks, text, &mut self.cues);
    }

    /// Close any still-open cue at end of stream, at `end_pts33`.
    pub fn finalize(&mut self, end_pts33: u64) {
        let ticks = self.state.unroll(end_pts33);
        self.state.finalize(ticks, &mut self.cues);
    }

    /// The cues extracted so far, in order.
    #[must_use]
    pub fn cues(&self) -> &[Cue] {
        &self.cues
    }

    /// Consume the extractor, returning the extracted cues.
    #[must_use]
    pub fn into_cues(self) -> Vec<Cue> {
        self.cues
    }
}

#[cfg(all(test, feature = "cc-data"))]
mod tests {
    use super::*;
    use cc_data::{CcTriplet, CcType};

    fn t608(b1: u8, b2: u8) -> CcTriplet {
        CcTriplet {
            cc_valid: true,
            cc_type: CcType::Ntsc608Field1,
            cc_data_1: b1,
            cc_data_2: b2,
        }
    }

    /// Pop-on: RCL/PAC/chars produce no cue until EOC; EDM closes it.
    #[test]
    fn pop_on_boundary_is_eoc_and_edm() {
        let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
        ex.push_frame(0, &[t608(0x14, 0x20)]); // RCL
        ex.push_frame(1, &[t608(0x14, 0x70)]); // PAC row 15
        assert!(ex.cues().is_empty(), "composing must not yet emit a cue");
        ex.push_frame(2, &[t608(b'H', b'I')]);
        assert!(
            ex.cues().is_empty(),
            "non-displayed writes must not emit a cue"
        );
        ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC -> opens a cue (not yet closed)
        assert!(
            ex.cues().is_empty(),
            "the cue is open, not yet closed by an erase/replace"
        );
        ex.push_frame(4, &[t608(0x14, 0x2C)]); // EDM -> closes it
        assert_eq!(ex.cues().len(), 1);
        assert_eq!(ex.cues()[0].start, MediaTime(3));
        assert_eq!(ex.cues()[0].end, MediaTime(4));
        assert_eq!(ex.cues()[0].text, "HI");
    }

    /// An unclosed cue is closed by `finalize` at end of stream.
    #[test]
    fn finalize_closes_open_cue() {
        let mut ex = Cea608CueExtractor::new(cc_data::decode::Cea608Channel::Cc1);
        ex.push_frame(0, &[t608(0x14, 0x20)]);
        ex.push_frame(1, &[t608(0x14, 0x70)]);
        ex.push_frame(2, &[t608(b'O', b'K')]);
        ex.push_frame(3, &[t608(0x14, 0x2F)]); // EOC
        assert!(ex.cues().is_empty());
        ex.finalize(10);
        assert_eq!(ex.cues().len(), 1);
        assert_eq!(ex.cues()[0].start, MediaTime(3));
        assert_eq!(ex.cues()[0].end, MediaTime(10));
        assert_eq!(ex.cues()[0].text, "OK");
    }

    /// 708 extractor: DefineWindow + text + ToggleWindows(visible) commits a
    /// cue; a subsequent hide closes it. Worked-example CCP bytes adapted
    /// from `cc-data`'s own `Cea708Decoder` doctest.
    #[test]
    fn cea708_service1_basic_window() {
        let mut ex = Cea708CueExtractor::new(1);
        // DefineWindow window 0, then draw via a raw packet push through the
        // extractor's triplet path (start + data triplets forming one CCP).
        // header 0x05 (seq=0,size=5*2-1=9? use decoder's own doctest packet).
        let packet: [u8; 9] = [0x05, 0x27, 0x9A, 0x38, 0x4A, 0xD1, 0x8B, 0x0F, 0x11];
        let t0 = CcTriplet {
            cc_valid: true,
            cc_type: CcType::Dtvcc708Start,
            cc_data_1: packet[0],
            cc_data_2: packet[1],
        };
        let rest: alloc::vec::Vec<CcTriplet> = packet[2..]
            .chunks(2)
            .map(|c| CcTriplet {
                cc_valid: true,
                cc_type: CcType::Dtvcc708Data,
                cc_data_1: c[0],
                cc_data_2: *c.get(1).unwrap_or(&0),
            })
            .collect();
        let mut triplets = alloc::vec![t0];
        triplets.extend(rest);
        ex.push_frame(0, &triplets);
        // This packet only defines window 2 on service 1 (from cc-data's own
        // doctest) -- it does not make it visible or paint text, so no cue
        // should be produced yet (documents that DefineWindow alone doesn't
        // commit a cue).
        assert!(ex.cues().is_empty());
    }
}