From ee9fd5307969dede19aa5295d8722e2d20b5d81e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:52:15 -0400 Subject: [PATCH] M10.7: graphics templates (.fcgt), rolls/crawls, responsive design, per-character styles, MCC/STL/TTML/DFXP captions - Text engine: per-character style runs (layout_rich), per-run underline/colour index. - Project: graphic_design (style runs, pins, roll/crawl maths, responsive time, source graphics), gtemplate (.fcgt format, validation, base64 resources, 8 original built-in templates; .mogrt refused). - Render: item-level graphic evaluation (responsive time, pins, rolls), per-run fills. - Engine: graphics.template.* (list/apply/export/install/remove/set/controls/thumbnail), graphics.setRoll/setResponsiveTime/pin/setCharStyle, Upgrade Caption to Graphic, Upgrade to Source Graphic (shared edits), Replace Fonts in Projects, fonts used. - Captions: MacCaption MCC (CDP, CEA-608 + CEA-708), EBU STL (Tech 3264), TTML/IMSC1, DFXP. - Schema v12 (no-op migration + fixture), docs. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + Cargo.lock | 1 + ROADMAP.md | 2 +- crates/captions/Cargo.toml | 3 +- crates/captions/README.md | 18 +- crates/captions/src/lib.rs | 80 +- crates/captions/src/mcc.rs | 694 +++++++++ crates/captions/src/scc.rs | 32 +- crates/captions/src/stl.rs | 466 ++++++ crates/captions/src/ttml.rs | 547 ++++++++ crates/captions/tests/roundtrip.rs | 125 +- crates/edit/src/tests.rs | 1 + crates/edit/src/through.rs | 1 + crates/edit/src/transcript/tests.rs | 1 + crates/engine/src/captions.rs | 10 +- crates/engine/src/commands.rs | 1 + crates/engine/src/export_tools.rs | 3 +- crates/engine/src/graphic_templates.rs | 1247 +++++++++++++++++ crates/engine/src/graphic_templates_tests.rs | 365 +++++ crates/engine/src/graphics.rs | 70 +- crates/engine/src/lib.rs | 5 + crates/export/src/lib.rs | 5 + crates/format/src/lib.rs | 13 +- crates/format/tests/fixtures.rs | 17 + .../format/tests/fixtures/v11-minimal.fcproj | 1 + crates/interchange/src/common.rs | 2 + crates/project/src/effect.rs | 5 + crates/project/src/essential.rs | 1 + crates/project/src/graphic.rs | 62 +- crates/project/src/graphic_design.rs | 593 ++++++++ crates/project/src/gtemplate.rs | 764 ++++++++++ crates/project/src/keyframe.rs | 7 + crates/project/src/lib.rs | 20 +- crates/render/src/graphic_clip.rs | 342 ++++- crates/render/src/lib.rs | 4 +- crates/render/src/plan.rs | 2 +- crates/text/README.md | 3 +- crates/text/src/fonts.rs | 4 + crates/text/src/layout.rs | 231 ++- crates/text/src/lib.rs | 4 +- crates/text/src/render.rs | 34 +- docs/graphics.md | 93 +- docs/project-files.md | 3 +- 43 files changed, 5746 insertions(+), 137 deletions(-) create mode 100644 crates/captions/src/mcc.rs create mode 100644 crates/captions/src/stl.rs create mode 100644 crates/captions/src/ttml.rs create mode 100644 crates/engine/src/graphic_templates.rs create mode 100644 crates/engine/src/graphic_templates_tests.rs create mode 100644 crates/format/tests/fixtures/v11-minimal.fcproj create mode 100644 crates/project/src/graphic_design.rs create mode 100644 crates/project/src/gtemplate.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4c68087a..1273dfea 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -97,6 +97,7 @@ is traced or derived from Adobe artwork. | UI icons (tools, transport, panels, header) | `crates/ui-egui/src/icons.rs`: vector paths on a 16×16 grid | | Align / distribute / paragraph-alignment glyphs in the graphics panel | `crates/ui-egui/src/panels/graphics.rs` (`align_glyph`): bars and lines drawn with the egui painter | | Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` | +| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files | | Lumetri "Look" presets (Teal & Orange, Warm Film, …) | `crates/render/src/effects.rs` (`apply_look`): procedural colour transforms, not LUT files | | Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork | diff --git a/Cargo.lock b/Cargo.lock index 62b69ec0..d874f8b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1734,6 +1734,7 @@ dependencies = [ "filmcraft-text", "filmcraft-time", "proptest", + "roxmltree", "thiserror 2.0.21", ] diff --git a/ROADMAP.md b/ROADMAP.md index e87a6581..14cba073 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,7 +67,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 | | M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 | | M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, MPEG-2 decoder, hardware decode | 12–20 | -| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute | Responsive design pins, rolls/crawls, per-character styles, motion graphics templates, MCC/STL/TTML, 608/708 embedding, speech-to-text | 12–18 | +| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 | | M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 | | M12–M16 | Multicam, web (WASM), platform, long tail | 🟡 | Multicam: audio sync (GCC-PHAT, sub-sample), Merge Clips, multi-camera source sequences, Multi-Camera view with live switching (1–9); L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), scene detection, auto reframe, ~800 remaining commands and dialogs, performance hardening | 35–55 | diff --git a/crates/captions/Cargo.toml b/crates/captions/Cargo.toml index dad44780..ea748380 100644 --- a/crates/captions/Cargo.toml +++ b/crates/captions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filmcraft-captions" -description = "FilmCraft captions: SRT, WebVTT and SCC (CEA-608 pop-on) import/export, caption layout and burn-in rasterisation" +description = "FilmCraft captions: SRT, WebVTT, SCC, MCC, EBU STL and TTML/IMSC1/DFXP import/export, caption layout and burn-in rasterisation" version.workspace = true edition.workspace = true license.workspace = true @@ -15,6 +15,7 @@ filmcraft-color = { workspace = true } filmcraft-project = { workspace = true } filmcraft-text = { workspace = true } thiserror = { workspace = true } +roxmltree = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/captions/README.md b/crates/captions/README.md index b988d65f..d65b03b3 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -1,7 +1,7 @@ # filmcraft-captions Caption files and caption burn-in for FilmCraft (layer L2: depends on `filmcraft-time`, -`filmcraft-color`, `filmcraft-project` and `filmcraft-text`). +`filmcraft-color`, `filmcraft-project`, `filmcraft-text` and `roxmltree` for TTML). The caption *model* (caption tracks in a sequence, captions, track style) lives in `filmcraft-project` (`caption.rs`) because it is part of the `.fcproj` document. This crate reads @@ -14,6 +14,10 @@ and writes caption files, converts them to and from caption tracks, and draws ca | 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 `` 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 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. | +| 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 frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's @@ -43,10 +47,18 @@ Text is set in **Inter SemiBold** (`assets/fonts/Inter-SemiBold.ttf`, SIL OFL 1. `ATTRIBUTION.md`) by the `filmcraft-text` engine: shaped with kerning and ligatures, bidi-ordered, and drawn from its sub-pixel positioned glyph cache. +Specifications used: SMPTE ST 334-2 (CDP structure), CEA-608 / CEA-708 (public descriptions of +the caption commands), the MacCaption MCC format description (line syntax and abbreviations), +EBU Tech 3264-E (1991), ISO/IEC 6937, W3C TTML1 (2nd ed.), TTML2 and IMSC 1.1. No other +implementation's code was consulted. + ## Tests - Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608 tables, PAC round trips, known SCC pop-on and roll-up streams, layout). - `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, - settings, STYLE blocks) and SCC (drop-frame and non-drop, gaps and back-to-back captions) - round-trip exactly; readers never panic on random bytes; SRT → VTT → SCC keeps text and frames. + settings, STYLE blocks), SCC and MCC (drop-frame and non-drop, gaps and back-to-back captions; + MCC also through CEA-708 alone), EBU STL (25 and 29.97 fps, accents, extension blocks), TTML + (23.976 / 25 / 29.97 / 59.94, ids, speakers) round-trip frame-exactly; DFXP round-trips after + snapping to frames; readers never panic on random bytes (also a valid STL header followed by + random blocks, and random MCC lines); SRT → VTT → SCC keeps text and frames. diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index d10c98d1..7ddf759f 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -1,8 +1,10 @@ //! FilmCraft captions. //! -//! - **Files:** [SubRip](srt) (`.srt`), [WebVTT](vtt) (`.vtt`) and [Scenarist SCC](scc) (`.scc`, -//! CEA-608 pop-on, roll-up and paint-on on read; pop-on on write) parse into a [`Document`] of -//! [`Cue`]s and write back. Readers are forgiving (BOM, UTF-16, Windows-1252 fallback, CRLF/CR, +//! - **Files:** [SubRip](srt) (`.srt`), [WebVTT](vtt) (`.vtt`), [Scenarist SCC](scc) (`.scc`, +//! CEA-608 pop-on, roll-up and paint-on on read; pop-on on write), [MacCaption MCC](mcc) +//! (`.mcc`, SMPTE ST 334-2 packets with CEA-608 and CEA-708), [EBU STL](stl) (`.stl`, EBU +//! Tech 3264) and [TTML](ttml) (`.ttml` IMSC1 Text profile, `.dfxp` DFXP) parse into a +//! [`Document`] of [`Cue`]s and write back. Readers are forgiving (BOM, UTF-16, Windows-1252 fallback, CRLF/CR, //! missing SRT indexes, `,` or `.` milliseconds, missing hours, no blank line between cues); //! writers are strict. WebVTT cue identifiers, cue settings, `` voices and //! STYLE/REGION/NOTE blocks are kept. @@ -15,8 +17,11 @@ pub mod burn; pub mod cea608; +pub mod mcc; pub mod scc; pub mod srt; +pub mod stl; +pub mod ttml; pub mod vtt; use filmcraft_project::{Caption, CaptionFormat, CaptionTrack, ClipId, TrackId}; @@ -28,15 +33,27 @@ pub enum Format { Srt, WebVtt, Scc, + /// MacCaption MCC (CEA-608 + CEA-708 in SMPTE ST 334-2 packets). + Mcc, + /// EBU STL (EBU Tech 3264). + Stl, + /// TTML, IMSC1 Text profile. + Ttml, + /// DFXP (TTML1). + Dfxp, } impl Format { - pub const ALL: [Format; 3] = [Format::Srt, Format::WebVtt, Format::Scc]; + pub const ALL: [Format; 7] = [Format::Srt, Format::WebVtt, Format::Scc, Format::Mcc, Format::Stl, Format::Ttml, Format::Dfxp]; pub fn name(self) -> &'static str { match self { Format::Srt => "SubRip (SRT)", Format::WebVtt => "WebVTT", Format::Scc => "Scenarist SCC (CEA-608)", + Format::Mcc => "MacCaption MCC (CEA-608/708)", + Format::Stl => "EBU STL", + Format::Ttml => "TTML (IMSC1)", + Format::Dfxp => "DFXP (TTML1)", } } pub fn extension(self) -> &'static str { @@ -44,6 +61,10 @@ impl Format { Format::Srt => "srt", Format::WebVtt => "vtt", Format::Scc => "scc", + Format::Mcc => "mcc", + Format::Stl => "stl", + Format::Ttml => "ttml", + Format::Dfxp => "dfxp", } } pub fn from_name(s: &str) -> Option { @@ -51,6 +72,10 @@ impl Format { "srt" | "subrip" => Some(Format::Srt), "vtt" | "webvtt" => Some(Format::WebVtt), "scc" | "cea608" | "cea-608" => Some(Format::Scc), + "mcc" | "maccaption" | "cea708" | "cea-708" => Some(Format::Mcc), + "stl" | "ebu-stl" | "ebustl" | "ebu" => Some(Format::Stl), + "ttml" | "imsc" | "imsc1" | "xml" => Some(Format::Ttml), + "dfxp" => Some(Format::Dfxp), _ => None, } } @@ -58,9 +83,15 @@ impl Format { pub fn track_format(self) -> CaptionFormat { match self { Format::Scc => CaptionFormat::Cea608, + Format::Mcc => CaptionFormat::Cea708, + Format::Stl => CaptionFormat::Teletext, _ => CaptionFormat::Subtitle, } } + /// Whether the format is a binary file (EBU STL). + pub fn is_binary(self) -> bool { + self == Format::Stl + } } #[derive(Debug, Clone, PartialEq, thiserror::Error)] @@ -138,6 +169,9 @@ impl Document { /// Guess a caption format from content (and the file extension as a hint). pub fn detect(bytes: &[u8], ext: Option<&str>) -> Option { + if stl::sniff(bytes) { + return Some(Format::Stl); + } let text = decode_text(&bytes[..bytes.len().min(4096)]); let head = text.trim_start_matches('\u{feff}').trim_start(); if head.starts_with("WEBVTT") { @@ -146,12 +180,21 @@ pub fn detect(bytes: &[u8], ext: Option<&str>) -> Option { if head.starts_with("Scenarist_SCC") { return Some(Format::Scc); } + if head.starts_with("File Format=MacCaption_MCC") { + return Some(Format::Mcc); + } let ext = ext.map(|e| e.trim_start_matches('.').to_ascii_lowercase()); + if (head.starts_with('<') && (head.contains("") || head.contains(":tt "))) + && (head.contains("ns/ttml") || head.contains("ttaf1")) + { + let legacy = head.contains("ttaf1") || head.contains("dfxp"); + return Some(if ext.as_deref() == Some("dfxp") || (legacy && ext.as_deref() != Some("ttml")) { Format::Dfxp } else { Format::Ttml }); + } let has_arrow_timing = head.lines().take(40).any(|l| srt::parse_timing_line(l).is_some()); match ext.as_deref() { Some("srt") if has_arrow_timing || head.is_empty() => Some(Format::Srt), Some("vtt") if has_arrow_timing => Some(Format::WebVtt), - Some("srt" | "vtt" | "scc") => None, + Some("srt" | "vtt" | "scc" | "mcc" | "stl" | "ttml" | "dfxp" | "xml") => None, _ if has_arrow_timing => Some(Format::Srt), _ => None, } @@ -159,24 +202,33 @@ pub fn detect(bytes: &[u8], ext: Option<&str>) -> Option { /// Parse caption bytes. pub fn parse(bytes: &[u8], format: Format) -> Result { + if format == Format::Stl { + return stl::parse(bytes); + } let text = decode_text(bytes); match format { Format::Srt => srt::parse(&text), Format::WebVtt => vtt::parse(&text), Format::Scc => scc::parse(&text), + Format::Mcc => mcc::parse(&text), + Format::Ttml | Format::Dfxp => ttml::parse(&text), + Format::Stl => unreachable!("handled above"), } } /// Options for [`write`]. #[derive(Clone, Copy, Debug)] pub struct WriteOptions { - /// SCC: drop-frame timecode (the usual choice for 29.97 fps SCC). + /// SCC / MCC: drop-frame timecode (the usual choice for 29.97 fps). pub drop_frame: bool, + /// The sequence frame rate: TTML (IMSC1) writes exact frame times at it; EBU STL picks + /// `STL25.01` or `STL30.01` from it (None = 25 fps). Other formats ignore it. + pub rate: Option, } impl Default for WriteOptions { fn default() -> Self { - Self { drop_frame: true } + Self { drop_frame: true, rate: None } } } @@ -186,6 +238,10 @@ pub fn write(doc: &Document, format: Format, opts: WriteOptions) -> Vec { 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(doc, opts.drop_frame).into_bytes(), + Format::Stl => stl::write(doc, opts.rate), + Format::Ttml => ttml::write(doc, ttml::Flavor::Imsc1, opts.rate, "en").into_bytes(), + Format::Dfxp => ttml::write(doc, ttml::Flavor::Dfxp, opts.rate, "en").into_bytes(), } } @@ -326,6 +382,16 @@ mod tests { assert_eq!(detect(b"Scenarist_SCC V1.0\n\n00:00:00;00\t9420\n", Some("scc")), Some(Format::Scc)); assert_eq!(detect(b"hello", Some("srt")), None); assert_eq!(detect(b"", None), None); + assert_eq!(detect(b"File Format=MacCaption_MCC V1.0\n", Some("mcc")), Some(Format::Mcc)); + let ttml = b""; + assert_eq!(detect(ttml, Some("ttml")), Some(Format::Ttml)); + assert_eq!(detect(ttml, Some("dfxp")), Some(Format::Dfxp)); + assert_eq!(detect(b"", Some("xml")), Some(Format::Dfxp)); + let stl = stl::write(&Document::default(), None); + assert_eq!(detect(&stl, Some("stl")), Some(Format::Stl)); + for f in Format::ALL { + assert_eq!(Format::from_name(f.extension()), Some(f)); + } } #[test] diff --git a/crates/captions/src/mcc.rs b/crates/captions/src/mcc.rs new file mode 100644 index 00000000..2c2be41d --- /dev/null +++ b/crates/captions/src/mcc.rs @@ -0,0 +1,694 @@ +//! MacCaption MCC (`.mcc`): closed-caption data as text, one line per video frame, each line a +//! SMPTE ST 334-2 caption distribution packet (CDP) carrying CEA-608 byte pairs and CEA-708 +//! (DTVCC) packets, written in hexadecimal with MacCaption's letter abbreviations. +//! +//! ```text +//! File Format=MacCaption_MCC V1.0 +//! … +//! Time Code Rate=30DF +//! +//! 00:00:01;00 T49S494F43ZZ72F4FC9420RFE...G7400Z4C +//! ``` +//! +//! - **Line:** `timecodedata`; data is ancillary DID/SDID `61 01`, a byte count, then the +//! CDP. Letters stand for byte runs: `G`–`O` = 1–9 × `FA 00 00`, `P` = `FB 80 80`, +//! `Q` = `FC 80 80`, `R` = `FD 80 80`, `S` = `96 69`, `T` = `61 01`, `U` = `E1 00 00 00`, +//! `Z` = `00`. +//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence +//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two +//! bytes)), optional time code / service info sections, footer `74`, the counter again and a +//! checksum that makes the packet's byte sum 0 mod 256. +//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption +//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608 +//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708 +//! service 1 data and padding. The CEA-708 captions emulate pop-on with two windows: each +//! caption is defined (hidden) and loaded into one window while the other shows, then a +//! DisplayWindows / HideWindows pair switches them on the caption's first frame; HideWindows +//! clears it at its end. +//! - **Reading:** every CDP's `cc_data`; channel 1 CEA-608 field-1 data is decoded with the SCC +//! decoder when present, otherwise CEA-708 service 1 (windows, text, CR, clear / display / +//! hide / toggle / delete windows, DefineWindow visibility); a cue starts whenever the visible +//! text changes. `Time Code Rate` 30DF / 30 → 29.97 fps, 60DF / 60 → 59.94, 24, 25, 50. + +use std::collections::BTreeMap; + +use filmcraft_time::{FrameRate, fields_to_frames, frames_to_fields}; + +use crate::cea608::with_parity; +use crate::{Cue, Document, Error, Result, scc}; + +/// MCC files written by FilmCraft are 29.97 fps. +pub const RATE: FrameRate = FrameRate::FPS_29_97; +const CC_COUNT: usize = 20; +/// cc_data triplets per frame left for CEA-708 (after the two CEA-608 fields). +const DTVCC_PAIRS: usize = CC_COUNT - 2; + +// --------------------------------------------------------------------------------------------- +// Hex with MacCaption abbreviations +// --------------------------------------------------------------------------------------------- + +const ABBREV: &[(char, &[u8])] = &[ + ('P', &[0xfb, 0x80, 0x80]), + ('Q', &[0xfc, 0x80, 0x80]), + ('R', &[0xfd, 0x80, 0x80]), + ('S', &[0x96, 0x69]), + ('T', &[0x61, 0x01]), + ('U', &[0xe1, 0x00, 0x00, 0x00]), + ('Z', &[0x00]), +]; + +/// Bytes → MCC text (uppercase hex with abbreviations). +pub fn compress(b: &[u8]) -> String { + let mut out = String::new(); + let mut i = 0; + 'outer: while i < b.len() { + let mut n = 0; + while n < 9 && b[i + 3 * n..].starts_with(&[0xfa, 0x00, 0x00]) { + n += 1; + } + if n > 0 { + out.push((b'G' + n as u8 - 1) as char); + i += 3 * n; + continue; + } + for (c, seq) in ABBREV { + if b[i..].starts_with(seq) { + out.push(*c); + i += seq.len(); + continue 'outer; + } + } + out.push_str(&format!("{:02X}", b[i])); + i += 1; + } + out +} + +/// MCC text → bytes (None if malformed). +pub fn expand(s: &str) -> Option> { + let mut out = Vec::new(); + let mut hex: Option = None; + for c in s.chars().filter(|c| !c.is_whitespace()) { + if let Some(d) = c.to_digit(16) { + match hex.take() { + Some(h) => out.push(h << 4 | d as u8), + None => hex = Some(d as u8), + } + continue; + } + if hex.is_some() { + return None; + } + let c = c.to_ascii_uppercase(); + match c { + 'G'..='O' => { + for _ in 0..(c as u8 - b'G' + 1) { + out.extend_from_slice(&[0xfa, 0x00, 0x00]); + } + } + _ => out.extend_from_slice(ABBREV.iter().find(|a| a.0 == c)?.1), + } + } + if hex.is_some() { None } else { Some(out) } +} + +// --------------------------------------------------------------------------------------------- +// CDP +// --------------------------------------------------------------------------------------------- + +/// One frame's caption data: a field-1 CEA-608 pair (without parity) and DTVCC pairs +/// `(start_of_packet, b1, b2)`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FrameData { + pub f1: Option<(u8, u8)>, + pub dtvcc: Vec<(bool, u8, u8)>, +} + +fn rate_code(r: FrameRate) -> u8 { + match r { + FrameRate::FPS_23_976 => 1, + FrameRate::FPS_24 => 2, + FrameRate::FPS_25 => 3, + FrameRate::FPS_29_97 => 4, + FrameRate::FPS_30 => 5, + FrameRate::FPS_50 => 6, + FrameRate::FPS_59_94 => 7, + FrameRate::FPS_60 => 8, + _ => 4, + } +} + +/// A CDP for one 29.97 fps frame, prefixed with the ancillary DID/SDID (`61 01`) and count. +pub fn build_cdp(seq: u16, data: &FrameData) -> Vec { + let mut cc: Vec = Vec::with_capacity(CC_COUNT * 3); + match data.f1 { + Some((a, b)) => cc.extend_from_slice(&[0xfc, with_parity(a), with_parity(b)]), + None => cc.extend_from_slice(&[0xfc, 0x80, 0x80]), + } + cc.extend_from_slice(&[0xfd, 0x80, 0x80]); + for &(start, a, b) in data.dtvcc.iter().take(DTVCC_PAIRS) { + cc.extend_from_slice(&[if start { 0xff } else { 0xfe }, a, b]); + } + while cc.len() < CC_COUNT * 3 { + cc.extend_from_slice(&[0xfa, 0x00, 0x00]); + } + let len = 7 + 2 + cc.len() + 4; + let [s1, s0] = seq.to_be_bytes(); + let mut cdp = vec![0x96, 0x69, len as u8, rate_code(RATE) << 4 | 0x0f, 0x43, s1, s0, 0x72, 0xe0 | CC_COUNT as u8]; + cdp.extend_from_slice(&cc); + cdp.extend_from_slice(&[0x74, s1, s0]); + let sum: u32 = cdp.iter().map(|&b| b as u32).sum(); + cdp.push(((256 - sum % 256) % 256) as u8); + let mut out = vec![0x61, 0x01, cdp.len() as u8]; + out.extend(cdp); + out +} + +/// The cc_data triplets of one line's bytes (an ancillary packet with a CDP, or a bare CDP). +pub fn cc_triplets(b: &[u8]) -> Option> { + let cdp = if b.starts_with(&[0x61, 0x01]) { b.get(3..)? } else { b }; + if !cdp.starts_with(&[0x96, 0x69]) { + return None; + } + let len = (*cdp.get(2)? as usize).min(cdp.len()); + let cdp = &cdp[..len]; + let mut i = 7; + let mut out = Vec::new(); + while i < cdp.len() { + match cdp[i] { + 0x71 => i += 5, + 0x72 => { + let n = (*cdp.get(i + 1)? & 0x1f) as usize; + for k in 0..n { + let at = i + 2 + 3 * k; + out.push([*cdp.get(at)?, *cdp.get(at + 1)?, *cdp.get(at + 2)?]); + } + i += 2 + 3 * n; + } + 0x73 => i += 2 + 7 * (*cdp.get(i + 1)? & 0x0f) as usize, + 0x74 => break, + 0x75..=0xef => i += 2 + *cdp.get(i + 1)? as usize, + _ => return None, + } + } + Some(out) +} + +// --------------------------------------------------------------------------------------------- +// CEA-708 service data +// --------------------------------------------------------------------------------------------- + +/// G2 characters we write / read (after EXT1). +const G2: &[(u8, char)] = &[ + (0x25, '…'), + (0x2a, 'Š'), + (0x2c, 'Œ'), + (0x31, '‘'), + (0x32, '’'), + (0x33, '“'), + (0x34, '”'), + (0x35, '•'), + (0x39, '™'), + (0x3a, 'š'), + (0x3c, 'œ'), + (0x3d, '℠'), + (0x3f, 'Ÿ'), + (0x76, '⅛'), + (0x77, '⅜'), + (0x78, '⅝'), + (0x79, '⅞'), +]; + +/// Service-data atoms (a command with its parameters, or one character) for a character. +fn char_atom(c: char) -> Vec { + match c { + '♪' => vec![0x7f], + ' '..='~' => vec![c as u8], + '\u{a0}'..='\u{ff}' => vec![c as u32 as u8], + _ => match G2.iter().find(|g| g.1 == c) { + Some((b, _)) => vec![0x10, *b], + None => vec![b'?'], + }, + } +} + +/// Atoms that define window `win` hidden and load `rows` into it. +fn load_atoms(win: u8, rows: &[String]) -> Vec> { + let rc = rows.len().clamp(1, 12) as u8 - 1; + let cols = rows.iter().map(|r| r.chars().count()).max().unwrap_or(1).clamp(1, 42) as u8 - 1; + let mut v = vec![ + // DefineWindow: hidden, row/column lock, relative position 90 % down / 50 % across, + // anchored at its bottom centre, rows / columns, window style 1, pen style 1 + vec![0x98 + win, 0x18, 0x80 | 90, 50, 7 << 4 | rc, cols, 1 << 3 | 1], + vec![0x88, 1 << win], // ClearWindows + ]; + for (i, r) in rows.iter().enumerate() { + if i > 0 { + v.push(vec![0x0d]); + } + v.extend(r.chars().map(char_atom)); + } + v +} + +/// Pack atoms into DTVCC packets for service 1 (each ≤ 34 bytes, so one fits in a frame). +fn packets(atoms: &[Vec], seq: &mut u8) -> Vec> { + let mut blocks: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for a in atoms { + if cur.len() + a.len() > 31 { + blocks.push(std::mem::take(&mut cur)); + } + cur.extend_from_slice(a); + } + if !cur.is_empty() { + blocks.push(cur); + } + blocks + .into_iter() + .map(|b| { + let mut p = vec![0, 1 << 5 | b.len() as u8]; + p.extend(b); + if p.len() % 2 == 1 { + p.push(0); + } + p[0] = (*seq & 3) << 6 | (p.len() / 2) as u8; + *seq = seq.wrapping_add(1); + p + }) + .collect() +} + +fn pairs_of(packet: &[u8]) -> Vec<(bool, u8, u8)> { + packet.chunks(2).enumerate().map(|(i, c)| (i == 0, c[0], *c.get(1).unwrap_or(&0))).collect() +} + +/// Frames → DTVCC pairs, with per-frame capacity. +struct Dtv { + frames: BTreeMap>, +} + +impl Dtv { + fn room(&self, f: i64) -> usize { + DTVCC_PAIRS - self.frames.get(&f).map_or(0, Vec::len) + } + /// Put a packet in the first frame ≥ `f` with room; returns that frame. + fn place(&mut self, mut f: i64, packet: &[u8]) -> i64 { + let pairs = pairs_of(packet); + while self.room(f) < pairs.len() { + f += 1; + } + self.frames.entry(f).or_default().extend(pairs); + f + } +} + +/// Schedule CEA-708 service 1 pop-on captions (see the module docs). +fn schedule_708(doc: &Document) -> BTreeMap> { + let mut d = Dtv { frames: BTreeMap::new() }; + let mut seq = 0u8; + let mut cursor = 0i64; + let mut cues: Vec<&Cue> = doc.cues.iter().filter(|c| c.end > c.start).collect(); + cues.sort_by_key(|c| c.start); + for (i, c) in cues.iter().enumerate() { + let win = (i % 2) as u8; + let s = scc::frame_of(c.start).max(cursor); + let e = scc::frame_of(c.end); + let rows = scc::wrap_rows(&filmcraft_project::plain_text(&c.text)); + let load = packets(&load_atoms(win, &rows), &mut seq); + let mut f = (s - load.len() as i64).max(cursor); + let mut last = f; + for p in &load { + last = d.place(f, p); + f = last; + } + let show = packets(&[vec![0x8a, 1 << (1 - win)], vec![0x89, 1 << win]], &mut seq); + let shown = d.place(s.max(last), &show[0]); + cursor = shown + 1; + let next_start = cues.get(i + 1).map(|n| scc::frame_of(n.start)); + if next_start != Some(e) { + let hide = packets(&[vec![0x8a, 1 << win]], &mut seq); + d.place(e.max(cursor), &hide[0]); + } + } + d.frames +} + +/// CEA-708 decoder state for service 1. +#[derive(Clone, Default)] +struct Win { + defined: bool, + visible: bool, + rows: Vec, +} + +struct Dec708 { + win: [Win; 8], + cur: usize, + packet: Vec, + want: usize, + open: Option<(i64, String)>, + cues: Vec<(i64, i64, String)>, +} + +impl Dec708 { + fn new() -> Self { + Dec708 { win: Default::default(), cur: 0, packet: Vec::new(), want: 0, open: None, cues: Vec::new() } + } + fn visible_text(&self) -> String { + let mut lines = Vec::new(); + for w in self.win.iter().filter(|w| w.defined && w.visible) { + lines.extend(w.rows.iter().map(|r| r.trim().to_string()).filter(|r| !r.is_empty())); + } + lines.join("\n") + } + fn sync(&mut self, f: i64) { + let now = self.visible_text(); + if self.open.as_ref().map(|o| o.1.as_str()) == Some(now.as_str()) || (self.open.is_none() && now.is_empty()) { + return; + } + if let Some((s, t)) = self.open.take() + && f > s + { + self.cues.push((s, f, t)); + } + if !now.is_empty() { + self.open = Some((f, now)); + } + } + fn pair(&mut self, f: i64, start: bool, a: u8, b: u8) { + if start { + self.packet.clear(); + let size = (a & 0x3f) as usize; + self.want = if size == 0 { 128 } else { size * 2 }; + } else if self.want == 0 { + return; + } + self.packet.extend_from_slice(&[a, b]); + if self.packet.len() >= self.want { + let p = std::mem::take(&mut self.packet); + self.want = 0; + self.service_blocks(&p[1..]); + self.sync(f); + } + } + fn service_blocks(&mut self, mut b: &[u8]) { + while let Some(&h) = b.first() { + let (svc, len) = (h >> 5, (h & 0x1f) as usize); + if h == 0 { + break; + } + let (svc, skip) = if svc == 7 { (b.get(1).map_or(0, |x| x & 0x3f), 2) } else { (svc, 1) }; + let data = b.get(skip..skip + len).unwrap_or(&[]); + if svc == 1 { + self.service_data(data); + } + b = b.get(skip + len..).unwrap_or(&[]); + } + } + fn w(&mut self) -> &mut Win { + &mut self.win[self.cur] + } + fn put(&mut self, c: char) { + let w = self.w(); + if w.rows.is_empty() { + w.rows.push(String::new()); + } + w.rows.last_mut().expect("row").push(c); + } + fn bitmap(&mut self, m: u8, f: impl Fn(&mut Win)) { + for (i, w) in self.win.iter_mut().enumerate() { + if m & (1 << i) != 0 { + f(w); + } + } + } + fn service_data(&mut self, d: &[u8]) { + let mut i = 0; + let arg = |k: usize| d.get(k).copied().unwrap_or(0); + while i < d.len() { + let c = d[i]; + i += 1; + match c { + 0x03 | 0x00 => {} + 0x08 => { + if let Some(r) = self.w().rows.last_mut() { + r.pop(); + } + } + 0x0c => self.w().rows = vec![String::new()], + 0x0d => self.w().rows.push(String::new()), + 0x0e => { + if let Some(r) = self.w().rows.last_mut() { + r.clear(); + } + } + 0x10 => { + let x = arg(i); + i += 1; + match x { + 0x00..=0x07 => {} + 0x08..=0x0f => i += 1, + 0x10..=0x17 => i += 2, + 0x18..=0x1f => i += 3, + 0x20 | 0x21 => self.put(' '), + 0x20..=0x7f => { + if let Some((_, ch)) = G2.iter().find(|g| g.0 == x) { + self.put(*ch); + } + } + 0x80..=0x87 => i += 4, + 0x88..=0x8f => i += 5, + 0x90..=0x9f => i += 1 + (arg(i) & 0x1f) as usize, + _ => {} + } + } + 0x11..=0x17 => i += 1, + 0x18..=0x1f => i += 2, + 0x20..=0x7e => self.put(c as char), + 0x7f => self.put('♪'), + 0x80..=0x87 => self.cur = (c - 0x80) as usize, + 0x88 => { + self.bitmap(arg(i), |w| w.rows = vec![String::new()]); + i += 1; + } + 0x89 => { + self.bitmap(arg(i), |w| w.visible = true); + i += 1; + } + 0x8a => { + self.bitmap(arg(i), |w| w.visible = false); + i += 1; + } + 0x8b => { + self.bitmap(arg(i), |w| w.visible = !w.visible); + i += 1; + } + 0x8c => { + self.bitmap(arg(i), |w| *w = Win::default()); + i += 1; + } + 0x8d => i += 1, + 0x8e | 0x8f | 0x93..=0x96 => {} + 0x90 | 0x92 => i += 2, + 0x91 => i += 3, + 0x97 => i += 4, + 0x98..=0x9f => { + let n = (c - 0x98) as usize; + let vis = arg(i) & 0x20 != 0; + let w = &mut self.win[n]; + if !w.defined { + *w = Win { defined: true, visible: vis, rows: vec![String::new()] }; + } else { + w.visible = vis; + } + self.cur = n; + i += 6; + } + 0xa0..=0xff => self.put(c as char), + _ => {} + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// File +// --------------------------------------------------------------------------------------------- + +/// Write `doc` as MCC: CEA-608 field 1 and CEA-708 service 1 (see the module docs). +pub fn write(doc: &Document, drop_frame: bool) -> String { + write_with(doc, drop_frame, true, true) +} + +/// [`write`] choosing which caption streams to include. +pub fn write_with(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool) -> String { + let f1 = if cea608 { scc::schedule(doc) } else { BTreeMap::new() }; + let dtv = if cea708 { schedule_708(doc) } else { BTreeMap::new() }; + let mut out = String::from("File Format=MacCaption_MCC V1.0\n\n"); + out.push_str("///////////////////////////////////////////////////////////////////////////////////\n"); + out.push_str("// Closed captions written by FilmCraft: SMPTE ST 334-2 caption distribution packets\n"); + out.push_str("// carrying CEA-608 field 1 and CEA-708 service 1 data, one packet per frame.\n"); + out.push_str("///////////////////////////////////////////////////////////////////////////////////\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", + h >> 32, + (h >> 16) & 0xffff, + h & 0xfff, + (h >> 20) & 0xfff, + h.rotate_left(29) & 0xffff_ffff_ffff + )); + out.push_str("Creation Program=FilmCraft\n"); + out.push_str(&format!("Time Code Rate={}\n\n", if drop_frame { "30DF" } else { "30" })); + let first = f1.keys().next().copied().into_iter().chain(dtv.keys().next().copied()).min(); + let last = f1.keys().next_back().copied().into_iter().chain(dtv.keys().next_back().copied()).max(); + if let (Some(a), Some(b)) = (first, last) { + for (seq, f) in (a..=b + 1).enumerate() { + let data = FrameData { f1: f1.get(&f).copied(), dtvcc: dtv.get(&f).cloned().unwrap_or_default() }; + let (_, hh, mm, ss, ff) = frames_to_fields(f, 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 as u16, &data)))); + } + } + out +} + +fn tc_rate(v: &str) -> Option<(FrameRate, bool)> { + Some(match v.trim().to_ascii_uppercase().as_str() { + "30DF" => (FrameRate::FPS_29_97, true), + "30" => (FrameRate::FPS_29_97, false), + "60DF" => (FrameRate::FPS_59_94, true), + "60" => (FrameRate::FPS_59_94, false), + "24" => (FrameRate::FPS_24, false), + "25" => (FrameRate::FPS_25, false), + "50" => (FrameRate::FPS_50, false), + _ => return None, + }) +} + +pub fn parse(text: &str) -> Result { + let mut lines = text.lines().enumerate(); + match lines.by_ref().find(|(_, l)| !l.trim().is_empty()) { + Some((_, l)) if l.trim().starts_with("File Format=MacCaption_MCC") => {} + _ => return Err(Error::NotFormat("MacCaption MCC")), + } + let mut rate = (RATE, true); + let mut doc = Document::default(); + let mut f1: Vec<(i64, u8, u8)> = Vec::new(); + let mut dtv: Vec<(i64, bool, u8, u8)> = Vec::new(); + let mut any608 = false; + for (n, l) in lines { + let l = l.trim_end(); + if l.trim().is_empty() || l.starts_with("//") { + continue; + } + if let Some(v) = l.strip_prefix("Time Code Rate=") { + rate = tc_rate(v).ok_or_else(|| Error::Syntax { line: n + 1, msg: format!("unsupported time code rate \"{v}\"") })?; + continue; + } + if l.contains('=') { + continue; // other key=value header lines + } + let Some((tc, data)) = l.trim_start().split_once(['\t', ' ']) else { + continue; + }; + let tc = tc.split('.').next().unwrap_or(tc); + let parts: Vec = tc.split([':', ';']).filter_map(|p| p.parse().ok()).collect(); + let [h, m, s, f] = parts[..] else { + return Err(Error::Syntax { line: n + 1, msg: format!("bad timecode \"{tc}\"") }); + }; + let frame = fields_to_frames(h, m, s, f, rate.0, rate.1 || tc.contains(';')); + let Some(bytes) = expand(data) else { + doc.warnings.push(format!("line {}: bad caption data", n + 1)); + continue; + }; + let Some(trips) = cc_triplets(&bytes) else { + doc.warnings.push(format!("line {}: not a caption distribution packet", n + 1)); + continue; + }; + for [m0, a, b] in trips { + if m0 & 0x04 == 0 { + continue; + } + match m0 & 0x03 { + 0 => { + any608 |= a & 0x7f != 0 || b & 0x7f != 0; + f1.push((frame, a, b)); + } + 1 => {} + t => dtv.push((frame, t == 3, a, b)), + } + } + } + if any608 { + doc.cues = scc::decode_pairs(f1, rate.0); + } else { + let mut d = Dec708::new(); + let mut last = 0; + for (f, start, a, b) in dtv { + d.pair(f, start, a, b); + last = f + 1; + } + if let Some((s, t)) = d.open.take() { + d.cues.push((s, last.max(s + 1), t)); + } + doc.cues = d.cues.into_iter().map(|(s, e, text)| Cue { start: rate.0.tick_of(s), end: rate.0.tick_of(e), text, ..Default::default() }).collect(); + } + Ok(doc) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cue(s: i64, e: i64, text: &str) -> Cue { + Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() } + } + + #[test] + fn abbreviations_round_trip() { + let b = [0x61, 0x01, 0x49, 0x96, 0x69, 0xfa, 0, 0, 0xfa, 0, 0, 0xfc, 0x80, 0x80, 0x00, 0x12, 0xe1, 0, 0, 0, 0xfb, 0x80, 0x80, 0xfd, 0x80, 0x80]; + let s = compress(&b); + assert_eq!(s, "T49SHQZ12UPR"); + assert_eq!(expand(&s).unwrap(), b); + assert_eq!(expand("T52S5"), None); + } + + #[test] + fn cdp_structure_and_checksum() { + let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] }; + let b = build_cdp(7, &d); + assert_eq!(&b[..3], &[0x61, 0x01, 73]); + let cdp = &b[3..]; + assert_eq!(cdp.len(), 73); + assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]); + assert_eq!(cdp.iter().map(|&x| x as u32).sum::() % 256, 0, "checksum"); + let t = cc_triplets(&b).unwrap(); + assert_eq!(t.len(), 20); + assert_eq!(t[0], [0xfc, 0x94, 0x20]); + assert_eq!(t[2], [0xff, 0x02, 0x21]); + assert_eq!(t[19], [0xfa, 0, 0]); + } + + #[test] + fn writes_and_reads_608_and_708() { + let doc = Document { + cues: vec![cue(60, 120, "Hello, world!"), cue(120, 200, "Second caption\nwith two lines"), cue(260, 300, "Café “q” ♪")], + ..Default::default() + }; + let s = write(&doc, true); + assert!(s.starts_with("File Format=MacCaption_MCC V1.0\n")); + assert!(s.contains("Time Code Rate=30DF")); + let back = parse(&s).unwrap(); + let got: Vec<(i64, i64, &str)> = back.cues.iter().map(|c| (RATE.frame_at(c.start), RATE.frame_at(c.end), c.text.as_str())).collect(); + assert_eq!(got, vec![(60, 120, "Hello, world!"), (120, 200, "Second caption\nwith two lines"), (260, 300, "Café “q” ♪")]); + // 708 only + let s708 = write_with(&doc, false, false, true); + let back = parse(&s708).unwrap(); + let got: Vec<(i64, i64, &str)> = back.cues.iter().map(|c| (RATE.frame_at(c.start), RATE.frame_at(c.end), c.text.as_str())).collect(); + assert_eq!(got, vec![(60, 120, "Hello, world!"), (120, 200, "Second caption\nwith two lines"), (260, 300, "Café “q” ♪")]); + assert!(parse("garbage").is_err()); + } +} diff --git a/crates/captions/src/scc.rs b/crates/captions/src/scc.rs index a374cf40..1d2fafbf 100644 --- a/crates/captions/src/scc.rs +++ b/crates/captions/src/scc.rs @@ -242,6 +242,24 @@ impl Decoder { } } +/// Decode channel-1 CEA-608 byte pairs `(frame, b1, b2)` (with or without parity; frames +/// ascending) into cues at `rate`. A caption still showing after the last pair stays up for one +/// second more. +pub fn decode_pairs(pairs: impl IntoIterator, rate: FrameRate) -> Vec { + let mut dec = Decoder::new(); + let mut last_frame = 0; + for (f, b1, b2) in pairs { + dec.pair(f, b1, b2); + last_frame = last_frame.max(f + 1); + } + let one_second = rate.frame_at(Tick(filmcraft_time::TICKS_PER_SECOND)).max(1); + if let Some((s, text)) = dec.open.take() { + let end = last_frame.max(s + 1) + one_second; + dec.cues.push((s, end, text)); + } + dec.cues.into_iter().map(|(s, e, text)| Cue { start: rate.tick_of(s), end: rate.tick_of(e), text, ..Default::default() }).collect() +} + pub fn parse(text: &str) -> Result { let mut lines = text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()); match lines.next() { @@ -369,7 +387,9 @@ fn word((a, b): (u8, u8)) -> u16 { (cea608::with_parity(a) as u16) << 8 | cea608::with_parity(b) as u16 } -pub fn write(doc: &Document, drop_frame: bool) -> String { +/// Schedule pop-on CEA-608 byte pairs (without parity) for `doc`, one pair per 29.97 fps frame, +/// so each caption's End of Caption lands on its start frame (see the module docs). +pub fn schedule(doc: &Document) -> BTreeMap { let mut occ: BTreeMap = BTreeMap::new(); let mut cursor = 0i64; // first frame this cue's load may use let mut prev_end: Option = None; @@ -422,6 +442,16 @@ pub fn write(doc: &Document, drop_frame: bool) -> String { let f = place_free_from(&mut occ, pe, edm); occ.insert(f + 1, edm); } + occ +} + +/// The parity-coded SCC code word of a byte pair. +pub fn code_word(p: (u8, u8)) -> u16 { + word(p) +} + +pub fn write(doc: &Document, drop_frame: bool) -> String { + let occ = schedule(doc); let mut out = String::from(HEADER); out.push_str("\n\n"); let mut line: Option<(i64, Vec)> = None; diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs new file mode 100644 index 00000000..7b4a7b47 --- /dev/null +++ b/crates/captions/src/stl.rs @@ -0,0 +1,466 @@ +//! EBU STL (`.stl`): the EBU subtitling data exchange format, EBU Tech 3264-E (1991). +//! +//! A file is a 1024-byte General Subtitle Information (GSI) block of ASCII fields followed by +//! 128-byte Text and Timing Information (TTI) blocks. Each TTI block carries a subtitle number, +//! an extension block number (`0xFF` = last block of the subtitle), in / out timecodes (hours, +//! minutes, seconds, frames as binary bytes), vertical position, justification, a comment flag +//! and a 112-byte text field (`0x8A` = new line, `0x8F` = unused; `0x80`/`0x81` italics on/off, +//! `0x82`/`0x83` underline on/off; `0x00`–`0x1F` teletext control codes are skipped on read). +//! +//! - **Frame rates:** `STL25.01` is 25 fps; `STL30.01` is read and written as 29.97 fps with +//! non-drop-frame timecode labels (how it is used in practice). Timecodes are made relative to +//! the start-of-programme timecode (TCP). +//! - **Text:** character code table `00` (Latin, ISO/IEC 6937): ASCII, the 0xA0–0xFF specials and +//! accented letters as a non-spacing diacritic (0xC1–0xCF) followed by the base letter. Other +//! tables are read as Latin. Characters with no 6937 code are written as `?`. +//! - Long subtitles continue in extension blocks; user-data (`EBN 0xFE`) and comment blocks are +//! skipped. + +use filmcraft_time::{FrameRate, Tick, fields_to_frames, frames_to_fields}; + +use crate::{Cue, Document, Error, Result}; + +const GSI: usize = 1024; +const TTI: usize = 128; +const TF: usize = 112; + +/// Read the frame rate from the Disk Format Code. +fn rate_of(dfc: &[u8]) -> Option { + match dfc { + b"STL25.01" => Some(FrameRate::FPS_25), + b"STL30.01" => Some(FrameRate::FPS_29_97), + _ => None, + } +} + +/// Whether bytes look like an EBU STL file. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.len() >= GSI && bytes[3..11].starts_with(b"STL") && rate_of(&bytes[3..11]).is_some() +} + +// --------------------------------------------------------------------------------------------- +// ISO/IEC 6937 +// --------------------------------------------------------------------------------------------- + +const SPECIALS: &[(u8, char)] = &[ + (0xa0, '\u{a0}'), + (0xa1, '¡'), + (0xa2, '¢'), + (0xa3, '£'), + (0xa4, '$'), + (0xa5, '¥'), + (0xa6, '#'), + (0xa7, '§'), + (0xa8, '¤'), + (0xa9, '‘'), + (0xaa, '“'), + (0xab, '«'), + (0xac, '←'), + (0xad, '↑'), + (0xae, '→'), + (0xaf, '↓'), + (0xb0, '°'), + (0xb1, '±'), + (0xb2, '²'), + (0xb3, '³'), + (0xb4, '×'), + (0xb5, 'µ'), + (0xb6, '¶'), + (0xb7, '·'), + (0xb8, '÷'), + (0xb9, '’'), + (0xba, '”'), + (0xbb, '»'), + (0xbc, '¼'), + (0xbd, '½'), + (0xbe, '¾'), + (0xbf, '¿'), + (0xd0, '―'), + (0xd1, '¹'), + (0xd2, '®'), + (0xd3, '©'), + (0xd4, '™'), + (0xd5, '♪'), + (0xd6, '¬'), + (0xd7, '¦'), + (0xdc, '⅛'), + (0xdd, '⅜'), + (0xde, '⅝'), + (0xdf, '⅞'), + (0xe0, 'Ω'), + (0xe1, 'Æ'), + (0xe2, 'Đ'), + (0xe3, 'ª'), + (0xe4, 'Ħ'), + (0xe6, 'IJ'), + (0xe7, 'Ŀ'), + (0xe8, 'Ł'), + (0xe9, 'Ø'), + (0xea, 'Œ'), + (0xeb, 'º'), + (0xec, 'Þ'), + (0xed, 'Ŧ'), + (0xee, 'Ŋ'), + (0xef, 'ʼn'), + (0xf0, 'ĸ'), + (0xf1, 'æ'), + (0xf2, 'đ'), + (0xf3, 'ð'), + (0xf4, 'ħ'), + (0xf5, 'ı'), + (0xf6, 'ij'), + (0xf7, 'ŀ'), + (0xf8, 'ł'), + (0xf9, 'ø'), + (0xfa, 'œ'), + (0xfb, 'ß'), + (0xfc, 'þ'), + (0xfd, 'ŧ'), + (0xfe, 'ŋ'), +]; + +/// Non-spacing diacritics 0xC1–0xCF: (code, base letters, composed letters). +const DIACRITICS: &[(u8, &str, &str)] = &[ + (0xc1, "AEIOUaeiou", "ÀÈÌÒÙàèìòù"), + (0xc2, "ACEILNORSUYZacegilnorsuyz", "ÁĆÉÍĹŃÓŔŚÚÝŹáćéģíĺńóŕśúýź"), + (0xc3, "ACEGHIJOSUWYaceghijosuwy", "ÂĈÊĜĤÎĴÔŜÛŴŶâĉêĝĥîĵôŝûŵŷ"), + (0xc4, "AINOUainou", "ÃĨÑÕŨãĩñõũ"), + (0xc5, "AEIOUaeiou", "ĀĒĪŌŪāēīōū"), + (0xc6, "AGUagu", "ĂĞŬăğŭ"), + (0xc7, "CEGIZcegz", "ĊĖĠİŻċėġż"), + (0xc8, "AEIOUYaeiouy", "ÄËÏÖÜŸäëïöüÿ"), + (0xca, "AUau", "ÅŮåů"), + (0xcb, "CGKLNRSTcklnrst", "ÇĢĶĻŅŖŞŢçķļņŗşţ"), + (0xcd, "OUou", "ŐŰőű"), + (0xce, "AEIUaeiu", "ĄĘĮŲąęįų"), + (0xcf, "CDELNRSTZcdelnrstz", "ČĎĚĽŇŘŠŤŽčďěľňřšťž"), +]; + +fn compose(d: u8, base: u8) -> Option { + let (_, bases, comp) = DIACRITICS.iter().find(|x| x.0 == d)?; + let i = bases.bytes().position(|b| b == base)?; + comp.chars().nth(i) +} + +/// ISO 6937 bytes for a character (None = not representable). +pub fn encode_6937(c: char) -> Option> { + if (' '..='~').contains(&c) { + return Some(vec![c as u8]); + } + if let Some((b, _)) = SPECIALS.iter().find(|x| x.1 == c && x.0 != 0xa4 && x.0 != 0xa6) { + return Some(vec![*b]); + } + for (d, bases, comp) in DIACRITICS { + if let Some(i) = comp.chars().position(|x| x == c) { + return Some(vec![*d, bases.as_bytes()[i]]); + } + } + None +} + +/// Decode a text field (up to the first unused byte) into caption text with `` / `` tags. +fn decode_tf(tf: &[u8], out: &mut String, italic: &mut bool, underline: &mut bool) { + let mut i = 0; + while i < tf.len() { + let b = tf[i]; + i += 1; + match b { + 0x8f => {} + 0x8a => { + // a line break; repeated 0x8A (double-height rows) count once + if !out.ends_with('\n') || out.is_empty() { + out.push('\n'); + } + } + 0x80 if !*italic => { + out.push_str(""); + *italic = true; + } + 0x81 if *italic => { + out.push_str(""); + *italic = false; + } + 0x82 if !*underline => { + out.push_str(""); + *underline = true; + } + 0x83 if *underline => { + out.push_str(""); + *underline = false; + } + 0x00..=0x1f | 0x7f..=0x9f => {} + 0x20..=0x7e => out.push(b as char), + 0xc1..=0xcf => { + if let Some(&base) = tf.get(i) + && let Some(c) = compose(b, base) + { + out.push(c); + i += 1; + } + } + _ => { + if let Some((_, c)) = SPECIALS.iter().find(|x| x.0 == b) { + out.push(*c); + } + } + } + } +} + +/// Caption text → text-field bytes (``, `` become control codes; other tags dropped). +fn encode_text(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + while let Some(ch) = rest.chars().next() { + if ch == '<' + && let Some(end) = rest.find('>') + { + match rest[1..end].trim().to_ascii_lowercase().as_str() { + "i" => out.push(0x80), + "/i" => out.push(0x81), + "u" => out.push(0x82), + "/u" => out.push(0x83), + _ => {} + } + rest = &rest[end + 1..]; + continue; + } + if ch == '\n' { + out.push(0x8a); + } else { + out.extend(encode_6937(ch).unwrap_or_else(|| vec![b'?'])); + } + rest = &rest[ch.len_utf8()..]; + } + out +} + +// --------------------------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------------------------- + +fn ascii_field(b: &[u8]) -> String { + String::from_utf8_lossy(b).trim().to_string() +} + +fn tc_frames(b: &[u8], rate: FrameRate) -> i64 { + fields_to_frames(b[0] as i64, b[1] as i64, b[2] as i64, b[3] as i64, rate, false) +} + +pub fn parse(bytes: &[u8]) -> Result { + if !sniff(bytes) { + return Err(Error::NotFormat("EBU STL")); + } + let rate = rate_of(&bytes[3..11]).expect("sniffed"); + let gsi = &bytes[..GSI]; + // start of programme (HHMMSSFF, ASCII) + let tcp = ascii_field(&gsi[256..264]); + let start = if tcp.len() == 8 && tcp.bytes().all(|b| b.is_ascii_digit()) { + let n = |i: usize| tcp[i..i + 2].parse::().unwrap_or(0); + fields_to_frames(n(0), n(2), n(4), n(6), rate, false) + } else { + 0 + }; + let mut doc = Document::default(); + let mut cur: Option<(u16, i64, i64, String, bool, bool)> = None; + let finish = |c: Option<(u16, i64, i64, String, bool, bool)>, doc: &mut Document| { + if let Some((_, tin, tout, mut text, italic, underline)) = c { + if italic { + text.push_str(""); + } + if underline { + text.push_str(""); + } + let text = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect::>().join("\n"); + if tout > tin && !text.is_empty() { + doc.cues.push(Cue { start: rate.tick_of((tin - start).max(0)), end: rate.tick_of((tout - start).max(0)), text, ..Default::default() }); + } + } + }; + for blk in bytes[GSI..].chunks_exact(TTI) { + let sn = u16::from_le_bytes([blk[1], blk[2]]); + let ebn = blk[3]; + let cf = blk[15]; + if ebn == 0xfe || cf != 0 { + continue; // user data / comment + } + let tin = tc_frames(&blk[5..9], rate); + let tout = tc_frames(&blk[9..13], rate); + let same = cur.as_ref().is_some_and(|c| c.0 == sn); + if !same { + finish(cur.take(), &mut doc); + cur = Some((sn, tin, tout, String::new(), false, false)); + } + if let Some(c) = cur.as_mut() { + let (mut it, mut ul) = (c.4, c.5); + decode_tf(&blk[16..16 + TF], &mut c.3, &mut it, &mut ul); + c.4 = it; + c.5 = ul; + } + if ebn == 0xff { + finish(cur.take(), &mut doc); + } + } + finish(cur.take(), &mut doc); + if bytes.len() > GSI && !(bytes.len() - GSI).is_multiple_of(TTI) { + doc.warnings.push("trailing bytes after the last TTI block".into()); + } + Ok(doc) +} + +// --------------------------------------------------------------------------------------------- +// Writer +// --------------------------------------------------------------------------------------------- + +fn put(buf: &mut [u8], at: usize, len: usize, s: &str) { + let b = s.as_bytes(); + for i in 0..len { + buf[at + i] = *b.get(i).unwrap_or(&b' '); + } +} + +/// Write `doc` at `rate` (25 fps → `STL25.01`; 29.97 / 30 → `STL30.01`; others are written at 25). +pub fn write(doc: &Document, rate: Option) -> Vec { + let (rate, dfc) = match rate { + Some(r) if r == FrameRate::FPS_29_97 || r == FrameRate::FPS_30 => (FrameRate::FPS_29_97, "STL30.01"), + _ => (FrameRate::FPS_25, "STL25.01"), + }; + // TTI blocks first (the GSI counts them) + let mut ttis: Vec<[u8; TTI]> = Vec::new(); + let mut n_subs = 0u16; + let mut first_in = None; + for c in doc.cues.iter().filter(|c| c.end > c.start) { + let sn = n_subs; + n_subs = n_subs.wrapping_add(1); + let tin = rate.frame_at(rate.snap_nearest(c.start)); + let tout = rate.frame_at(rate.snap_nearest(c.end)).max(tin + 1); + first_in.get_or_insert(tin); + let text = encode_text(&c.text); + let chunks: Vec<&[u8]> = if text.is_empty() { vec![&[][..]] } else { split_tf(&text) }; + let lines = c.text.lines().count().clamp(1, 11); + for (k, chunk) in chunks.iter().enumerate() { + let mut b = [0u8; TTI]; + b[0] = 0; + b[1..3].copy_from_slice(&sn.to_le_bytes()); + b[3] = if k + 1 == chunks.len() { 0xff } else { k.min(0xfd) as u8 }; + b[4] = 0; + for (at, f) in [(5, tin), (9, tout)] { + let (_, h, m, s, fr) = frames_to_fields(f, rate, false); + b[at..at + 4].copy_from_slice(&[h.min(255) as u8, m as u8, s as u8, fr as u8]); + } + b[13] = (22 - 2 * (lines - 1)) as u8; + b[14] = 2; // centred + b[15] = 0; + b[16..].fill(0x8f); + b[16..16 + chunk.len()].copy_from_slice(chunk); + ttis.push(b); + } + } + let mut gsi = [b' '; GSI]; + put(&mut gsi, 0, 3, "850"); + put(&mut gsi, 3, 8, dfc); + put(&mut gsi, 11, 1, "0"); + put(&mut gsi, 12, 2, "00"); + put(&mut gsi, 14, 2, "09"); + put(&mut gsi, 16, 32, "FilmCraft"); + put(&mut gsi, 224, 6, "000101"); + put(&mut gsi, 230, 6, "000101"); + put(&mut gsi, 236, 2, "00"); + put(&mut gsi, 238, 5, &format!("{:05}", ttis.len().min(99_999))); + put(&mut gsi, 243, 5, &format!("{:05}", n_subs)); + put(&mut gsi, 248, 3, "001"); + put(&mut gsi, 251, 2, "40"); + put(&mut gsi, 253, 2, "23"); + put(&mut gsi, 255, 1, "1"); + put(&mut gsi, 256, 8, "00000000"); + let (_, h, m, s, f) = frames_to_fields(first_in.unwrap_or(0), rate, false); + put(&mut gsi, 264, 8, &format!("{h:02}{m:02}{s:02}{f:02}")); + put(&mut gsi, 272, 1, "1"); + put(&mut gsi, 273, 1, "1"); + let mut out = gsi.to_vec(); + for t in ttis { + out.extend_from_slice(&t); + } + out +} + +/// Split text-field bytes into ≤ 112-byte chunks without splitting a diacritic from its letter. +fn split_tf(text: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut i = 0; + while i < text.len() { + let mut end = (i + TF).min(text.len()); + if end < text.len() && (0xc1..=0xcf).contains(&text[end - 1]) { + end -= 1; + } + out.push(&text[i..end]); + i = end; + } + out +} + +/// Frame of `t` on the STL grid of `rate` (see [`write`]). +pub fn frame_of(t: Tick, rate: FrameRate) -> i64 { + rate.frame_at(rate.snap_nearest(t)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso6937_round_trip() { + for c in "Ñandú café Škoda Łódź Ærø ß £ ½ ♪ « » ‘q’ “q”".chars() { + let b = encode_6937(c).unwrap_or_else(|| panic!("{c}")); + let mut s = String::new(); + decode_tf(&b, &mut s, &mut false, &mut false); + assert_eq!(s, c.to_string()); + } + assert_eq!(encode_6937('€'), None); + } + + #[test] + fn writes_gsi_and_extension_blocks() { + let long = "x".repeat(150); + let doc = Document { + cues: vec![ + Cue { start: FrameRate::FPS_25.tick_of(25), end: FrameRate::FPS_25.tick_of(75), text: "Hello\nWörld".into(), ..Default::default() }, + Cue { start: FrameRate::FPS_25.tick_of(100), end: FrameRate::FPS_25.tick_of(150), text: long.clone(), ..Default::default() }, + ], + ..Default::default() + }; + let b = write(&doc, Some(FrameRate::FPS_25)); + assert_eq!(&b[3..11], b"STL25.01"); + assert_eq!(b.len(), GSI + 3 * TTI, "the long subtitle takes two blocks"); + assert_eq!(&b[238..243], b"00003"); + assert_eq!(&b[243..248], b"00002"); + let tti0 = &b[GSI..GSI + TTI]; + assert_eq!(&tti0[5..9], &[0, 0, 1, 0]); + assert_eq!(&tti0[9..13], &[0, 0, 3, 0]); + assert_eq!(tti0[3], 0xff); + assert_eq!(&tti0[16..24], &[0x80, b'H', b'e', b'l', b'l', b'o', 0x81, 0x8a]); + assert_eq!(b[GSI + TTI + 3], 0); + assert_eq!(b[GSI + 2 * TTI + 3], 0xff); + let back = parse(&b).unwrap(); + assert_eq!(back.cues.len(), 2); + assert_eq!(back.cues[0].text, "Hello\nWörld"); + assert_eq!(back.cues[1].text, long); + assert_eq!(back.cues[1].start, FrameRate::FPS_25.tick_of(100)); + assert!(parse(&b[..500]).is_err()); + } + + #[test] + fn programme_start_is_subtracted() { + let doc = Document { + cues: vec![Cue { start: FrameRate::FPS_25.tick_of(250), end: FrameRate::FPS_25.tick_of(300), text: "A".into(), ..Default::default() }], + ..Default::default() + }; + let mut b = write(&doc, None); + // re-stamp as a programme starting at 10:00:00:00 with absolute in-cues + b[256..264].copy_from_slice(b"10000000"); + b[GSI + 5] = 10; + b[GSI + 9] = 10; + let back = parse(&b).unwrap(); + assert_eq!(back.cues[0].start, FrameRate::FPS_25.tick_of(250)); + } +} diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs new file mode 100644 index 00000000..20e090f7 --- /dev/null +++ b/crates/captions/src/ttml.rs @@ -0,0 +1,547 @@ +//! TTML (W3C Timed Text Markup Language): IMSC1 Text profile (`.ttml`, written per TTML2 / +//! IMSC 1.1 Text) and DFXP (`.dfxp`, TTML1's Distribution Format Exchange Profile). +//! +//! **Writing.** One `

