From e370027e7014f4595f80e2b9db7db86bd9ac5c13 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:58:27 +0200 Subject: [PATCH] fix(captions): apply body and div timing to TTML paragraphs The TTML/DFXP reader ignored begin/end/dur on , so a body begin offset was lost and captions landed early. Body and div are now treated as par time containers: their begins offset descendants, their ends clip them, paragraphs without an end last until the container ends, and end + dur take the earlier. seq containers warn instead of being silently mistimed. Fixes #895 Co-Authored-By: Claude Opus 5.5 --- crates/captions/README.md | 2 +- crates/captions/src/ttml.rs | 89 ++++++++++++++++------ crates/captions/tests/ttml_body_timing.rs | 92 +++++++++++++++++++++++ 3 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 crates/captions/tests/ttml_body_timing.rs diff --git a/crates/captions/README.md b/crates/captions/README.md index d65b03b3..909636fd 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -16,7 +16,7 @@ and writes caption files, converts them to and from caption tracks, and draws ca | 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 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): `

` 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, timed spans, referenced styles. | +| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `

` 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`). | All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 000 ticks); SCC diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 6c98a7ff..c16d6976 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -10,8 +10,8 @@ //! //! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read): //! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`, -//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested time containers -//! (`body` / `div` begins add up), timed ``s inside an untimed `

`, `
`, whitespace +//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time +//! containers (`body` / `div` begins add up and their ends clip what is inside), timed ``s inside an untimed `

