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 <br/> 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 --&gt; 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 <searay112023@gmail.com>
Co-authored-by: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com>
Co-authored-by: JonRC <jonarc06@gmail.com>
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-11 20:03:52 -05:00
1 parent 491fb0095f
commit 9e78e96c16
3 files changed
+148 -17

No files matched your search

+17 -2
View File
@@ -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 `<v Speaker>` 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 `<i>` / `<u>`, 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): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `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`).
+38 -2
View File
@@ -310,11 +310,16 @@ pub struct WriteOptions {
pub rate: Option<FrameRate>,
/// 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<Tick>,
}
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<u8> {
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<c
}
}
/// [`write`] into `out`. MCC goes out line by line, so a long `every_frame_until` export is never
/// held in memory; the other formats are small and written whole.
pub fn write_to(out: &mut dyn std::io::Write, doc: &Document, format: Format, opts: WriteOptions) -> 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:?}");
}
}
}
+93 -13
View File
@@ -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::<u32>() % 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::<Vec<_>>();
let first_unpadded = scc::format_timecode(200, true);
let later = |tcs: Vec<String>| tcs.into_iter().filter(|tc| *tc >= first_unpadded).collect::<Vec<_>>();
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() {