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#""#, &[(6.0, 7.0, "x")]);
+}
+
+#[test]
+fn div_begin_is_inherited() {
+ both(r#""#, &[(6.0, 7.0, "x")]);
+}
+
+#[test]
+fn untimed_containers_leave_times_unchanged() {
+ both(r#""#, &[(1.0, 2.0, "x")]);
+}
+
+#[test]
+fn nested_begins_add_up() {
+ both(
+ r#"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#""#, &[(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#""#,
+ &[(12.0, 13.0, "cut")],
+ );
+ both(r#""#, &[(1.0, 3.0, "x")]);
+}
+
+#[test]
+fn paragraphs_without_an_end_last_until_their_container_ends() {
+ both(
+ r#""#,
+ &[(3.0, 6.0, "open"), (10.0, 12.0, "whole")],
+ );
+}
+
+#[test]
+fn end_and_dur_take_the_earlier() {
+ both(r#""#, &[(1.0, 3.0, "x")]);
+}
+
+#[test]
+fn seq_containers_warn() {
+ let xml = r#""#;
+ 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#""#;
+ 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:?}");
+ }
+}