`, `
`, whitespace //! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`. use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; @@ -423,44 +423,84 @@ pub fn parse(text: &str) -> Result { let Some(body) = root.children().find(|n| n.is_element() && n.tag_name().name() == "body") else { return Ok(doc); }; - walk(body, Tick::ZERO, &tm, &styles, Fmt::default(), &mut doc); + if root.descendants().any(|n| attr(n, "timeContainer").is_some_and(|v| v.trim() == "seq")) { + doc.warnings.push("timeContainer=\"seq\" is not supported; its children are timed as in a par container".into()); + } + // the body is a time container like a div: its begin offsets, and its end clips, everything inside + let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc); + walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc); doc.cues.sort_by_key(|c| (c.start, c.end)); Ok(doc) } -/// Begin and end of a timed element relative to `parent` (None when it has no timing). -fn times(n: roxmltree::Node, parent: Tick, tm: &Timing, doc: &mut Document) -> (Option, Option) { - let p = |k: &str, doc: &mut Document| -> Option { +/// The active interval of the enclosing time containers (`par`): child times are offsets from +/// `begin`, and nothing inside outlasts `end` (None when no ancestor sets an end). +#[derive(Clone, Copy)] +struct Scope { + begin: Tick, + end: Option, +} + +/// Whether `n` has timing attributes of its own. +fn has_timing(n: roxmltree::Node) -> bool { + ["begin", "end", "dur"].into_iter().any(|k| attr(n, k).is_some()) +} + +/// Timing of an element in its parent container. +struct Times { + /// The begin, when the element sets one. + begin: Option, + /// Its own end (`end`, or `begin` + `dur`, the earlier of the two) clipped to the parent's + /// end, else the parent's end. + end: Option, + /// A timing attribute did not parse (and was warned about). + bad: bool, +} + +fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> Times { + let mut bad = false; + let mut p = |k: &str, doc: &mut Document| -> Option { let v = attr(n, k)?; - let t = parse_time(v, *tm); + let t = parse_time(v, tm); if t.is_none() { + bad = true; doc.warnings.push(format!("bad time expression \"{v}\"")); } t }; - let begin = p("begin", doc).map(|b| parent + b); - let end = match (p("end", doc), p("dur", doc)) { - (Some(e), _) => Some(parent + e), - (None, Some(d)) => Some(begin.unwrap_or(parent) + d), - _ => None, + let begin = p("begin", doc).map(|b| parent.begin + b); + let own_end = match (p("end", doc).map(|e| parent.begin + e), p("dur", doc).map(|d| begin.unwrap_or(parent.begin) + d)) { + (Some(e), Some(d)) => Some(e.min(d)), + (e, d) => e.or(d), }; - (begin, end) + let end = match (own_end, parent.end) { + (Some(e), Some(pe)) => Some(e.min(pe)), + (e, pe) => e.or(pe), + }; + Times { begin, end, bad } } -fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections::HashMap, fmt: Fmt, doc: &mut Document) { +fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap, fmt: Fmt, doc: &mut Document) { for c in n.children().filter(|c| c.is_element()) { let f = style_of(c, styles, fmt); match c.tag_name().name() { "div" => { - let (b, _) = times(c, parent, tm, doc); - walk(c, b.unwrap_or(parent), tm, styles, f, doc); + let t = times(c, parent, *tm, doc); + walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc); } "p" => { - let (b, e) = times(c, parent, tm, doc); + let t = times(c, parent, *tm, doc); + if t.bad { + continue; + } + let (b, e) = (t.begin.unwrap_or(parent.begin), t.end); let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " ")); let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string()); - match (b, e) { - (Some(b), Some(e)) => { + let spans: Vec = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect(); + // a timed paragraph, or one without timed spans in a container with an end, is one cue + let whole = if has_timing(c) || spans.is_empty() { e } else { None }; + match whole { + Some(e) => { let mut o = Out { s: String::new(), pending: false }; let tags: Vec<&str> = [(f.italic, "i"), (f.bold, "b"), (f.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1).collect(); for t in &tags { @@ -475,12 +515,13 @@ fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() }); } } - _ => { + None => { // untimed paragraph: its timed spans are the cues - let base = b.unwrap_or(parent); - for sp in c.children().filter(|x| x.is_element() && x.tag_name().name() == "span") { - let (sb, se) = times(sp, base, tm, doc); - if let (Some(sb), Some(se)) = (sb, se) { + let scope = Scope { begin: b, end: e }; + for sp in spans { + let st = times(sp, scope, *tm, doc); + let sb = st.begin.unwrap_or(scope.begin); + if let (false, Some(se)) = (st.bad, st.end) { let mut o = Out { s: String::new(), pending: false }; collect(sp, styles, style_of(sp, styles, f), &mut o); let text = clean(&o.s); diff --git a/crates/captions/tests/ttml_body_timing.rs b/crates/captions/tests/ttml_body_timing.rs new file mode 100644 index 00000000..27da2d9f --- /dev/null +++ b/crates/captions/tests/ttml_body_timing.rs @@ -0,0 +1,92 @@ +//! TTML / DFXP inherited timing: `begin`, `end` and `dur` on `body` and `div` time containers +//! offset and clip the paragraphs inside them (#895). + +use filmcraft_captions::{Format, WriteOptions, parse, write}; + +/// (start, end, text) of every cue, in seconds; panics on a parse error. +fn cues(xml: &str, format: Format) -> Vec<(f64, f64, String)> { + let doc = parse(xml.as_bytes(), format).unwrap(); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + doc.cues.iter().map(|c| (c.start.seconds(), c.end.seconds(), c.text.clone())).collect() +} + +fn both(xml: &str, want: &[(f64, f64, &str)]) { + let want: Vec<(f64, f64, String)> = want.iter().map(|(s, e, t)| (*s, *e, t.to_string())).collect(); + for format in [Format::Ttml, Format::Dfxp] { + assert_eq!(cues(xml, format), want, "{format:?}: {xml}"); + } +} + +#[test] +fn body_begin_is_inherited() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn div_begin_is_inherited() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn untimed_containers_leave_times_unchanged() { + both(r#"

x

"#, &[(1.0, 2.0, "x")]); +} + +#[test] +fn nested_begins_add_up() { + both( + r#"

deep

mid

top

"#, + &[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")], + ); +} + +#[test] +fn body_begin_offsets_timed_spans() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn container_ends_clip_descendants() { + // div active 11 s to 13 s: a later paragraph is dropped, a longer one is cut at 13 s + both( + r#"

cut

gone

"#, + &[(12.0, 13.0, "cut")], + ); + both(r#"

x

"#, &[(1.0, 3.0, "x")]); +} + +#[test] +fn paragraphs_without_an_end_last_until_their_container_ends() { + both( + r#"

open

whole

"#, + &[(3.0, 6.0, "open"), (10.0, 12.0, "whole")], + ); +} + +#[test] +fn end_and_dur_take_the_earlier() { + both(r#"

x

"#, &[(1.0, 3.0, "x")]); +} + +#[test] +fn seq_containers_warn() { + let xml = r#"

x

"#; + for format in [Format::Ttml, Format::Dfxp] { + let doc = parse(xml.as_bytes(), format).unwrap(); + assert_eq!(doc.cues.len(), 1); + assert!(doc.warnings.iter().any(|w| w.contains("seq")), "{:?}", doc.warnings); + } +} + +#[test] +fn inherited_times_survive_export() { + // the writer uses untimed body / div and absolute paragraph times, so the resolved times stay + let xml = r#"

x

"#; + for format in [Format::Ttml, Format::Dfxp] { + let doc = parse(xml.as_bytes(), format).unwrap(); + let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap(); + let times = |d: &filmcraft_captions::Document| d.cues.iter().map(|c| (c.start, c.end, c.text.clone())).collect::>(); + assert_eq!(times(&back), times(&doc), "{format:?}"); + assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}"); + } +}