` per cue inside `body/div`, lines separated by `
`; ``, `` and +//! `` become ``s with `tts:fontStyle="italic"`, `tts:fontWeight="bold"` and +//! `tts:textDecoration="underline"`. IMSC1 files signal `ttp:profile` (the IMSC1 Text profile +//! designator), a bottom region and a default style. With a frame rate, IMSC1 times are frame +//! 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): +//! 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 +//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`. + +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; + +use crate::{Cue, Document, Error, Result, TICKS_PER_MS}; + +/// Which TTML flavour to write. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Flavor { + /// IMSC1 Text profile (TTML2 namespaces). + Imsc1, + /// DFXP (TTML1). + Dfxp, +} + +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 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"; + +fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\'' => o.push_str("'"), + c => o.push(c), + } + } + o +} + +fn clock(t: Tick) -> String { + let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS; + format!("{:02}:{:02}:{:02}.{:03}", ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000) +} + +/// Time expression for `t`: frames at `rate` when it is on a frame boundary, else clock time. +fn time_expr(t: Tick, rate: Option) -> String { + match rate { + Some(r) if r.tick_of(r.frame_at(t)) == t => format!("{}f", r.frame_at(t)), + _ => clock(t), + } +} + +/// Caption text (`\n` lines, ``/``/`` tags) → TTML inline content. +fn inline(text: &str) -> String { + let mut out = String::new(); + let mut open: Vec<&str> = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + if let Some(after) = rest.strip_prefix('<') + && let Some(end) = after.find('>') + { + let tag = after[..end].trim().to_ascii_lowercase(); + let (closing, name) = match tag.strip_prefix('/') { + Some(n) => (true, n.trim().to_string()), + None => (false, tag.split_whitespace().next().unwrap_or("").to_string()), + }; + let attr = match name.as_str() { + "i" => Some("tts:fontStyle=\"italic\""), + "b" => Some("tts:fontWeight=\"bold\""), + "u" => Some("tts:textDecoration=\"underline\""), + _ => None, + }; + if let Some(a) = attr { + if closing { + if let Some(pos) = open.iter().rposition(|x| *x == a) { + // close spans opened after it, then it, then reopen the inner ones + let inner: Vec<&str> = open.drain(pos..).skip(1).collect(); + for _ in 0..=inner.len() { + out.push_str(""); + } + for x in inner { + out.push_str(&format!("")); + open.push(x); + } + } + } else { + out.push_str(&format!("")); + open.push(a); + } + } + rest = &after[end + 1..]; + continue; + } + let ch = rest.chars().next().expect("non-empty"); + if ch == '\n' { + out.push_str("
"); + } else { + out.push_str(&esc(&ch.to_string())); + } + rest = &rest[ch.len_utf8()..]; + } + for _ in open { + out.push_str("
"); + } + out +} + +fn is_ncname(s: &str) -> bool { + let mut c = s.chars(); + c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.')) +} + +/// 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 { + let mut o = String::from("\n"); + let rate = if flavor == Flavor::Imsc1 { rate } else { None }; + o.push_str(&format!("\n", esc(if lang.is_empty() { "en" } else { lang }))); + o.push_str(" \n"); + o.push_str(" \n