diff --git a/crates/captions/README.md b/crates/captions/README.md index 909636fd..08cc4d0d 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -23,6 +23,27 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00 frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's frame grid (the engine does this on import) and removes overlaps. +### TTML speaker metadata + +TTML and DFXP exports define each distinct speaker label once in the document head and refer to +it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved +whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is +`other` because a caption's speaker label does not say whether it denotes a person or a group. + +On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed +span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006 +TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified +`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent` +attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred, +otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in +reference order; the project does not retain the individual agent identities. Older FilmCraft +files without agent definitions remain readable through the previous underscore-to-space +fallback. Other agent metadata, including actor relationships, is not retained. + +This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent). +The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/) +defines its earlier metadata namespace and agent ID form. + ### SCC writer Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index c16d6976..4367663a 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -8,12 +8,14 @@ //! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without //! one, and always for DFXP (for older players), times are clock times with milliseconds. //! -//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read): +//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2: //! 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 `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 std::collections::{HashMap, HashSet}; + use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use crate::{Cue, Document, Error, Result, TICKS_PER_MS}; @@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml"; const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter"; const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling"; const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata"; +const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata"; +const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata"; +const NS_XML: &str = "http://www.w3.org/XML/1998/namespace"; const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text"; const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation"; @@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool { c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.')) } +fn cue_id(cue: &Cue, index: usize) -> String { + cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1)) +} + /// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the /// document language (`xml:lang`). pub fn write(doc: &Document, flavor: Flavor, rate: Option, lang: &str) -> String { @@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option, lang: &str } } o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang }))); + // Keep agent identifiers distinct from the IDs already emitted for cues, style and region. + let mut ids: HashSet = ["s1".into(), "r1".into()].into_iter().collect(); + ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i))); + let mut speakers = Vec::new(); + let mut speaker_ids = HashMap::new(); + for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) { + if speaker_ids.contains_key(speaker) { + continue; + } + let mut id = format!("agent{}", speakers.len() + 1); + // Each collision consumes a distinct reserved ID, so this is bounded by ids.len(). + while ids.contains(&id) { + id.push('_'); + } + ids.insert(id.clone()); + speaker_ids.insert(speaker, id.clone()); + speakers.push((speaker, id)); + } o.push_str(" \n"); + for (speaker, id) in speakers { + o.push_str(&format!( + " {}\n", + // XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve". + esc(speaker).replace('\r', " ") + )); + } o.push_str(" \n