From 9e78e96c16e9cfb06af378bb2328c742401021ee Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:03:52 -0500 Subject: [PATCH] craft-captions: MCC export can write a packet on every frame (#951) * craft-captions: TTML cue text collapses only XML whitespace and keeps edge line breaks Ported from storytold/filmcraft#3145. char::is_whitespace also matched U+00A0, U+2009 and U+3000, so no-break spaces became ordinary spaces on import, and a leading or trailing
was trimmed away. * craft-captions: TTML/DFXP export drops characters XML 1.0 forbids Ported from storytold/filmcraft#4467. A control character in cue text made the exported file unreadable. The filter also covers U+FFFE and U+FFFF, which the review of that pull request asked for. * craft-captions: WebVTT cue-text arrow escape is reversible Ported from storytold/filmcraft#4437. A literal --> in cue text came back as --> after a write and parse; the writer now adds one amp; level and the reader removes exactly one. * craft-captions: a WebVTT speaker name with > no longer spills into the cue text Ported from storytold/filmcraft#4470. The writer puts the lookalike U+203A in place of > inside the voice tag. Also from that pull request's review: a line break in a name is written as a space, and a name ending in - is kept apart from the tag's closing >. * craft-captions: an SRT text line that looks like a timing line survives a round trip Ported from storytold/filmcraft#4448. The writer marks such a line with a leading zero-width space and the reader removes it, so the cue is not split. * craft-captions: back-to-back SCC captions with the same text stay separate cues Ported from storytold/filmcraft#3808. Every End of Caption now closes the open cue. MCC's CEA-608 path uses the same decoder. * craft-captions: an SCC pop-on load may run across the previous caption's erase Ported from storytold/filmcraft#1837 (filmcraft#1791). A caption that closely follows another popped on about a second late, because its load had to be one unbroken run of free frames. Doubled codes stay adjacent; pauses are allowed between them. * craft-captions: CEA-708 export starts each caption at its window's top left Ported from storytold/filmcraft#4043 (filmcraft#4042). Each window load now sends DeleteWindows before DefineWindow in place of the ClearWindows after it: a window made anew is empty with the pen at its top left in any decoder, for the same number of bytes, so cue timing is unchanged. * craft-captions: TTML/DFXP export keeps literal < and > text Ported from storytold/filmcraft#2830. The writer treated every < with a later > as a tag and wrote nothing for unknown names, so "1 < 2 and 3 > 2" lost its middle. Only a well-formed tag is markup now; a > inside a quoted attribute value does not end the tag. * craft-captions: TTML/DFXP export keeps cue IDs unique and valid Unicode IDs as written Ported from the TTML commit of storytold/filmcraft#1839. A cue named s1 could collide with the style ID, and a generated c1 could take the ID of a later cue. Valid explicit IDs are reserved first; missing, invalid or repeated ones get a replacement that is in no one's way. IDs are checked as XML NCNames. * craft-captions: compare the craft-xml and roxmltree views of TTML documents The TTML reader moved from roxmltree to craft-xml. This dev-only test checks that both hand it the same elements, attributes and text, by namespace, on TTML documents and on the writer's output. roxmltree becomes a dev-dependency, which the cue ID tests use too. * craft-captions: use craft-timeline's exact time instead of a private copy craft-captions carried a 290-line subset of the tick, frame rate and timecode field code because craft-libs had none when it was added. craft-timeline now has the whole module with the same tick unit. craft_captions::time is a re-export of craft_timeline::time, so every public path still resolves and the types are now the same ones craft-timeline's callers hold. * craft-captions: SCC and MCC time codes count from the document's start timecode Ported from the caption-format part of storytold/filmcraft#4131 (filmcraft#4130). Document::start_timecode is the label of time zero; the SCC and MCC writers count from the 29.97 frame with the same label, so the label carries across rates. Readers note the rate and drop-frame mode of a file's labels in Document::labels, and Document::count_from counts cues back from a start time code. charset_warnings names the time codes the file has, which that pull request's review found missing. * craft-captions: MCC export can write a packet on every frame Ported from the caption-format part of storytold/filmcraft#4037 (filmcraft#4024). WriteOptions::every_frame_until fills every 29.97 fps frame before that time that has no caption data with a packet of CEA-608 nulls and CEA-708 padding, as SDI and frame-wrapped MXF carry one per frame. Off by default, at most 24 hours (mcc::MAX_PADDED_FRAMES), and write_to streams the file line by line. * craft-captions: README has its MCC writer section, with the every-frame option The section's text was cut off when the crate was added; only a stray heading marker was left. --------- Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Co-authored-by: SeeRay11 Co-authored-by: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com> Co-authored-by: JonRC Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> --- crates/captions/README.md | 19 ++++++- crates/captions/src/lib.rs | 40 +++++++++++++- crates/captions/src/mcc.rs | 106 ++++++++++++++++++++++++++++++++----- 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/crates/captions/README.md b/crates/captions/README.md index 60fed235b..03d93cd01 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -51,7 +51,7 @@ lists at most 20 characters per stream. | SubRip `.srt` | yes | yes | BOM, UTF-16, Windows-1252 fallback, CRLF/CR, missing or wrong indexes, `,` or `.` milliseconds, 1–9 fraction digits, missing hours, SSA coordinates, cues not separated by blank lines. Inline tags are kept as text. | | WebVTT `.vtt` | yes | yes | Cue identifiers, cue settings (verbatim), a leading `` voice span (as the caption's speaker) and STYLE / REGION / NOTE blocks are kept. A missing `WEBVTT` signature is tolerated. | | Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. | -| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame carrying caption data, omitting empty frames, in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. | +| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame carrying caption data, omitting empty frames (or on every frame, on request), in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. | | EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `` / ``, extension blocks for long subtitles, start-of-programme (TCP) subtracted. | | TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces) with the document's language as `xml:lang` (read back on import): `

` per cue, `
`, styled ``s for `` / `` / ``, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. | | DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). | @@ -117,4 +117,19 @@ character, as the standard requires); a few have a close stand-in (’ as ', – dropped. `charset_warnings` names each dropped or replaced character. If there is no room to load a caption before its in point (for example a caption at 00:00:00:00), it appears as soon as it is loaded. -# +### MCC writer + +The writer merges the scheduled CEA-608 and CEA-708 frames in timestamp order. Frames without +caption data are omitted, including during a displayed caption; its explicit erase/hide command +still lands at its out point. CDP sequence counters advance per emitted packet, wrapping at 16 bits. +CEA-708 text uses the G0 and G1 (Latin-1) sets and part of G2 (… ‘ ’ “ ” • ™ and a few more); +other characters are sent as `?`, and `charset_warnings` names them. +Export work and output size therefore follow the scheduled caption data, not the time between +cues or their duration. + +For consumers that need a packet on every video frame (SDI, frame-wrapped MXF with a SMPTE 436M +track), `WriteOptions::every_frame_until` also writes one on every frame without caption data, +from the start to that time: CEA-608 null pairs and CEA-708 padding. The packets are 29.97 fps, +so they line up with the video only at that rate; the caller decides whether to offer it. It is +off by default, covers at most 24 hours (`mcc::MAX_PADDED_FRAMES`), and can be streamed +(`write_to`). diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index 752976f11..47cb0006e 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -310,11 +310,16 @@ pub struct WriteOptions { pub rate: Option, /// MCC / EBU STL: the name written as the creating program ([`PROGRAM`] by default). pub program: &'static str, + /// MCC: a packet on every frame from the start to this time (the end of the programme), with + /// no caption data where there is none, as SDI and frame-wrapped MXF carry one per frame. + /// The frames are MCC's 29.97 fps ones, so this is one packet per video frame only for + /// 29.97 fps video. None (the default) writes only the frames that carry caption data. + pub every_frame_until: Option, } impl Default for WriteOptions { fn default() -> Self { - Self { drop_frame: true, rate: None, program: PROGRAM } + Self { drop_frame: true, rate: None, program: PROGRAM, every_frame_until: None } } } @@ -324,7 +329,7 @@ pub fn write(doc: &Document, format: Format, opts: WriteOptions) -> Vec { Format::Srt => srt::write(doc).into_bytes(), Format::WebVtt => vtt::write(doc).into_bytes(), Format::Scc => scc::write(doc, opts.drop_frame).into_bytes(), - Format::Mcc => mcc::write_with(doc, opts.drop_frame, true, true, opts.program).into_bytes(), + Format::Mcc => mcc::write_padded(doc, opts.drop_frame, true, true, mcc_pad_frames(opts), opts.program).into_bytes(), Format::Stl => stl::write(doc, opts.rate, opts.program), Format::Ttml => ttml::write(doc, ttml::Flavor::Imsc1, opts.rate, doc.language.as_deref().unwrap_or("")).into_bytes(), Format::Dfxp => ttml::write(doc, ttml::Flavor::Dfxp, opts.rate, doc.language.as_deref().unwrap_or("")).into_bytes(), @@ -385,6 +390,24 @@ fn lossy_chars(doc: &Document, stream: &str, shown_as: impl Fn(char) -> Option std::io::Result<()> { + match format { + Format::Mcc => mcc::write_padded_to(out, doc, opts.drop_frame, true, true, mcc_pad_frames(opts), opts.program), + _ => out.write_all(&write(doc, format, opts)), + } +} + +/// The MCC frames `opts.every_frame_until` asks for: those that start before it, so a last +/// partial frame gets its packet too. +fn mcc_pad_frames(opts: WriteOptions) -> i64 { + opts.every_frame_until.map_or(0, |end| { + let f = mcc::RATE.frame_at(end); + if mcc::RATE.tick_of(f) < end { f.saturating_add(1) } else { f } + }) +} + /// Decode text bytes: UTF-8 (BOM stripped), UTF-16 with BOM, else Windows-1252. Line endings are /// normalised to `\n`. pub fn decode_text(bytes: &[u8]) -> String { @@ -588,4 +611,17 @@ mod tests { let r: Vec<(i64, i64)> = d.cues.iter().map(|c| (c.start.0, c.end.0)).collect(); assert_eq!(r, vec![(0, 10), (10, 12), (12, 20)]); } + + /// `write_to` streams the same bytes `write` returns, for every format and a padded MCC. + #[test] + fn write_to_matches_write() { + let cue = Cue { start: Tick(TICKS_PER_SECOND), end: Tick(2 * TICKS_PER_SECOND), text: "Hello".into(), ..Default::default() }; + let doc = Document { cues: vec![cue], ..Default::default() }; + let padded = WriteOptions { every_frame_until: Some(Tick(3 * TICKS_PER_SECOND)), ..Default::default() }; + for (format, opts) in Format::ALL.map(|f| (f, WriteOptions::default())).into_iter().chain([(Format::Mcc, padded)]) { + let mut out = Vec::new(); + write_to(&mut out, &doc, format, opts).unwrap(); + assert_eq!(out, write(&doc, format, opts), "{format:?}"); + } + } } diff --git a/crates/captions/src/mcc.rs b/crates/captions/src/mcc.rs index 7b13ac3c2..127dfca16 100644 --- a/crates/captions/src/mcc.rs +++ b/crates/captions/src/mcc.rs @@ -581,6 +581,10 @@ impl Dec708 { // File // --------------------------------------------------------------------------------------------- +/// The most frames [`write_padded`] fills: 24 hours of 29.97 fps drop-frame time code, as far +/// as an MCC time code counts. +pub const MAX_PADDED_FRAMES: i64 = 24 * 107_892; + /// Write `doc` as MCC: CEA-608 field 1 and CEA-708 service 1 (see the module docs), with /// [`crate::PROGRAM`] as the creation program. pub fn write(doc: &Document, drop_frame: bool) -> String { @@ -600,46 +604,82 @@ fn header_value(program: &str) -> String { /// An empty document or two disabled streams produces a header with no data lines. /// `program` is named as the file's creation program. pub fn write_with(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool, program: &str) -> String { + write_padded(doc, drop_frame, cea608, cea708, 0, program) +} + +/// [`write_with`], plus a packet with no caption data (CEA-608 nulls, CEA-708 padding) on every +/// frame before `pad_frames` that has none, as SDI and frame-wrapped MXF carry one packet per +/// video frame. Frames with caption data after `pad_frames` are written as usual. At most +/// [`MAX_PADDED_FRAMES`] are filled. +pub fn write_padded(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool, pad_frames: i64, program: &str) -> String { + let mut out = Vec::new(); + // writing into a Vec cannot fail, and the text is UTF-8 (ASCII but for the program name) + let _ = write_padded_to(&mut out, doc, drop_frame, cea608, cea708, pad_frames, program); + String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) +} + +/// [`write_padded`] into `out` line by line, so a long padded export is never held in memory. +pub fn write_padded_to( + out: &mut dyn std::io::Write, + doc: &Document, + drop_frame: bool, + cea608: bool, + cea708: bool, + pad_frames: i64, + program: &str, +) -> std::io::Result<()> { let program = header_value(program); + let pad = pad_frames.clamp(0, MAX_PADDED_FRAMES); let f1 = if cea608 { scc::schedule(doc) } else { BTreeMap::new() }; let dtv = if cea708 { schedule_708(doc) } else { BTreeMap::new() }; let origin = crate::label_frame(doc.start_timecode, RATE, drop_frame); // time codes count from the start time code - let mut out = String::from("File Format=MacCaption_MCC V1.0\n\n"); - out.push_str("///////////////////////////////////////////////////////////////////////////////////\n"); - out.push_str(&format!("// Closed captions written by {program}: SMPTE ST 334-2 caption distribution packets\n")); - out.push_str("// carrying CEA-608 field 1 and CEA-708 service 1 data; empty frames omitted.\n"); - out.push_str("///////////////////////////////////////////////////////////////////////////////////\n\n"); + out.write_all(b"File Format=MacCaption_MCC V1.0\n\n")?; + out.write_all(b"///////////////////////////////////////////////////////////////////////////////////\n")?; + writeln!(out, "// Closed captions written by {program}: SMPTE ST 334-2 caption distribution packets")?; + let frames: &[u8] = if pad > 0 { + b"// carrying CEA-608 field 1 and CEA-708 service 1 data; a packet on every frame.\n" + } else { + b"// carrying CEA-608 field 1 and CEA-708 service 1 data; empty frames omitted.\n" + }; + out.write_all(frames)?; + out.write_all(b"///////////////////////////////////////////////////////////////////////////////////\n\n")?; let n = doc.cues.len() as u64; let h = doc .cues .iter() .fold(0xcbf2_9ce4_8422_2325u64 ^ n, |h, c| (h ^ c.start.0 as u64 ^ (c.end.0 as u64).rotate_left(17)).wrapping_mul(0x100_0000_01b3)); - out.push_str(&format!( - "UUID={:08X}-{:04X}-4{:03X}-8{:03X}-{:012X}\n", + writeln!( + out, + "UUID={:08X}-{:04X}-4{:03X}-8{:03X}-{:012X}", h >> 32, (h >> 16) & 0xffff, h & 0xfff, (h >> 20) & 0xfff, h.rotate_left(29) & 0xffff_ffff_ffff - )); - out.push_str(&format!("Creation Program={program}\n")); - out.push_str(&format!("Time Code Rate={}\n\n", if drop_frame { "30DF" } else { "30" })); + )?; + writeln!(out, "Creation Program={program}")?; + writeln!(out, "Time Code Rate={}\n", if drop_frame { "30DF" } else { "30" })?; // Merge the sparse schedules without walking (or allocating for) input-derived time gaps. // Erase/hide commands are scheduled data too, so omitting padding preserves cue end times. + // Padding walks only the frames before `pad`, which the caller chose and is capped. let mut f1 = f1.into_iter().peekable(); let mut dtv = dtv.into_iter().peekable(); let mut seq = 0u16; - while let Some(f) = f1.peek().map(|(f, _)| *f).into_iter().chain(dtv.peek().map(|(f, _)| *f)).min() { + let mut unpadded = 0i64; // the first frame with no packet yet, while it is before `pad` + loop { + let data = f1.peek().map(|(f, _)| *f).into_iter().chain(dtv.peek().map(|(f, _)| *f)).min(); + let Some(f) = data.into_iter().chain((unpadded < pad).then_some(unpadded)).min() else { break }; + unpadded = unpadded.max(f.saturating_add(1)); let data = FrameData { f1: f1.next_if(|(frame, _)| *frame == f).map(|(_, pair)| pair), dtvcc: dtv.next_if(|(frame, _)| *frame == f).map(|(_, pairs)| pairs).unwrap_or_default(), }; let (_, hh, mm, ss, ff) = frames_to_fields(f.saturating_add(origin), RATE, drop_frame); let sep = if drop_frame { ';' } else { ':' }; - out.push_str(&format!("{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}\t{}\n", compress(&build_cdp(seq, &data)))); + writeln!(out, "{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}\t{}", compress(&build_cdp(seq, &data)))?; seq = seq.wrapping_add(1); } - out + Ok(()) } fn tc_rate(v: &str) -> Option<(FrameRate, bool)> { @@ -893,6 +933,46 @@ mod tests { assert!(!write(&Document::default(), true).contains('\t')); } + /// With padding, every frame to the end has one packet in time code order, numbered and + /// checksummed, empty frames carry no caption data, and the captions read back unchanged. + #[test] + fn padded_export_has_a_packet_on_every_frame() { + let doc = Document { cues: vec![cue(60, 120, "First"), cue(300, 330, "Last")], ..Default::default() }; + let empty = compress(&build_cdp(0, &FrameData::default())); + for drop_frame in [false, true] { + for (cea608, cea708) in [(true, true), (true, false), (false, true)] { + let text = write_padded(&doc, drop_frame, cea608, cea708, 2_000, "test"); + assert!(text.contains("a packet on every frame")); + let lines: Vec<(&str, &str)> = text.lines().filter_map(|line| line.split_once('\t')).collect(); + assert_eq!(lines.len(), 2_000); + for (f, (tc, data)) in lines.iter().enumerate() { + let (_, hh, mm, ss, ff) = frames_to_fields(f as i64, RATE, drop_frame); + let sep = if drop_frame { ';' } else { ':' }; + assert_eq!(*tc, format!("{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}")); + let bytes = expand(data).unwrap(); + assert_eq!(u16::from_be_bytes([bytes[8], bytes[9]]), f as u16); + assert_eq!(bytes[3..76].iter().map(|&b| b as u32).sum::() % 256, 0); + } + // nothing is scheduled at frame 0 (the first load comes later), nor after the last erase + assert_eq!(lines[0].1, empty); + assert_eq!(lines[1_999].1, compress(&build_cdp(1_999, &FrameData::default()))); + assert_eq!(parse(&text).unwrap().cues, doc.cues); + } + } + // caption data after the padded span is still written; no padding is the sparse writer + let sparse = write_with(&doc, true, true, true, "test"); + let tcs = |text: &str| text.lines().filter_map(|line| line.split_once('\t')).map(|(tc, _)| tc.to_string()).collect::>(); + let first_unpadded = scc::format_timecode(200, true); + let later = |tcs: Vec| tcs.into_iter().filter(|tc| *tc >= first_unpadded).collect::>(); + let short = tcs(&write_padded(&doc, true, true, true, 200, "test")); + assert!(!later(short.clone()).is_empty()); + assert_eq!(short.len(), 200 + later(short.clone()).len()); + assert_eq!(later(short), later(tcs(&sparse))); + for pad in [0, -5, i64::MIN] { + assert_eq!(write_padded(&doc, true, true, true, pad, "test"), sparse); + } + } + /// Prevent CEA-708 text and line breaks from overflowing a window's declared dimensions. #[test] fn decoder_keeps_708_text_within_declared_window_dimensions() {