mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
fix(captions): apply body and div timing to TTML paragraphs
The TTML/DFXP reader ignored begin/end/dur on <body>, 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 <[email protected]>
This commit is contained in:
1 parent
4e2d0bc2d3
commit
e370027e70
3 files changed
+158
-25
No files matched your search
@@ -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 `<i>` / `<u>`, extension blocks for long subtitles, start-of-programme (TCP) subtracted. |
|
||||
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<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, timed spans, referenced styles. |
|
||||
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<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`). |
|
||||
|
||||
All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 000 ticks); SCC
|
||||
|
||||
+65
-24
@@ -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 `<span>`s inside an untimed `<p>`, `<br/>`, 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 `<span>`s inside an untimed `<p>`, `<br/>`, 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<Document> {
|
||||
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<Tick>, Option<Tick>) {
|
||||
let p = |k: &str, doc: &mut Document| -> Option<Tick> {
|
||||
/// 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<Tick>,
|
||||
}
|
||||
|
||||
/// 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<Tick>,
|
||||
/// 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<Tick>,
|
||||
/// 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<Tick> {
|
||||
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<String, Fmt>, fmt: Fmt, doc: &mut Document) {
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, 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<roxmltree::Node> = 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);
|
||||
|
||||
@@ -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#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn div_begin_is_inherited() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="5s"><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untimed_containers_leave_times_unchanged() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(1.0, 2.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_begins_add_up() {
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="00:00:10.000"><div begin="1s"><p begin="1s" end="2s">deep</p></div><p begin="0s" dur="1s">mid</p></div><p begin="1s" end="2s">top</p></body></tt>"#,
|
||||
&[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_begin_offsets_timed_spans() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p><span begin="1s" end="2s">x</span></p></div></body></tt>"#, &[(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#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="1s"><div begin="10s" end="12s"><p begin="1s" end="5s">cut</p><p begin="3s" end="4s">gone</p></div></body></tt>"#,
|
||||
&[(12.0, 13.0, "cut")],
|
||||
);
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body dur="3s"><div><p begin="1s" end="5s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_without_an_end_last_until_their_container_ends() {
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="2s" end="6s"><p begin="1s">open</p></div><div begin="10s" dur="2s"><p>whole</p></div></body></tt>"#,
|
||||
&[(3.0, 6.0, "open"), (10.0, 12.0, "whole")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_and_dur_take_the_earlier() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="5s" dur="2s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_containers_warn() {
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"><body><div timeContainer="seq"><p begin="1s" end="2s">x</p></div></body></tt>"#;
|
||||
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#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="1s" end="4s"><p begin="1s" end="9s">x</p></div></body></tt>"#;
|
||||
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::<Vec<_>>();
|
||||
assert_eq!(times(&back), times(&doc), "{format:?}");
|
||||
assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user