#![cfg(feature = "cc-data")]
use broadcast_common::{Parse, Unpackage};
use cc_data::CcData;
use cc_data::decode::Cea608Channel;
use std::fs;
use std::path::{Path, PathBuf};
use timed_metadata::event::MediaTime;
use timed_metadata::webvtt::{Cea608CueExtractor, write_document};
use transmux::{CodecConfig, NalCodec, TsDemux, caption_cc_data};
fn sei_varint(mut v: u32) -> Vec<u8> {
let mut out = Vec::new();
while v >= 0xFF {
out.push(0xFF);
v -= 0xFF;
}
out.push(v as u8);
out
}
fn wrap_a53_sei(cc_data: &[u8]) -> Vec<u8> {
const ITU_T_T35_COUNTRY_CODE_USA: u8 = 0xB5;
const ATSC_T35_PROVIDER_CODE: [u8; 2] = [0x00, 0x31];
const GA94: [u8; 4] = *b"GA94";
const ATSC_USER_DATA_TYPE_CODE_CC_DATA: u8 = 0x03;
const SEI_PAYLOAD_TYPE_USER_DATA_REGISTERED_ITU_T_T35: u32 = 4;
const AVC_NAL_HEADER_SEI: u8 = 0x06;
let mut nal = vec![0x00, 0x00, 0x01, AVC_NAL_HEADER_SEI];
nal.extend(sei_varint(SEI_PAYLOAD_TYPE_USER_DATA_REGISTERED_ITU_T_T35));
let payload_len = 1 + 2 + 4 + 1 + cc_data.len();
nal.extend(sei_varint(payload_len as u32));
nal.push(ITU_T_T35_COUNTRY_CODE_USA);
nal.extend_from_slice(&ATSC_T35_PROVIDER_CODE);
nal.extend_from_slice(&GA94);
nal.push(ATSC_USER_DATA_TYPE_CODE_CC_DATA);
nal.extend_from_slice(cc_data);
nal
}
fn assert_valid_webvtt(doc: &str) {
let blocks: Vec<&str> = doc.split("\n\n").collect();
assert_eq!(
blocks.first().and_then(|b| b.lines().next()),
Some("WEBVTT"),
"must start with the signature: {doc:?}"
);
assert_eq!(blocks.last(), Some(&""), "must end blank-line-terminated");
assert!(blocks.len() > 2, "expected at least one cue block: {doc:?}");
for block in &blocks[1..blocks.len() - 1] {
let mut lines = block.lines();
let timings = lines
.next()
.unwrap_or_else(|| panic!("empty cue block: {doc:?}"));
assert!(
timings.contains(" --> "),
"cue block must open with a timings line: {timings:?}"
);
assert!(
lines.next().is_some(),
"cue block must have a payload: {block:?}"
);
}
}
fn synthetic_fixture_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("fixtures")
.join("cc")
.join("cea608_cc1_synthetic.txt")
}
fn load_synthetic_frames() -> Vec<(u64, Vec<u8>)> {
let text = fs::read_to_string(synthetic_fixture_path())
.expect("read cea608_cc1_synthetic.txt fixture (shared with issue #568)");
let mut frames = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let pts: u64 = parts.next().expect("pts field").parse().expect("u64 pts");
let hex = parts.next().expect("hex field");
let bytes = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
.collect();
frames.push((pts, bytes));
}
frames
}
#[test]
fn caption_cc_data_round_trips_every_synthetic_frame() {
let frames = load_synthetic_frames();
assert_eq!(frames.len(), 13, "fixture frame count changed unexpectedly");
for (pts, cc_data) in &frames {
let au = wrap_a53_sei(cc_data);
let extracted = caption_cc_data(NalCodec::Avc, &au, false);
assert_eq!(&extracted, cc_data, "frame at pts {pts} round-trip");
}
}
#[test]
fn sei_path_matches_pes_path_expected_cues() {
let frames = load_synthetic_frames();
let mut ex = Cea608CueExtractor::new(Cea608Channel::Cc1);
for (pts, cc_data) in &frames {
let au = wrap_a53_sei(cc_data);
let extracted = caption_cc_data(NalCodec::Avc, &au, false);
let cc = CcData::parse(&extracted).expect("valid cc_data() Table B.9 bytes");
ex.push_frame(*pts, &cc.triplets);
}
ex.finalize(45_000);
let cues = ex.into_cues();
let expected = [
(6_000u64, 9_000u64, "HELLO"),
(15_000, 21_000, "HI"),
(21_000, 24_000, "HI\nBYE"),
(24_000, 33_000, "BYE"),
(39_000, 42_000, "OK"),
];
assert_eq!(cues.len(), expected.len(), "cues: {cues:?}");
for (cue, (start, end, text)) in cues.iter().zip(expected.iter()) {
assert_eq!(cue.start, MediaTime(*start), "cue {text:?} start");
assert_eq!(cue.end, MediaTime(*end), "cue {text:?} end");
assert_eq!(cue.text, *text);
}
let doc = write_document(&cues);
assert_valid_webvtt(&doc);
assert!(doc.contains("HELLO"));
assert!(doc.contains("HI\nBYE"));
}
#[test]
fn non_caption_sei_produces_no_cues() {
let au = [
0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x0A, 0x00, 0x00, 0x01, 0x06, 0x06, 0x00, 0x80, 0x00, 0x00, 0x01, 0x41, 0x9A, ];
let extracted = caption_cc_data(NalCodec::Avc, &au, false);
assert!(extracted.is_empty());
let mut ex = Cea608CueExtractor::new(Cea608Channel::Cc1);
ex.finalize(0);
assert!(ex.into_cues().is_empty());
}
const REAL_CAPTURE: &str = "transformers-eia608-h264";
fn real_capture_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join(".test-streams")
.join(format!("{REAL_CAPTURE}.ts"))
}
fn extract_sei_frames_from_ts(data: &[u8]) -> Vec<(u64, Vec<u8>)> {
let media = TsDemux::new()
.unpackage(data)
.expect("demux the real capture slice");
let video = media
.tracks
.iter()
.find(|t| matches!(t.config(), CodecConfig::Avc { .. }))
.expect("capture must contain an H.264 video track");
let mut frames = Vec::new();
for sample in &video.samples {
let pts = sample
.pts
.expect("a demuxed H.264 video sample always carries an absolute pts")
.max(0) as u64;
let cc = caption_cc_data(NalCodec::Avc, &sample.data, true);
if !cc.is_empty() {
frames.push((pts, cc));
}
}
frames.sort_by_key(|(pts, _)| *pts);
frames
}
#[test]
fn real_capture_sei_captions_match_ffmpeg_oracle() {
let path = real_capture_path();
if !path.exists() {
eprintln!(
"real_capture_sei_captions_match_ffmpeg_oracle: SKIPPED — \
{REAL_CAPTURE}.ts not in .test-streams/. Run \
`tools/fetch-test-streams.sh {REAL_CAPTURE}` to enable."
);
return;
}
let data = fs::read(&path).expect("read real capture slice");
let frames = extract_sei_frames_from_ts(&data);
assert!(
!frames.is_empty(),
"expected at least one A/53 caption SEI access unit in the real capture"
);
let mut ex = Cea608CueExtractor::new(Cea608Channel::Cc1);
for (pts, cc_data) in &frames {
let cc = CcData::parse(cc_data).expect("valid cc_data() Table B.9 bytes");
ex.push_frame(*pts, &cc.triplets);
}
ex.finalize(frames.last().map_or(0, |(pts, _)| pts + 90_000));
let cues = ex.into_cues();
assert!(!cues.is_empty(), "expected at least one decoded cue");
let doc = write_document(&cues);
assert_valid_webvtt(&doc);
let oracle_fragments = [
"its cities now.",
"watch the skies.",
"in solving human conflicts.",
];
for fragment in oracle_fragments {
assert!(
doc.contains(fragment),
"expected oracle caption fragment {fragment:?} in decoded WebVTT: {doc}"
);
}
}