diff --git a/Cargo.lock b/Cargo.lock index 845fb6db..0edf4911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1335,6 +1335,18 @@ dependencies = [ "proptest", ] +[[package]] +name = "filmcraft-captions" +version = "0.1.0" +dependencies = [ + "filmcraft-color", + "filmcraft-project", + "filmcraft-time", + "proptest", + "skrifa", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-cli" version = "0.1.0" @@ -1392,6 +1404,7 @@ dependencies = [ name = "filmcraft-engine" version = "0.1.0" dependencies = [ + "filmcraft-captions", "filmcraft-codecs", "filmcraft-color", "filmcraft-edit", diff --git a/Cargo.toml b/Cargo.toml index ee4bf1a3..59c98543 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ filmcraft-codecs = { path = "crates/codecs" } filmcraft-project = { path = "crates/project" } filmcraft-edit = { path = "crates/edit" } filmcraft-interchange = { path = "crates/interchange" } +filmcraft-captions = { path = "crates/captions" } filmcraft-render = { path = "crates/render" } filmcraft-gpu = { path = "crates/gpu" } filmcraft-export = { path = "crates/export" } diff --git a/assets/fonts/Inter-SemiBold.ttf.attribution b/assets/fonts/Inter-SemiBold.ttf.attribution index 20476e00..771822f0 100644 --- a/assets/fonts/Inter-SemiBold.ttf.attribution +++ b/assets/fonts/Inter-SemiBold.ttf.attribution @@ -1,8 +1,8 @@ asset: Inter-SemiBold.ttf -title: Inter SemiBold (UI typeface) +title: Inter SemiBold (UI typeface; caption burn-in font) author: The Inter Project Authors (Rasmus Andersson et al.) source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the UI (crates/ui-egui/src/theme.rs) and as the caption burn-in font (crates/captions/src/font.rs). diff --git a/crates/captions/Cargo.toml b/crates/captions/Cargo.toml new file mode 100644 index 00000000..55ce943c --- /dev/null +++ b/crates/captions/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "filmcraft-captions" +description = "FilmCraft captions: SRT, WebVTT and SCC (CEA-608 pop-on) import/export, caption layout and burn-in rasterisation" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-time = { workspace = true } +filmcraft-color = { workspace = true } +filmcraft-project = { workspace = true } +thiserror = { workspace = true } +# Font tables, glyph outlines and metrics (MIT OR Apache-2.0). Rasterisation is our own. +skrifa = { version = "0.44", default-features = false, features = ["std"] } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/captions/README.md b/crates/captions/README.md new file mode 100644 index 00000000..919e057c --- /dev/null +++ b/crates/captions/README.md @@ -0,0 +1,55 @@ +# filmcraft-captions + +Caption files and caption burn-in for FilmCraft (layer L2: depends on `filmcraft-time`, +`filmcraft-color`, `filmcraft-project` and `skrifa`). + +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 +and writes caption files, converts them to and from caption tracks, and draws captions. + +## Formats + +| Format | Read | Write | Notes | +|---|---|---|---| +| 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. | + +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 +frame grid (the engine does this on import) and removes overlaps. + +### SCC writer + +Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab +offset for centring, then text; control codes doubled) into non-displayed memory, followed by End +Of Caption exactly on the caption's first frame. The load is placed in the latest run of free +frames before the caption, so back-to-back captions load while the previous one is on screen. An +Erase Displayed Memory clears the caption at its out point unless the next caption replaces it. +Text is wrapped at 32 columns, bottom-aligned (last row 15) and centred. Characters come from the +608 basic, special and extended sets (extended characters are preceded by a basic fallback +character, as the standard requires); others are dropped. If there is no room to load a caption +before its in point (for example a caption at 00:00:00:00), it appears as soon as it is loaded. + +## Burn-in + +`burn::render_caption` lays out a caption with the track style (font size relative to a 1080-line +frame, colour, background box, outline, alignment, top/middle/bottom anchor and margin; WebVTT +`line:N%` and `align:` settings override them), wraps lines to 90% of the frame width and +rasterises it to a small premultiplied linear-light RGBA overlay. `filmcraft-render` composites the +overlays of visible caption tracks over the finished frame (Program monitor and export burn-in). + +Text is set in **Inter SemiBold** (`assets/fonts/Inter-SemiBold.ttf`, SIL OFL 1.1, attributed in +`ATTRIBUTION.md`). `skrifa` (MIT OR Apache-2.0) provides glyph outlines and metrics; the +rasteriser (`raster.rs`, non-zero winding, 5 sub-scanlines with exact horizontal coverage) is our +own. Glyph masks are cached per character, size and quarter-pixel offset. There is no shaping or +kerning yet; that arrives with the M10.1 text engine. + +## 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, rasteriser coverage, glyphs, + 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. diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs new file mode 100644 index 00000000..8fb7d75c --- /dev/null +++ b/crates/captions/src/burn.rs @@ -0,0 +1,324 @@ +//! Caption layout and burn-in. +//! +//! [`overlay`] lays out the caption showing on a track at a time with the track's +//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box, outline, +//! alignment, anchor and margin; WebVTT `line:N%` and `align:` cue settings override the style) +//! and rasterises it into a small premultiplied linear-light RGBA [`Overlay`] positioned in the +//! frame. Renderers composite overlays over the finished picture. + +use filmcraft_color::srgb_to_linear; +use filmcraft_project::{Caption, CaptionAlign, CaptionAnchor, CaptionStyle, CaptionTrack, Sequence}; +use filmcraft_time::Tick; + +use crate::font; + +/// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Overlay { + pub x: i32, + pub y: i32, + pub w: usize, + pub h: usize, + pub px: Vec, +} + +impl Overlay { + /// Composite over a premultiplied RGBA f32 canvas of `cw`×`ch` pixels. + pub fn composite_onto(&self, canvas: &mut [f32], cw: usize, ch: usize) { + for y in 0..self.h { + let cy = self.y + y as i32; + if cy < 0 || cy >= ch as i32 { + continue; + } + for x in 0..self.w { + let cx = self.x + x as i32; + if cx < 0 || cx >= cw as i32 { + continue; + } + let s = &self.px[(y * self.w + x) * 4..(y * self.w + x) * 4 + 4]; + if s[3] <= 0.0 { + continue; + } + let i = (cy as usize * cw + cx as usize) * 4; + let k = 1.0 - s[3]; + for c in 0..4 { + canvas[i + c] = s[c] + canvas[i + c] * k; + } + } + } + } +} + +fn lin(c: [u8; 4]) -> [f32; 4] { + let a = c[3] as f32 / 255.0; + [srgb_to_linear(c[0] as f32 / 255.0) * a, srgb_to_linear(c[1] as f32 / 255.0) * a, srgb_to_linear(c[2] as f32 / 255.0) * a, a] +} + +/// Word-wrap one line to `max_w` pixels. +fn wrap(line: &str, px: f32, max_w: f32) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + for word in line.split(' ') { + let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; + if !cur.is_empty() && font::measure(&cand, px) > max_w { + out.push(std::mem::take(&mut cur)); + cur = word.to_string(); + } else { + cur = cand; + } + } + out.push(cur); + out +} + +/// Placement overrides from WebVTT cue settings. +fn vtt_overrides(settings: &str) -> (Option, Option) { + let mut line = None; + let mut align = None; + for kv in settings.split_whitespace() { + let Some((k, v)) = kv.split_once(':') else { continue }; + match k { + "line" => { + let v = v.split(',').next().unwrap_or(""); + if let Some(p) = v.strip_suffix('%').and_then(|p| p.parse::().ok()) { + line = Some((p / 100.0).clamp(0.0, 1.0)); + } + } + "align" => { + align = match v { + "start" | "left" => Some(CaptionAlign::Left), + "end" | "right" => Some(CaptionAlign::Right), + "center" | "middle" => Some(CaptionAlign::Center), + _ => None, + } + } + _ => {} + } + } + (line, align) +} + +/// Lay out and rasterise one caption for a `w`×`h` frame. +pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> Option { + if w == 0 || h == 0 { + return None; + } + let scale = h as f32 / 1080.0; + let px = (style.size * scale).max(4.0); + let max_w = w as f32 * 0.9; + let mut lines: Vec = Vec::new(); + // (speaker names are metadata; like Premiere, they are not burned in) + for l in c.plain_lines() { + lines.extend(wrap(l.trim(), px, max_w)); + } + lines.retain(|l| !l.is_empty()); + if lines.is_empty() { + return None; + } + let (asc, desc) = font::metrics(px); + let lh = px * style.line_spacing.max(0.8); + let pad_x = (px * 0.3).round(); + let block_h = lh * lines.len() as f32; + let (line_override, align_override) = vtt_overrides(&c.settings); + let align = align_override.unwrap_or(style.align); + let margin = style.margin.clamp(0.0, 0.45) * h as f32; + let top = match (line_override, style.anchor) { + (Some(f), _) => (f * h as f32).min(h as f32 - block_h), + (None, CaptionAnchor::Top) => margin, + (None, CaptionAnchor::Middle) => (h as f32 - block_h) / 2.0, + (None, CaptionAnchor::Bottom) => h as f32 - margin - block_h, + } + .max(0.0) + .round(); + let side = w as f32 * 0.05; + let widths: Vec = lines.iter().map(|l| font::measure(l, px)).collect(); + let xs: Vec = widths + .iter() + .map(|&lw| match align { + CaptionAlign::Left => side + pad_x, + CaptionAlign::Right => w as f32 - side - pad_x - lw, + CaptionAlign::Center => (w as f32 - lw) / 2.0, + }) + .collect(); + let outline = (style.outline * scale).max(0.0); + let grow = outline.ceil() + 1.0; + let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor(); + let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil(); + let by0 = (top - grow).floor(); + let by1 = (top + block_h + grow).ceil(); + let (ox, oy) = (bx0 as i32, by0 as i32); + let ow = (bx1 - bx0).max(1.0) as usize; + let oh = (by1 - by0).max(1.0) as usize; + let mut cover = vec![0.0f32; ow * oh]; + let mut bg = vec![0.0f32; ow * oh]; + for (i, line) in lines.iter().enumerate() { + let lt = top + lh * i as f32; + if style.background && style.background_color[3] > 0 { + let (x0, x1) = (xs[i] - pad_x, xs[i] + widths[i] + pad_x); + let (y0, y1) = (lt, lt + lh); + for y in (y0.floor() as i32).max(oy)..(y1.ceil() as i32).min(oy + oh as i32) { + let fy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0); + for x in (x0.floor() as i32).max(ox)..(x1.ceil() as i32).min(ox + ow as i32) { + let fx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0); + let j = (y - oy) as usize * ow + (x - ox) as usize; + bg[j] = (bg[j] + fx * fy).min(1.0); + } + } + } + let baseline = lt + (lh - (asc + desc)) / 2.0 + asc; + let mut pen = xs[i]; + for ch in line.chars() { + if let Some(g) = font::glyph(ch, px, pen.fract()) { + let gx = pen.floor() as i32 + g.left - ox; + let gy = baseline.round() as i32 + g.top - oy; + for y in 0..g.mask.h { + let ty = gy + y as i32; + if ty < 0 || ty >= oh as i32 { + continue; + } + for x in 0..g.mask.w { + let tx = gx + x as i32; + if tx < 0 || tx >= ow as i32 { + continue; + } + let j = ty as usize * ow + tx as usize; + cover[j] = (cover[j] + g.mask.get(x, y)).min(1.0); + } + } + } + pen += font::advance(ch, px); + } + } + let stroke = if outline > 0.0 { dilate(&cover, ow, oh, outline) } else { Vec::new() }; + let (tc, bc, oc) = (lin(style.color), lin(style.background_color), lin(style.outline_color)); + let mut out = vec![0.0f32; ow * oh * 4]; + for j in 0..ow * oh { + let mut p = [0.0f32; 4]; + let over = |p: &mut [f32; 4], c: [f32; 4], a: f32| { + if a > 0.0 { + for k in 0..4 { + p[k] = c[k] * a + p[k] * (1.0 - c[3] * a); + } + } + }; + over(&mut p, bc, bg[j]); + if !stroke.is_empty() { + over(&mut p, oc, stroke[j]); + } + over(&mut p, tc, cover[j]); + out[j * 4..j * 4 + 4].copy_from_slice(&p); + } + Some(Overlay { x: ox, y: oy, w: ow, h: oh, px: out }) +} + +/// Max-filter a coverage mask with a disc of radius `r`. +fn dilate(m: &[f32], w: usize, h: usize, r: f32) -> Vec { + let ri = r.ceil() as i32; + let mut offs = Vec::new(); + for dy in -ri..=ri { + for dx in -ri..=ri { + let d = ((dx * dx + dy * dy) as f32).sqrt(); + if d <= r + 0.5 { + offs.push((dx, dy, (r + 0.5 - d).clamp(0.0, 1.0))); + } + } + } + let mut out = vec![0.0f32; w * h]; + for y in 0..h as i32 { + for x in 0..w as i32 { + let mut v = 0.0f32; + for &(dx, dy, wgt) in &offs { + let (sx, sy) = (x + dx, y + dy); + if sx >= 0 && sy >= 0 && sx < w as i32 && sy < h as i32 { + v = v.max(m[sy as usize * w + sx as usize] * wgt); + } + } + out[y as usize * w + x as usize] = v; + } + } + out +} + +/// The overlay for a caption track at timeline time `t` (None when nothing shows). +pub fn track_overlay(track: &CaptionTrack, t: Tick, w: usize, h: usize) -> Option { + if !track.enabled { + return None; + } + let c = track.caption_at(t)?; + render_caption(c, &track.style, w, h) +} + +/// Overlays of every visible caption track of a sequence at `t`, bottom track first. +pub fn sequence_overlays(seq: &Sequence, t: Tick, w: usize, h: usize) -> Vec { + seq.caption_tracks.iter().rev().filter_map(|tr| track_overlay(tr, t, w, h)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::{CaptionFormat, ClipId, TrackId}; + + fn cap(text: &str) -> Caption { + Caption { id: ClipId(1), start: Tick(0), duration: Tick(100), text: text.into(), speaker: None, cue_id: None, settings: String::new() } + } + + #[test] + fn bottom_centre_with_box() { + let st = CaptionStyle::default(); + let o = render_caption(&cap("Hello world"), &st, 1920, 1080).unwrap(); + // centred horizontally, in the lower part of the frame + let cx = o.x as f32 + o.w as f32 / 2.0; + assert!((cx - 960.0).abs() < 4.0, "centre {cx}"); + assert!(o.y > 800 && (o.y as usize + o.h) < 1080, "y {} h {}", o.y, o.h); + // white text pixels exist and the box is dark translucent + let mut white = 0; + let mut boxy = 0; + for p in o.px.chunks(4) { + if p[3] > 0.99 && p[0] > 0.9 { + white += 1; + } else if p[3] > 0.7 && p[0] < 0.01 { + boxy += 1; + } + } + assert!(white > 500, "{white}"); + assert!(boxy > 1000, "{boxy}"); + } + + #[test] + fn top_anchor_and_vtt_line() { + let st = CaptionStyle { anchor: CaptionAnchor::Top, ..Default::default() }; + let o = render_caption(&cap("Top"), &st, 1280, 720).unwrap(); + assert!(o.y < 100); + let mut c = cap("Line"); + c.settings = "line:50% align:start".into(); + let o = render_caption(&c, &CaptionStyle::default(), 1280, 720).unwrap(); + assert!((o.y - 360).abs() < 4, "{}", o.y); + assert!(o.x < 120, "{}", o.x); + } + + #[test] + fn wraps_and_composites() { + let long = "word ".repeat(60); + let o = render_caption(&cap(&long), &CaptionStyle::default(), 640, 360).unwrap(); + assert!(o.w <= 640 && o.h > 40, "{}x{}", o.w, o.h); + let mut canvas = vec![0.0f32; 640 * 360 * 4]; + o.composite_onto(&mut canvas, 640, 360); + assert!(canvas.chunks(4).any(|p| p[0] > 0.9)); + } + + #[test] + fn outline_draws_around_text() { + let st = CaptionStyle { background: false, outline: 4.0, outline_color: [255, 0, 0, 255], ..Default::default() }; + let o = render_caption(&cap("O"), &st, 1920, 1080).unwrap(); + assert!(o.px.chunks(4).any(|p| p[0] > 0.9 && p[1] < 0.05 && p[3] > 0.9), "red outline pixels"); + } + + #[test] + fn hidden_track_draws_nothing() { + let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle); + t.captions.push(cap("x")); + assert!(track_overlay(&t, Tick(5), 100, 100).is_some()); + t.enabled = false; + assert!(track_overlay(&t, Tick(5), 100, 100).is_none()); + } +} diff --git a/crates/captions/src/cea608.rs b/crates/captions/src/cea608.rs new file mode 100644 index 00000000..b17f6a2f --- /dev/null +++ b/crates/captions/src/cea608.rs @@ -0,0 +1,215 @@ +//! CEA-608 (line 21) character sets and control codes, from the public specification +//! (ANSI/CTA-608-E; 47 CFR §15.119). +//! +//! Bytes travel in pairs with odd parity in bit 7. A pair whose first byte (parity stripped) is +//! 0x10–0x1F is a control code (channel 1: 0x10–0x17, channel 2: 0x18–0x1F); otherwise both bytes +//! are characters from the basic set (0x00 = padding). + +/// Add odd parity to a 7-bit value. +pub fn with_parity(b: u8) -> u8 { + let b = b & 0x7f; + if b.count_ones().is_multiple_of(2) { b | 0x80 } else { b } +} + +/// Basic character set (0x20–0x7F). Differs from ASCII in ten positions. +pub fn basic_char(b: u8) -> Option { + Some(match b { + 0x2a => 'á', + 0x5c => 'é', + 0x5e => 'í', + 0x5f => 'ó', + 0x60 => 'ú', + 0x7b => 'ç', + 0x7c => '÷', + 0x7d => 'Ñ', + 0x7e => 'ñ', + 0x7f => '█', + 0x20..=0x7e => b as char, + _ => return None, + }) +} + +/// Special characters: control pair `0x11, 0x30 + i`. +pub const SPECIAL: [char; 16] = ['®', '°', '½', '¿', '™', '¢', '£', '♪', 'à', '\u{a0}', 'è', 'â', 'ê', 'î', 'ô', 'û']; + +/// Extended Spanish/French/miscellaneous: control pair `0x12, 0x20 + i`. +pub const EXT_12: [char; 32] = [ + 'Á', 'É', 'Ó', 'Ú', 'Ü', 'ü', '‘', '¡', '*', '\'', '—', '©', '℠', '•', '“', '”', 'À', 'Â', 'Ç', 'È', 'Ê', 'Ë', 'ë', 'Î', 'Ï', 'ï', 'Ô', 'Ù', 'ù', 'Û', '«', + '»', +]; + +/// Extended Portuguese/German/Danish: control pair `0x13, 0x20 + i`. +pub const EXT_13: [char; 32] = [ + 'Ã', 'ã', 'Í', 'Ì', 'ì', 'Ò', 'ò', 'Õ', 'õ', '{', '}', '\\', '^', '_', '|', '~', 'Ä', 'ä', 'Ö', 'ö', 'ß', '¥', '¤', '¦', 'Å', 'å', 'Ø', 'ø', '┌', '┐', '└', + '┘', +]; + +/// How a character is transmitted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Encoded { + /// One basic-set byte. + Basic(u8), + /// A two-byte special/extended code (channel 1 first byte, second byte). Extended characters + /// replace the preceding character, so a basic `fallback` is sent first for older decoders. + Pair { b1: u8, b2: u8, fallback: Option }, +} + +fn basic_byte(c: char) -> Option { + (0x20u8..=0x7f).find(|&b| basic_char(b) == Some(c)) +} + +/// Encode one character (`None` if 608 cannot show it). +pub fn encode_char(c: char) -> Option { + let c = match c { + '’' | 'ʼ' => '\'', + '\t' => ' ', + '–' => '-', + _ => c, + }; + if let Some(b) = basic_byte(c) { + return Some(Encoded::Basic(b)); + } + if let Some(i) = SPECIAL.iter().position(|&s| s == c) { + return Some(Encoded::Pair { b1: 0x11, b2: 0x30 + i as u8, fallback: None }); + } + let fallback = |c: char| -> u8 { + let f = match c { + 'Á' | 'À' | 'Â' | 'Ã' | 'Ä' | 'Å' => 'A', + 'É' | 'È' | 'Ê' | 'Ë' => 'E', + 'Í' | 'Ì' | 'Î' | 'Ï' => 'I', + 'Ó' | 'Ò' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => 'O', + 'Ú' | 'Ù' | 'Û' | 'Ü' => 'U', + 'ü' | 'ù' => 'u', + 'ë' => 'e', + 'ï' | 'ì' => 'i', + 'ã' | 'ä' | 'å' => 'a', + 'ò' | 'õ' | 'ö' | 'ø' => 'o', + 'Ç' => 'C', + 'ß' => 's', + '‘' | '“' | '”' | '«' | '»' => '"', + '{' | '┌' | '└' => '[', + '}' | '┐' | '┘' => ']', + '¡' => '!', + '*' | '•' => '.', + _ => '-', + }; + basic_byte(f).unwrap_or(0x2d) + }; + if let Some(i) = EXT_12.iter().position(|&s| s == c) { + return Some(Encoded::Pair { b1: 0x12, b2: 0x20 + i as u8, fallback: Some(fallback(c)) }); + } + if let Some(i) = EXT_13.iter().position(|&s| s == c) { + return Some(Encoded::Pair { b1: 0x13, b2: 0x20 + i as u8, fallback: Some(fallback(c)) }); + } + None +} + +/// Miscellaneous control codes (second byte, first byte 0x14 on channel 1). +pub mod misc { + pub const RCL: u8 = 0x20; // resume caption loading (pop-on) + pub const BS: u8 = 0x21; // backspace + pub const AOF: u8 = 0x22; + pub const AON: u8 = 0x23; + pub const DER: u8 = 0x24; // delete to end of row + pub const RU2: u8 = 0x25; // roll-up 2/3/4 rows + pub const RU3: u8 = 0x26; + pub const RU4: u8 = 0x27; + pub const FON: u8 = 0x28; + pub const RDC: u8 = 0x29; // resume direct captioning (paint-on) + pub const TR: u8 = 0x2a; + pub const RTD: u8 = 0x2b; + pub const EDM: u8 = 0x2c; // erase displayed memory + pub const CR: u8 = 0x2d; // carriage return (roll-up) + pub const ENM: u8 = 0x2e; // erase non-displayed memory + pub const EOC: u8 = 0x2f; // end of caption (swap memories) +} + +/// Row (1–15) and indent column addressed by a preamble address code, if `(b1, b2)` is one +/// (channel bit already removed from `b1`). +pub fn decode_pac(b1: u8, b2: u8) -> Option<(usize, usize)> { + if !(0x40..=0x7f).contains(&b2) { + return None; + } + let hi = b2 >= 0x60; + let row = match (b1, hi) { + (0x11, false) => 1, + (0x11, true) => 2, + (0x12, false) => 3, + (0x12, true) => 4, + (0x15, false) => 5, + (0x15, true) => 6, + (0x16, false) => 7, + (0x16, true) => 8, + (0x17, false) => 9, + (0x17, true) => 10, + (0x10, false) => 11, + (0x13, false) => 12, + (0x13, true) => 13, + (0x14, false) => 14, + (0x14, true) => 15, + _ => return None, + }; + let a = b2 & 0x1f; + let indent = if a & 0x10 != 0 { ((a & 0x0e) >> 1) as usize * 4 } else { 0 }; + Some((row, indent)) +} + +/// Preamble address code for `row` (1–15) with an indent (rounded down to a multiple of 4). +pub fn encode_pac(row: usize, indent: usize) -> (u8, u8) { + let (b1, hi) = match row { + 1 => (0x11, false), + 2 => (0x11, true), + 3 => (0x12, false), + 4 => (0x12, true), + 5 => (0x15, false), + 6 => (0x15, true), + 7 => (0x16, false), + 8 => (0x16, true), + 9 => (0x17, false), + 10 => (0x17, true), + 11 => (0x10, false), + 12 => (0x13, false), + 13 => (0x13, true), + 14 => (0x14, false), + _ => (0x14, true), + }; + let attr = if indent >= 4 { 0x10 | (((indent / 4).min(7) as u8) << 1) } else { 0 }; + (b1, (if hi { 0x60 } else { 0x40 }) | attr) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parity() { + assert_eq!(with_parity(0x14), 0x94); + assert_eq!(with_parity(0x2c), 0x2c); + assert_eq!(with_parity(0x20), 0x20); + assert_eq!(with_parity(0x2f), 0x2f); + assert_eq!(with_parity(0x2e), 0xae); + for b in 0..128u8 { + assert_eq!(with_parity(b).count_ones() % 2, 1); + } + } + + #[test] + fn pac_roundtrip() { + for row in 1..=15 { + for indent in (0..32).step_by(4) { + let (b1, b2) = encode_pac(row, indent); + assert_eq!(decode_pac(b1, b2), Some((row, indent)), "row {row} indent {indent}"); + } + } + } + + #[test] + fn chars() { + assert_eq!(encode_char('A'), Some(Encoded::Basic(0x41))); + assert_eq!(encode_char('é'), Some(Encoded::Basic(0x5c))); + assert_eq!(encode_char('♪'), Some(Encoded::Pair { b1: 0x11, b2: 0x37, fallback: None })); + assert!(matches!(encode_char('{'), Some(Encoded::Pair { b1: 0x13, b2: 0x29, .. }))); + assert!(matches!(encode_char('*'), Some(Encoded::Pair { b1: 0x12, b2: 0x28, .. }))); + assert_eq!(encode_char('漢'), None); + } +} diff --git a/crates/captions/src/font.rs b/crates/captions/src/font.rs new file mode 100644 index 00000000..2aa1cf84 --- /dev/null +++ b/crates/captions/src/font.rs @@ -0,0 +1,137 @@ +//! The caption font: Inter SemiBold (SIL OFL 1.1, `assets/fonts/Inter-SemiBold.ttf`, attributed in +//! ATTRIBUTION.md), read with `skrifa` and rasterised by [`crate::raster`]. Glyph masks are +//! cached by (char, size, sub-pixel offset). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::{FontRef, MetadataProvider}; + +use crate::raster::{Mask, Path, fill}; + +static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); + +fn font() -> Option> { + FontRef::new(INTER_SEMIBOLD).ok() +} + +/// Vertical metrics at a pixel size: (ascent, descent), both positive. +pub fn metrics(px: f32) -> (f32, f32) { + match font() { + Some(f) => { + let m = f.metrics(Size::new(px), LocationRef::default()); + (m.ascent, -m.descent) + } + None => (px * 0.8, px * 0.2), + } +} + +/// Horizontal advance of a character at a pixel size. +pub fn advance(c: char, px: f32) -> f32 { + let Some(f) = font() else { return px * 0.5 }; + let gid = f.charmap().map(c).or_else(|| f.charmap().map('?')); + gid.and_then(|g| f.glyph_metrics(Size::new(px), LocationRef::default()).advance_width(g)).unwrap_or(px * 0.5) +} + +/// Width of a single line of text. +pub fn measure(text: &str, px: f32) -> f32 { + text.chars().map(|c| advance(c, px)).sum() +} + +struct Pen { + p: Path, +} + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + self.p.move_to(x, -y); + } + fn line_to(&mut self, x: f32, y: f32) { + self.p.line_to(x, -y); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.p.quad_to(cx0, -cy0, x, -y); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.p.cubic_to(cx0, -cy0, cx1, -cy1, x, -y); + } + fn close(&mut self) { + self.p.close(); + } +} + +/// A rasterised glyph: coverage mask placed with its top-left at `(left, top)` relative to the +/// pen position on the baseline. +#[derive(Debug)] +pub struct Glyph { + pub mask: Mask, + pub left: i32, + pub top: i32, +} + +type Key = (char, u32, u8); + +fn cache() -> &'static Mutex>>> { + static C: OnceLock>>>> = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// Rasterise `c` at `px` with the pen at fractional x offset `frac` (0..1, quantised to ¼ px). +pub fn glyph(c: char, px: f32, frac: f32) -> Option> { + let size_q = (px * 4.0).round().max(1.0) as u32; + let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; + let key = (c, size_q, frac_q); + if let Some(g) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return g.clone(); + } + let g = render_glyph(c, size_q as f32 / 4.0, frac_q as f32 / 4.0).map(Arc::new); + let mut m = cache().lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 8192 { + m.clear(); + } + m.insert(key, g.clone()); + g +} + +fn render_glyph(c: char, px: f32, frac: f32) -> Option { + if c.is_whitespace() { + return None; + } + let f = font()?; + let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'))?; + let outline = f.outline_glyphs().get(gid)?; + let mut pen = Pen { p: Path::default() }; + outline.draw(DrawSettings::unhinted(Size::new(px), LocationRef::default()), &mut pen).ok()?; + pen.p.close(); + let (x0, y0, x1, y1) = pen.p.bounds()?; + let left = (x0 + frac).floor() as i32; + let top = y0.floor() as i32; + let w = ((x1 + frac).ceil() as i32 - left).max(1) as usize; + let h = (y1.ceil() as i32 - top).max(1) as usize; + let mask = fill(&pen.p, w, h, frac - left as f32, -top as f32); + Some(Glyph { mask, left, top }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inter_loads_and_draws() { + let (a, d) = metrics(100.0); + assert!(a > 80.0 && a < 110.0, "ascent {a}"); + assert!(d > 10.0 && d < 40.0, "descent {d}"); + assert!(advance('W', 100.0) > advance('i', 100.0)); + let g = glyph('H', 40.0, 0.0).unwrap(); + // the H has two stems: the middle row has ink on both sides and a gap between them + let y = g.mask.h / 4; + let row: Vec = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect(); + assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}"); + assert!(row[g.mask.w / 2] < 0.1, "{row:?}"); + assert!(g.top < 0, "glyph rises above the baseline"); + assert!(glyph(' ', 40.0, 0.0).is_none()); + assert!(measure("Hello", 30.0) > 50.0); + } +} diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs new file mode 100644 index 00000000..4c8b24b7 --- /dev/null +++ b/crates/captions/src/lib.rs @@ -0,0 +1,341 @@ +//! 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, +//! 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. +//! - **Time:** cue times are exact [`Tick`]s. SRT/VTT milliseconds and SCC 29.97 fps timecodes +//! (drop-frame or not) convert exactly; [`Document::snap_to_frames`] snaps to a sequence's frames. +//! - **Model:** [`track_from_document`] / [`document_from_track`] convert to and from the project's +//! [`CaptionTrack`]. +//! - **Burn-in:** [`burn`] lays a caption out with the track style and rasterises it with the +//! bundled Inter font (outlines via `skrifa`, coverage rasteriser in [`raster`]). + +pub mod burn; +pub mod cea608; +pub mod font; +pub mod raster; +pub mod scc; +pub mod srt; +pub mod vtt; + +use filmcraft_project::{Caption, CaptionFormat, CaptionTrack, ClipId, TrackId}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; + +/// A caption file format. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Format { + Srt, + WebVtt, + Scc, +} + +impl Format { + pub const ALL: [Format; 3] = [Format::Srt, Format::WebVtt, Format::Scc]; + pub fn name(self) -> &'static str { + match self { + Format::Srt => "SubRip (SRT)", + Format::WebVtt => "WebVTT", + Format::Scc => "Scenarist SCC (CEA-608)", + } + } + pub fn extension(self) -> &'static str { + match self { + Format::Srt => "srt", + Format::WebVtt => "vtt", + Format::Scc => "scc", + } + } + pub fn from_name(s: &str) -> Option { + match s.trim_start_matches('.').to_ascii_lowercase().as_str() { + "srt" | "subrip" => Some(Format::Srt), + "vtt" | "webvtt" => Some(Format::WebVtt), + "scc" | "cea608" | "cea-608" => Some(Format::Scc), + _ => None, + } + } + /// The caption track format a file of this kind maps to. + pub fn track_format(self) -> CaptionFormat { + match self { + Format::Scc => CaptionFormat::Cea608, + _ => CaptionFormat::Subtitle, + } + } +} + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum Error { + #[error("not a {0} file")] + NotFormat(&'static str), + #[error("line {line}: {msg}")] + Syntax { line: usize, msg: String }, +} + +pub type Result = std::result::Result; + +/// One caption cue. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Cue { + pub start: Tick, + pub end: Tick, + /// Text as written (lines separated by `\n`, inline tags kept). + pub text: String, + /// WebVTT voice (``). + pub speaker: Option, + /// WebVTT cue identifier. + pub id: Option, + /// WebVTT cue settings, verbatim. + pub settings: String, +} + +/// A parsed caption file. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Document { + pub cues: Vec, + /// WebVTT STYLE / REGION / NOTE blocks (verbatim, without trailing blank lines). + pub blocks: Vec, + /// Non-fatal problems found while reading (skipped cues…). + pub warnings: Vec, +} + +impl Document { + /// Snap cue times to frame boundaries of `rate` (nearest), keeping every cue at least one frame + /// long and removing overlaps (a cue is cut where the next one starts). + pub fn snap_to_frames(&mut self, rate: FrameRate) { + for c in &mut self.cues { + c.start = rate.snap_nearest(c.start); + c.end = rate.snap_nearest(c.end).max(c.start + rate.frame_duration()); + } + self.normalize(); + } + + /// Sort by start, drop empty cues and cut overlaps. + pub fn normalize(&mut self) { + self.cues.retain(|c| c.end > c.start); + self.cues.sort_by_key(|c| (c.start, c.end)); + let mut out: Vec = Vec::with_capacity(self.cues.len()); + for c in self.cues.drain(..) { + let mut c = c; + if let Some(prev) = out.last_mut() + && c.start < prev.end + { + if c.start > prev.start { + prev.end = c.start; + } else { + // same start: keep the first, shift this one after it + c.start = prev.end; + if c.end <= c.start { + self.warnings.push(format!("dropped overlapping cue \"{}\"", c.text.lines().next().unwrap_or(""))); + continue; + } + } + } + out.push(c); + } + self.cues = out; + } +} + +/// Guess a caption format from content (and the file extension as a hint). +pub fn detect(bytes: &[u8], ext: Option<&str>) -> Option { + let text = decode_text(&bytes[..bytes.len().min(4096)]); + let head = text.trim_start_matches('\u{feff}').trim_start(); + if head.starts_with("WEBVTT") { + return Some(Format::WebVtt); + } + if head.starts_with("Scenarist_SCC") { + return Some(Format::Scc); + } + let ext = ext.map(|e| e.trim_start_matches('.').to_ascii_lowercase()); + 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, + _ if has_arrow_timing => Some(Format::Srt), + _ => None, + } +} + +/// Parse caption bytes. +pub fn parse(bytes: &[u8], format: Format) -> Result { + let text = decode_text(bytes); + match format { + Format::Srt => srt::parse(&text), + Format::WebVtt => vtt::parse(&text), + Format::Scc => scc::parse(&text), + } +} + +/// Options for [`write`]. +#[derive(Clone, Copy, Debug)] +pub struct WriteOptions { + /// SCC: drop-frame timecode (the usual choice for 29.97 fps SCC). + pub drop_frame: bool, +} + +impl Default for WriteOptions { + fn default() -> Self { + Self { drop_frame: true } + } +} + +/// Write a document. +pub fn write(doc: &Document, format: Format, opts: WriteOptions) -> Vec { + match format { + Format::Srt => srt::write(doc).into_bytes(), + Format::WebVtt => vtt::write(doc).into_bytes(), + Format::Scc => scc::write(doc, opts.drop_frame).into_bytes(), + } +} + +/// Decode text bytes: UTF-8 (BOM stripped), UTF-16 with BOM, else Windows-1252. Line endings are +/// normalised to `\n`. +pub fn decode_text(bytes: &[u8]) -> String { + let s = if let Some(rest) = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]) { + String::from_utf8_lossy(rest).into_owned() + } else if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) { + let le = bytes[0] == 0xff; + let units: Vec = + bytes[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect(); + String::from_utf16_lossy(&units) + } else { + match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => bytes.iter().map(|&b| cp1252(b)).collect(), + } + }; + let s = s.strip_prefix('\u{feff}').map(str::to_string).unwrap_or(s); + if s.contains('\r') { s.replace("\r\n", "\n").replace('\r', "\n") } else { s } +} + +fn cp1252(b: u8) -> char { + const HI: [char; 32] = [ + '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', + 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ', + ]; + if (0x80..0xa0).contains(&b) { HI[(b - 0x80) as usize] } else { b as char } +} + +pub(crate) const TICKS_PER_MS: i64 = TICKS_PER_SECOND / 1000; + +/// Parse `[H…:]MM:SS[.,]fff` (the fraction may have 1–9 digits) into ticks. +pub fn parse_clock(s: &str) -> Option { + let s = s.trim(); + let (hms, frac) = match s.find([',', '.']) { + Some(i) => (&s[..i], &s[i + 1..]), + None => (s, ""), + }; + let parts: Vec<&str> = hms.split(':').collect(); + let num = |p: &str| -> Option { if !p.is_empty() && p.len() <= 9 && p.bytes().all(|b| b.is_ascii_digit()) { p.parse().ok() } else { None } }; + let (h, m, sec) = match parts.as_slice() { + [h, m, s] => (num(h)?, num(m)?, num(s)?), + [m, s] => (0, num(m)?, num(s)?), + _ => return None, + }; + if m >= 60 || sec >= 60 || h > 9999 { + return None; + } + let mut ticks = ((h * 60 + m) * 60 + sec) * TICKS_PER_SECOND; + if !frac.is_empty() { + if frac.len() > 9 || !frac.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let d: i64 = frac.parse().ok()?; + ticks += (d as i128 * TICKS_PER_SECOND as i128 / 10i128.pow(frac.len() as u32)) as i64; + } + Some(Tick(ticks)) +} + +/// Format ticks as `HH:MM:SSmmm` (rounded to the nearest millisecond). +pub fn format_clock(t: Tick, sep: char) -> String { + let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS; + let (h, m, s, f) = (ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000); + format!("{h:02}:{m:02}:{s:02}{sep}{f:03}") +} + +/// Build a caption track from a document. Ids are allocated with `alloc`. +pub fn track_from_document(doc: &Document, id: TrackId, name: &str, format: CaptionFormat, alloc: &mut dyn FnMut() -> u64) -> CaptionTrack { + let mut t = CaptionTrack::new(id, name.to_string(), format); + t.vtt_blocks = doc.blocks.clone(); + t.captions = doc + .cues + .iter() + .filter(|c| c.end > c.start) + .map(|c| Caption { + id: ClipId(alloc()), + start: c.start, + duration: c.end - c.start, + text: c.text.clone(), + speaker: c.speaker.clone(), + cue_id: c.id.clone(), + settings: c.settings.clone(), + }) + .collect(); + t.sort(); + t +} + +/// The document for a caption track (for export). `offset` is subtracted from every time (export +/// of a range starting later than zero). +pub fn document_from_track(t: &CaptionTrack, offset: Tick) -> Document { + Document { + cues: t + .captions + .iter() + .filter(|c| c.end() > offset) + .map(|c| Cue { + start: (c.start - offset).max(Tick::ZERO), + end: c.end() - offset, + text: c.text.clone(), + speaker: c.speaker.clone(), + id: c.cue_id.clone(), + settings: c.settings.clone(), + }) + .collect(), + blocks: t.vtt_blocks.clone(), + warnings: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock() { + assert_eq!(parse_clock("00:00:01,500"), Some(Tick(TICKS_PER_SECOND * 3 / 2))); + assert_eq!(parse_clock("01:02.25"), Some(Tick(TICKS_PER_SECOND * 62 + TICKS_PER_SECOND / 4))); + assert_eq!(parse_clock("100:00:00.000"), Some(Tick(TICKS_PER_SECOND * 360_000))); + assert_eq!(parse_clock("00:61:00,000"), None); + assert_eq!(parse_clock("aa:00:00,000"), None); + assert_eq!(format_clock(Tick(TICKS_PER_SECOND * 3723 + TICKS_PER_MS * 45), ','), "01:02:03,045"); + } + + #[test] + fn decode() { + assert_eq!(decode_text(b"\xef\xbb\xbfa\r\nb\rc"), "a\nb\nc"); + assert_eq!(decode_text(&[0xff, 0xfe, b'h', 0, b'i', 0]), "hi"); + assert_eq!(decode_text(b"caf\xe9 \x93x\x94"), "café “x”"); + } + + #[test] + fn detects() { + assert_eq!(detect(b"WEBVTT\n\n00:01.000 --> 00:02.000\nhi\n", Some("vtt")), Some(Format::WebVtt)); + assert_eq!(detect(b"1\n00:00:01,000 --> 00:00:02,000\nhi\n", Some("srt")), Some(Format::Srt)); + 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); + } + + #[test] + fn normalize_cuts_overlaps() { + let c = |s: i64, e: i64| Cue { start: Tick(s), end: Tick(e), text: "x".into(), ..Default::default() }; + let mut d = Document { cues: vec![c(10, 20), c(0, 15), c(10, 12)], ..Default::default() }; + d.normalize(); + let r: Vec<(i64, i64)> = d.cues.iter().map(|c| (c.start.0, c.end.0)).collect(); + assert_eq!(r, vec![(0, 10), (10, 12), (12, 20)]); + } +} diff --git a/crates/captions/src/raster.rs b/crates/captions/src/raster.rs new file mode 100644 index 00000000..ff8dcc38 --- /dev/null +++ b/crates/captions/src/raster.rs @@ -0,0 +1,228 @@ +//! A small anti-aliased path rasteriser for glyph outlines. +//! +//! Curves are flattened to line segments; each pixel row is sampled at [`SUB`] sub-scanlines, and +//! on each sub-scanline the non-zero winding spans between edge crossings are accumulated with +//! exact horizontal coverage at the span ends. Original implementation for FilmCraft. + +/// Vertical sub-samples per pixel row. +pub const SUB: usize = 5; + +/// A flattened path: line segments `(x0, y0, x1, y1)` in pixel space, y down. +#[derive(Clone, Debug, Default)] +pub struct Path { + pub segs: Vec<[f32; 4]>, + start: (f32, f32), + cur: (f32, f32), + open: bool, +} + +impl Path { + pub fn move_to(&mut self, x: f32, y: f32) { + self.close(); + self.start = (x, y); + self.cur = (x, y); + self.open = true; + } + pub fn line_to(&mut self, x: f32, y: f32) { + if (x, y) != self.cur { + self.segs.push([self.cur.0, self.cur.1, x, y]); + } + self.cur = (x, y); + } + pub fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + let (x0, y0) = self.cur; + let dd = ((x0 - 2.0 * cx + x).abs() + (y0 - 2.0 * cy + y).abs()).max(0.01); + let n = ((dd * 2.0).sqrt().ceil() as usize).clamp(1, 32); + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + self.line_to(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y); + } + } + pub fn cubic_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { + let (x0, y0) = self.cur; + let dd = (x0 - 2.0 * c1x + c2x).abs() + (y0 - 2.0 * c1y + c2y).abs() + (c1x - 2.0 * c2x + x).abs() + (c1y - 2.0 * c2y + y).abs(); + let n = (((dd.max(0.01)) * 3.0).sqrt().ceil() as usize).clamp(1, 48); + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + self.line_to(a * x0 + b * c1x + c * c2x + d * x, a * y0 + b * c1y + c * c2y + d * y); + } + } + pub fn close(&mut self) { + if self.open && self.cur != self.start { + self.segs.push([self.cur.0, self.cur.1, self.start.0, self.start.1]); + } + self.cur = self.start; + self.open = false; + } + /// Bounding box `(x0, y0, x1, y1)` of all segments. + pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { + let mut it = self.segs.iter(); + let f = it.next()?; + let mut b = (f[0].min(f[2]), f[1].min(f[3]), f[0].max(f[2]), f[1].max(f[3])); + for s in it { + b = (b.0.min(s[0]).min(s[2]), b.1.min(s[1]).min(s[3]), b.2.max(s[0]).max(s[2]), b.3.max(s[1]).max(s[3])); + } + Some(b) + } +} + +/// Coverage mask (0..1), row-major. +#[derive(Clone, Debug, PartialEq)] +pub struct Mask { + pub w: usize, + pub h: usize, + pub a: Vec, +} + +impl Mask { + pub fn new(w: usize, h: usize) -> Self { + Self { w, h, a: vec![0.0; w * h] } + } + pub fn get(&self, x: usize, y: usize) -> f32 { + self.a[y * self.w + x] + } +} + +/// Fill `path` (non-zero winding) into a `w`×`h` mask, offsetting the path by `(dx, dy)`. +pub fn fill(path: &Path, w: usize, h: usize, dx: f32, dy: f32) -> Mask { + let mut m = Mask::new(w, h); + if w == 0 || h == 0 { + return m; + } + // edges: (ytop, ybot, x at ytop, dx/dy, winding) + let mut edges: Vec<(f32, f32, f32, f32, i32)> = path + .segs + .iter() + .filter(|s| s[1] != s[3]) + .map(|s| { + let (x0, y0, x1, y1) = (s[0] + dx, s[1] + dy, s[2] + dx, s[3] + dy); + let (dir, xa, ya, xb, yb) = if y0 < y1 { (1, x0, y0, x1, y1) } else { (-1, x1, y1, x0, y0) }; + (ya, yb, xa, (xb - xa) / (yb - ya), dir) + }) + .collect(); + edges.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut xs: Vec<(f32, i32)> = Vec::new(); + let mut row = vec![0.0f32; w + 1]; + let weight = 1.0 / SUB as f32; + for y in 0..h { + row.iter_mut().for_each(|v| *v = 0.0); + let mut any = false; + for k in 0..SUB { + let sy = y as f32 + (k as f32 + 0.5) / SUB as f32; + xs.clear(); + for e in &edges { + if e.0 > sy { + break; // sorted by top + } + if sy < e.1 { + xs.push((e.2 + (sy - e.0) * e.3, e.4)); + } + } + if xs.len() < 2 { + continue; + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut wind = 0; + let mut span_start = 0.0f32; + for &(x, d) in &xs { + let before = wind; + wind += d; + if before == 0 && wind != 0 { + span_start = x; + } else if before != 0 && wind == 0 { + add_span(&mut row, span_start, x, weight, w); + any = true; + } + } + } + if any { + let out = &mut m.a[y * w..(y + 1) * w]; + for (o, v) in out.iter_mut().zip(&row) { + *o = v.min(1.0); + } + } + } + m +} + +/// Add coverage `weight` over `[x0, x1)` with fractional ends. +fn add_span(row: &mut [f32], x0: f32, x1: f32, weight: f32, w: usize) { + let x0 = x0.clamp(0.0, w as f32); + let x1 = x1.clamp(0.0, w as f32); + if x1 <= x0 { + return; + } + let i0 = x0.floor() as usize; + let i1 = x1.floor() as usize; + if i0 == i1 { + row[i0] += (x1 - x0) * weight; + return; + } + row[i0] += (i0 as f32 + 1.0 - x0) * weight; + for v in &mut row[i0 + 1..i1] { + *v += weight; + } + if i1 < w { + row[i1] += (x1 - i1 as f32) * weight; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Path { + let mut p = Path::default(); + p.move_to(x0, y0); + p.line_to(x1, y0); + p.line_to(x1, y1); + p.line_to(x0, y1); + p.close(); + p + } + + #[test] + fn rectangle_coverage() { + let m = fill(&rect(1.5, 2.0, 4.0, 5.0), 6, 6, 0.0, 0.0); + assert!((m.get(1, 3) - 0.5).abs() < 1e-5); + assert!((m.get(2, 3) - 1.0).abs() < 1e-5); + assert_eq!(m.get(4, 3), 0.0); + assert_eq!(m.get(2, 1), 0.0); + assert_eq!(m.get(2, 5), 0.0); + let total: f32 = m.a.iter().sum(); + assert!((total - 2.5 * 3.0).abs() < 1e-3, "{total}"); + } + + #[test] + fn hole_with_opposite_winding() { + let mut p = rect(0.0, 0.0, 10.0, 10.0); + // inner square wound the other way + p.move_to(3.0, 3.0); + p.line_to(3.0, 7.0); + p.line_to(7.0, 7.0); + p.line_to(7.0, 3.0); + p.close(); + let m = fill(&p, 10, 10, 0.0, 0.0); + assert_eq!(m.get(5, 5), 0.0); + assert!((m.get(1, 5) - 1.0).abs() < 1e-5); + } + + #[test] + fn circle_area() { + let mut p = Path::default(); + let (cx, cy, r) = (10.0f32, 10.0f32, 6.0f32); + p.move_to(cx + r, cy); + for i in 1..=64 { + let a = i as f32 / 64.0 * std::f32::consts::TAU; + p.line_to(cx + r * a.cos(), cy + r * a.sin()); + } + p.close(); + let m = fill(&p, 20, 20, 0.0, 0.0); + let total: f32 = m.a.iter().sum(); + let want = std::f32::consts::PI * r * r; + assert!((total - want).abs() / want < 0.02, "{total} vs {want}"); + } +} diff --git a/crates/captions/src/scc.rs b/crates/captions/src/scc.rs new file mode 100644 index 00000000..a374cf40 --- /dev/null +++ b/crates/captions/src/scc.rs @@ -0,0 +1,514 @@ +//! Scenarist SCC (`.scc`): CEA-608 byte pairs with SMPTE timecodes at 29.97 fps. +//! +//! ```text +//! Scenarist_SCC V1.0 +//! +//! 00:00:00;00 9420 9420 94ae 94ae 1370 1370 c845 4c4c cf80 942f 942f +//! +//! 00:00:02;00 942c 942c +//! ``` +//! +//! A tab separates the timecode from the code words. Each line's pairs are sent one per frame +//! starting at its timecode. **Reading** runs a channel-1 +//! CEA-608 decoder (pop-on, roll-up, paint-on; redundant control codes ignored) and emits a cue +//! whenever the displayed memory changes. **Writing** produces pop-on captions: for each cue the +//! load (RCL, ENM, PAC + tab offset per row, text) is scheduled into free frames before the cue so +//! its End of Caption lands exactly on the cue start; an Erase Displayed Memory clears it at the +//! cue end (omitted when the next caption replaces it). Rows are bottom-aligned (row 15 last), +//! centred, wrapped at 32 columns; characters outside the 608 sets are dropped. + +use std::collections::BTreeMap; + +use filmcraft_time::{FrameRate, Tick, fields_to_frames, frames_to_fields}; + +use crate::cea608::{self, Encoded, misc}; +use crate::{Cue, Document, Error, Result}; + +/// SCC is always 29.97 fps. +pub const RATE: FrameRate = FrameRate::FPS_29_97; +const COLS: usize = 32; +const HEADER: &str = "Scenarist_SCC V1.0"; + +/// Parse `HH:MM:SS:FF` / `HH:MM:SS;FF` (drop-frame when any separator is `;` or `.`). +pub fn parse_timecode(s: &str) -> Option { + let df = s.contains([';', '.', ',']); + let parts: Vec<&str> = s.split([':', ';', '.', ',']).collect(); + let [h, m, sec, f] = parts.as_slice() else { return None }; + let n = |p: &str| if (1..=3).contains(&p.len()) { p.parse::().ok() } else { None }; + Some(fields_to_frames(n(h)?, n(m)?, n(sec)?, n(f)?, RATE, df)) +} + +pub fn format_timecode(frame: i64, drop_frame: bool) -> String { + let (_, h, m, s, f) = frames_to_fields(frame.max(0), RATE, drop_frame); + format!("{h:02}:{m:02}:{s:02}{}{f:02}", if drop_frame { ';' } else { ':' }) +} + +// ------------------------------------------------------------------------------------------------ +// Decoder +// ------------------------------------------------------------------------------------------------ + +type Memory = [[Option; COLS]; 15]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + PopOn, + RollUp(usize), + PaintOn, +} + +struct Decoder { + displayed: Memory, + hidden: Memory, + mode: Mode, + row: usize, + col: usize, + /// Last control pair (for redundancy filtering). + last_ctrl: Option<(u8, u8)>, + /// Channel of the current data (true = channel 1). + ch1: bool, + /// Cue being shown: (start frame, text). + open: Option<(i64, String)>, + cues: Vec<(i64, i64, String)>, +} + +fn memory_text(m: &Memory) -> String { + m.iter().map(|r| r.iter().map(|c| c.unwrap_or(' ')).collect::().trim().to_string()).filter(|r| !r.is_empty()).collect::>().join("\n") +} + +impl Decoder { + fn new() -> Self { + Self { + displayed: [[None; COLS]; 15], + hidden: [[None; COLS]; 15], + mode: Mode::PopOn, + row: 14, + col: 0, + last_ctrl: None, + ch1: true, + open: None, + cues: Vec::new(), + } + } + + fn target(&mut self) -> &mut Memory { + if self.mode == Mode::PopOn { &mut self.hidden } else { &mut self.displayed } + } + + fn put(&mut self, c: char) { + let (r, col) = (self.row, self.col.min(COLS - 1)); + self.target()[r][col] = Some(c); + self.col = (self.col + 1).min(COLS - 1); + } + + /// Displayed memory may have changed at frame `f`: close/open cues. + fn sync(&mut self, f: i64) { + let now = memory_text(&self.displayed); + let cur = self.open.as_ref().map(|o| o.1.clone()).unwrap_or_default(); + if now == cur { + return; + } + if let Some((s, text)) = self.open.take() + && f > s + { + self.cues.push((s, f, text)); + } + if !now.is_empty() { + self.open = Some((f, now)); + } + } + + fn pair(&mut self, f: i64, b1: u8, b2: u8) { + let (b1, b2) = (b1 & 0x7f, b2 & 0x7f); + if (0x10..=0x1f).contains(&b1) { + if self.last_ctrl == Some((b1, b2)) { + self.last_ctrl = None; + return; + } + self.last_ctrl = Some((b1, b2)); + self.ch1 = b1 & 0x08 == 0; + if !self.ch1 { + return; + } + self.control(f, b1 & 0x17, b2); + return; + } + self.last_ctrl = None; + if !self.ch1 { + return; + } + for b in [b1, b2] { + if let Some(c) = cea608::basic_char(b) { + self.put(c); + } + } + if self.mode != Mode::PopOn { + self.sync(f); + } + } + + fn control(&mut self, f: i64, b1: u8, b2: u8) { + // b1 has the channel bit cleared; 0x14/0x15 both carry misc codes (field 1 / 2 variants) + if (b1 == 0x14 || b1 == 0x15) && (0x20..=0x2f).contains(&b2) { + match b2 { + misc::RCL => self.mode = Mode::PopOn, + misc::RDC => self.mode = Mode::PaintOn, + misc::RU2 | misc::RU3 | misc::RU4 => { + let n = (b2 - misc::RU2) as usize + 2; + if !matches!(self.mode, Mode::RollUp(_)) { + self.displayed = [[None; COLS]; 15]; + self.row = 14; + } + self.mode = Mode::RollUp(n); + self.col = 0; + } + misc::BS => { + self.col = self.col.saturating_sub(1); + let (r, c) = (self.row, self.col); + self.target()[r][c] = None; + } + misc::DER => { + let (r, c) = (self.row, self.col); + for x in c..COLS { + self.target()[r][x] = None; + } + } + misc::EDM => self.displayed = [[None; COLS]; 15], + misc::ENM => self.hidden = [[None; COLS]; 15], + misc::EOC => { + std::mem::swap(&mut self.displayed, &mut self.hidden); + self.mode = Mode::PopOn; + } + misc::CR => { + if let Mode::RollUp(n) = self.mode { + let top = self.row + 1 - n.min(self.row + 1); + for r in top..self.row { + self.displayed[r] = self.displayed[r + 1]; + } + if top > 0 { + self.displayed[top - 1] = [None; COLS]; + } + self.displayed[self.row] = [None; COLS]; + self.col = 0; + } + } + _ => {} + } + self.sync(f); + return; + } + if b1 == 0x17 && (0x21..=0x23).contains(&b2) { + self.col = (self.col + (b2 - 0x20) as usize).min(COLS - 1); + return; + } + if b1 == 0x11 && (0x30..=0x3f).contains(&b2) { + self.put(cea608::SPECIAL[(b2 - 0x30) as usize]); + if self.mode != Mode::PopOn { + self.sync(f); + } + return; + } + if (b1 == 0x12 || b1 == 0x13) && (0x20..=0x3f).contains(&b2) { + // extended characters replace the preceding (fallback) character + self.col = self.col.saturating_sub(1); + let set = if b1 == 0x12 { &cea608::EXT_12 } else { &cea608::EXT_13 }; + self.put(set[(b2 - 0x20) as usize]); + if self.mode != Mode::PopOn { + self.sync(f); + } + return; + } + if b1 == 0x11 && (0x20..=0x2f).contains(&b2) { + // mid-row style change: shown as a space + self.put(' '); + return; + } + if let Some((row, indent)) = cea608::decode_pac(b1, b2) { + let row = row - 1; + if let Mode::RollUp(n) = self.mode + && row != self.row + { + // roll-up window moves with the base row + let old = self.displayed; + self.displayed = [[None; COLS]; 15]; + for k in 0..n { + if self.row >= k && row >= k { + self.displayed[row - k] = old[self.row - k]; + } + } + } + self.row = row; + self.col = indent; + } + } +} + +pub fn parse(text: &str) -> Result { + let mut lines = text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()); + match lines.next() { + Some((_, l)) if l.trim().starts_with("Scenarist_SCC") => {} + _ => return Err(Error::NotFormat("Scenarist SCC")), + } + let mut dec = Decoder::new(); + let mut last_frame = 0; + let mut doc = Document::default(); + for (n, l) in lines { + let mut it = l.split_whitespace(); + let Some(tc) = it.next() else { continue }; + let Some(frame) = parse_timecode(tc) else { + return Err(Error::Syntax { line: n + 1, msg: format!("bad timecode \"{tc}\"") }); + }; + for (k, w) in it.enumerate() { + let Ok(v) = u16::from_str_radix(w, 16) else { + doc.warnings.push(format!("line {}: bad code word \"{w}\"", n + 1)); + continue; + }; + let f = frame + k as i64; + dec.pair(f, (v >> 8) as u8, v as u8); + last_frame = last_frame.max(f + 1); + } + } + // a caption still showing at the end of the file stays up for 1 s more + if let Some((s, text)) = dec.open.take() { + let end = last_frame.max(s + 1) + 30; + dec.cues.push((s, end, text)); + } + doc.cues = dec.cues.into_iter().map(|(s, e, text)| Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text, ..Default::default() }).collect(); + Ok(doc) +} + +// ------------------------------------------------------------------------------------------------ +// Encoder +// ------------------------------------------------------------------------------------------------ + +/// Wrap plain text into rows of at most 32 columns (word wrap, hard-splitting long words). +pub fn wrap_rows(text: &str) -> Vec { + let mut rows = Vec::new(); + for line in text.lines() { + let mut cur = String::new(); + for word in line.split_whitespace() { + let mut word = word.to_string(); + loop { + let need = if cur.is_empty() { word.chars().count() } else { cur.chars().count() + 1 + word.chars().count() }; + if need <= COLS { + if !cur.is_empty() { + cur.push(' '); + } + cur.push_str(&word); + break; + } + if !cur.is_empty() { + rows.push(std::mem::take(&mut cur)); + continue; + } + let head: String = word.chars().take(COLS).collect(); + word = word.chars().skip(COLS).collect(); + rows.push(head); + if word.is_empty() { + break; + } + } + } + if !cur.is_empty() { + rows.push(cur); + } + } + rows.truncate(15); + rows +} + +/// Byte pairs (without parity) that load `text` into non-displayed memory. +fn load_pairs(text: &str) -> Vec<(u8, u8)> { + let plain = filmcraft_project::plain_text(text); + let rows = wrap_rows(&plain); + let mut out = vec![(0x14, misc::RCL), (0x14, misc::RCL), (0x14, misc::ENM), (0x14, misc::ENM)]; + let first_row = 16 - rows.len(); + for (i, row) in rows.iter().enumerate() { + let encoded: Vec = row.chars().filter_map(cea608::encode_char).collect(); + let width = encoded.len().min(COLS); + let col = (COLS - width) / 2; + let pac = cea608::encode_pac(first_row + i, col); + out.push(pac); + out.push(pac); + if !col.is_multiple_of(4) { + let to = (0x17, 0x20 + (col % 4) as u8); + out.push(to); + out.push(to); + } + let mut pending: Option = None; + let flush = |pending: &mut Option, out: &mut Vec<(u8, u8)>| { + if let Some(b) = pending.take() { + out.push((b, 0x00)); + } + }; + for e in encoded.into_iter().take(COLS) { + match e { + Encoded::Basic(b) => match pending.take() { + Some(a) => out.push((a, b)), + None => pending = Some(b), + }, + Encoded::Pair { b1, b2, fallback } => { + if let Some(fb) = fallback { + match pending.take() { + Some(a) => out.push((a, fb)), + None => out.push((fb, 0x00)), + } + } else { + flush(&mut pending, &mut out); + } + out.push((b1, b2)); + out.push((b1, b2)); + } + } + } + flush(&mut pending, &mut out); + } + out +} + +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 { + let mut occ: BTreeMap = BTreeMap::new(); + let mut cursor = 0i64; // first frame this cue's load may use + let mut prev_end: Option = None; + let edm = (0x14, misc::EDM); + let eoc = (0x14, misc::EOC); + let place_free_from = |occ: &mut BTreeMap, mut f: i64, p: (u8, u8)| -> i64 { + while occ.contains_key(&f) { + f += 1; + } + occ.insert(f, p); + f + }; + let mut cues: Vec<&Cue> = doc.cues.iter().filter(|c| c.end > c.start).collect(); + cues.sort_by_key(|c| c.start); + for c in cues { + let s = RATE.frame_at(RATE.snap_nearest(c.start)).max(cursor); + let e = RATE.frame_at(RATE.snap_nearest(c.end)); + // clear the previous caption if there is a gap before this one + if let Some(pe) = prev_end.take() + && pe < s + { + let f = place_free_from(&mut occ, pe, edm); + if !occ.contains_key(&(f + 1)) && f + 1 < s { + occ.insert(f + 1, edm); + } + } + let load = load_pairs(&c.text); + let n = load.len() as i64; + // Doubled codes must stay adjacent, so the load is one contiguous run of free frames: + // the latest run ending by `s`, else the first run after `cursor` (delaying the caption). + let run_free = |occ: &BTreeMap, b: i64| (b..b + n).all(|f| !occ.contains_key(&f)); + let b = (cursor..=s - n).rev().find(|&b| run_free(&occ, b)).unwrap_or_else(|| (cursor..).find(|&b| run_free(&occ, b)).unwrap_or(cursor)); + for (k, p) in load.into_iter().enumerate() { + occ.insert(b + k as i64, p); + } + let mut d = s.max(b + n); + while occ.contains_key(&d) { + d += 1; + } + occ.insert(d, eoc); + let mut next = d + 1; + if let std::collections::btree_map::Entry::Vacant(v) = occ.entry(next) { + v.insert(eoc); + next += 1; + } + cursor = next; + prev_end = Some(e.max(next)); + } + if let Some(pe) = prev_end { + let f = place_free_from(&mut occ, pe, edm); + occ.insert(f + 1, edm); + } + let mut out = String::from(HEADER); + out.push_str("\n\n"); + let mut line: Option<(i64, Vec)> = None; + let mut last = i64::MIN; + for (f, p) in occ { + if f != last + 1 + && let Some((start, words)) = line.take() + { + out.push_str(&format!("{}\t{}\n\n", format_timecode(start, drop_frame), words.join(" "))); + } + line.get_or_insert_with(|| (f, Vec::new())).1.push(format!("{:04x}", word(p))); + last = f; + } + if let Some((start, words)) = line { + out.push_str(&format!("{}\t{}\n\n", format_timecode(start, drop_frame), words.join(" "))); + } + out +} + +/// Convert ticks to the SCC frame grid (nearest frame). +pub fn frame_of(t: Tick) -> i64 { + RATE.frame_at(RATE.snap_nearest(t)) +} + +#[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 timecodes() { + assert_eq!(parse_timecode("00:01:00;02"), Some(1800)); + assert_eq!(parse_timecode("00:01:00:00"), Some(1800)); + assert_eq!(format_timecode(1800, true), "00:01:00;02"); + assert_eq!(format_timecode(1800, false), "00:01:00:00"); + assert_eq!(parse_timecode("00:10:00;00"), Some(17982)); + } + + #[test] + fn decode_known_popon() { + // "HELLO" on row 15 at 00:00:01;00, cleared at 00:00:03;00 + let scc = "Scenarist_SCC V1.0\n\n00:00:00;20\t9420 9420 94ae 94ae 94d0 94d0 c845 4c4c 4f80 942f 942f\n\n00:00:03;00\t942c 942c\n"; + let d = parse(scc).unwrap(); + assert_eq!(d.cues.len(), 1); + assert_eq!(d.cues[0].text, "HELLO"); + assert_eq!(RATE.frame_at(d.cues[0].start), 20 + 9); + assert_eq!(RATE.frame_at(d.cues[0].end), 90); + } + + #[test] + fn decode_rollup() { + // RU2, CR, "AB", CR, "CD" + let scc = "Scenarist_SCC V1.0\n\n00:00:00:00\t9425 9425 94ad 94ad c1c2 94ad 94ad 4344\n\n00:00:02:00\t942c 942c\n"; + let d = parse(scc).unwrap(); + let texts: Vec<&str> = d.cues.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, vec!["AB", "AB\nCD"]); + } + + #[test] + fn write_then_read() { + let doc = Document { + cues: vec![cue(60, 120, "Hello, world!"), cue(120, 200, "Second caption\nwith two lines"), cue(260, 300, "Ñandú ♪ {x} * café “q”")], + ..Default::default() + }; + let s = write(&doc, true); + assert!(s.starts_with("Scenarist_SCC V1.0\n\n")); + 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, "Ñandú ♪ {x} * café “q”")]); + } + + #[test] + fn wraps_long_lines() { + let rows = wrap_rows("the quick brown fox jumps over the lazy dog and keeps running far away"); + assert!(rows.iter().all(|r| r.chars().count() <= 32)); + assert_eq!(rows.join(" "), "the quick brown fox jumps over the lazy dog and keeps running far away"); + } + + #[test] + fn cue_at_zero_is_delayed_not_lost() { + let doc = Document { cues: vec![cue(0, 60, "Start")], ..Default::default() }; + let back = parse(&write(&doc, false)).unwrap(); + assert_eq!(back.cues.len(), 1); + assert_eq!(back.cues[0].text, "Start"); + assert_eq!(RATE.frame_at(back.cues[0].end), 60); + } +} diff --git a/crates/captions/src/srt.rs b/crates/captions/src/srt.rs new file mode 100644 index 00000000..ba90f3bd --- /dev/null +++ b/crates/captions/src/srt.rs @@ -0,0 +1,134 @@ +//! SubRip (`.srt`). +//! +//! ```text +//! 1 +//! 00:00:01,000 --> 00:00:03,500 +//! First line +//! second line +//! +//! 2 +//! … +//! ``` +//! +//! Reading accepts what real files contain: missing or wrong indexes, `.` instead of `,`, 1–3 +//! digit fractions, missing hours, extra spaces, trailing SSA position coordinates +//! (`X1:… Y2:…`), and cues not separated by a blank line (a new cue starts at a timing line, or at +//! an integer line directly followed by one). Writing renumbers from 1 and rounds to milliseconds. + +use filmcraft_time::Tick; + +use crate::{Cue, Document, Error, Result, format_clock, parse_clock}; + +/// Parse `start --> end[ settings]` (SRT or VTT timing line). Returns (start, end, settings). +pub fn parse_timing_line(line: &str) -> Option<(Tick, Tick, &str)> { + let (a, rest) = line.split_once("-->")?; + let start = parse_clock(a.trim())?; + let rest = rest.trim_start(); + let end_len = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let end = parse_clock(&rest[..end_len])?; + Some((start, end, rest[end_len..].trim())) +} + +fn is_index(line: &str) -> bool { + let l = line.trim(); + !l.is_empty() && l.len() <= 10 && l.bytes().all(|b| b.is_ascii_digit()) +} + +pub fn parse(text: &str) -> Result { + let lines: Vec<&str> = text.lines().collect(); + let mut doc = Document::default(); + let mut i = 0; + let mut found_any = false; + while i < lines.len() { + let Some((start, end, _settings)) = parse_timing_line(lines[i]) else { + let l = lines[i].trim(); + if !l.is_empty() && !is_index(l) && found_any { + doc.warnings.push(format!("line {}: ignored stray text \"{l}\"", i + 1)); + } + i += 1; + continue; + }; + found_any = true; + i += 1; + let mut text_lines: Vec<&str> = Vec::new(); + while i < lines.len() { + let l = lines[i]; + if l.trim().is_empty() { + break; + } + if parse_timing_line(l).is_some() { + break; + } + if is_index(l) && lines.get(i + 1).is_some_and(|n| parse_timing_line(n).is_some()) { + break; + } + text_lines.push(l.trim_end()); + i += 1; + } + if end <= start { + doc.warnings.push(format!("cue at {} has end before start; skipped", format_clock(start, ','))); + continue; + } + doc.cues.push(Cue { start, end, text: text_lines.join("\n"), ..Default::default() }); + } + if !found_any && !text.trim().is_empty() { + return Err(Error::NotFormat("SubRip")); + } + Ok(doc) +} + +pub fn write(doc: &Document) -> String { + let mut out = String::new(); + for (n, c) in doc.cues.iter().enumerate() { + out.push_str(&format!("{}\n{} --> {}\n", n + 1, format_clock(c.start, ','), format_clock(c.end, ','))); + // blank lines inside text would end the cue: drop them + for l in c.text.lines().filter(|l| !l.trim().is_empty()) { + out.push_str(l); + out.push('\n'); + } + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TICKS_PER_MS; + + fn ms(v: i64) -> Tick { + Tick(v * TICKS_PER_MS) + } + + #[test] + fn basic() { + let d = parse("1\n00:00:01,000 --> 00:00:02,500\nHello\nWorld\n\n2\n00:00:03,000 --> 00:00:04,000\nBye\n").unwrap(); + assert_eq!(d.cues.len(), 2); + assert_eq!(d.cues[0].start, ms(1000)); + assert_eq!(d.cues[0].end, ms(2500)); + assert_eq!(d.cues[0].text, "Hello\nWorld"); + assert_eq!(write(&d), "1\n00:00:01,000 --> 00:00:02,500\nHello\nWorld\n\n2\n00:00:03,000 --> 00:00:04,000\nBye\n\n"); + } + + #[test] + fn sloppy() { + // no indexes, period ms, short fractions, no hours, coordinates, no blank line between cues + let src = "00:00:01.5 --> 00:00:02.25 X1:10 X2:20 Y1:1 Y2:2\nA\n7\n00:03,000 --> 00:04,000\nB\n00:00:05,000-->00:00:06,000\n42\n\n\n"; + let d = parse(src).unwrap(); + let got: Vec<(i64, i64, &str)> = d.cues.iter().map(|c| (c.start.0 / TICKS_PER_MS, c.end.0 / TICKS_PER_MS, c.text.as_str())).collect(); + assert_eq!(got, vec![(1500, 2250, "A"), (3000, 4000, "B"), (5000, 6000, "42")]); + } + + #[test] + fn rejects_garbage() { + assert!(parse("hello world\nthis is not srt").is_err()); + assert!(parse("").unwrap().cues.is_empty()); + } + + #[test] + fn skips_inverted_cue() { + let d = parse("1\n00:00:05,000 --> 00:00:04,000\nX\n\n2\n00:00:06,000 --> 00:00:07,000\nY\n").unwrap(); + assert_eq!(d.cues.len(), 1); + assert_eq!(d.warnings.len(), 1); + } +} diff --git a/crates/captions/src/vtt.rs b/crates/captions/src/vtt.rs new file mode 100644 index 00000000..8c1d57bd --- /dev/null +++ b/crates/captions/src/vtt.rs @@ -0,0 +1,186 @@ +//! WebVTT (`.vtt`). +//! +//! ```text +//! WEBVTT +//! +//! STYLE +//! ::cue { color: yellow } +//! +//! intro +//! 00:00:01.000 --> 00:00:03.500 line:90% align:center +//! Hello there +//! ``` +//! +//! Kept on read: cue identifiers, cue settings (verbatim), a leading `` voice span (as +//! the speaker), inline markup in the text, and STYLE / REGION / NOTE blocks. A missing `WEBVTT` +//! signature is tolerated. Timestamps may omit hours. + +use crate::srt::parse_timing_line; +use crate::{Cue, Document, Error, Result, format_clock}; + +/// Split a leading `` / `` voice span off cue text. +pub fn split_voice(text: &str) -> (Option, String) { + let t = text.trim_start(); + if let Some(rest) = t.strip_prefix("') + { + let inner = &rest[..close]; + // skip classes: `.loud.x Name` + let name = match inner.find([' ', '\t']) { + Some(sp) => inner[sp..].trim(), + None => "", + }; + let body = &rest[close + 1..]; + // a closing at the very end goes too; one in the middle means several voices: keep all + let body = match body.find("") { + Some(p) if p + 4 == body.trim_end().len() => body[..p].to_string(), + Some(_) => return (None, text.to_string()), + None => body.to_string(), + }; + if body.contains(" Result { + let mut doc = Document::default(); + let mut lines = text.lines().peekable(); + // signature + header text + let has_sig = lines.peek().is_some_and(|l| { + let l = l.trim_start_matches('\u{feff}'); + l == "WEBVTT" || l.starts_with("WEBVTT ") || l.starts_with("WEBVTT\t") + }); + if has_sig { + for l in lines.by_ref() { + if l.trim().is_empty() { + break; + } + } + } + // blocks + let mut block: Vec<&str> = Vec::new(); + let mut blocks: Vec> = Vec::new(); + for l in lines { + if l.trim().is_empty() { + if !block.is_empty() { + blocks.push(std::mem::take(&mut block)); + } + } else { + block.push(l); + } + } + if !block.is_empty() { + blocks.push(block); + } + let mut seen_cue = false; + for b in blocks { + let first = b[0].trim(); + if !first.contains("-->") && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) { + doc.blocks.push(b.join("\n")); + continue; + } + if !seen_cue && !first.contains("-->") && (first == "STYLE" || first == "REGION") { + doc.blocks.push(b.join("\n")); + continue; + } + let (id, timing_idx) = if b[0].contains("-->") { (None, 0) } else { (Some(b[0].trim().to_string()), 1) }; + let Some(timing) = b.get(timing_idx) else { + doc.warnings.push(format!("ignored block \"{first}\"")); + continue; + }; + let Some((start, end, settings)) = parse_timing_line(timing) else { + doc.warnings.push(format!("bad timing line \"{}\"", timing.trim())); + continue; + }; + seen_cue = true; + if end <= start { + doc.warnings.push(format!("cue at {} has end before start; skipped", format_clock(start, '.'))); + continue; + } + let raw = b[timing_idx + 1..].iter().map(|l| l.trim_end()).collect::>().join("\n"); + let (speaker, text) = split_voice(&raw); + doc.cues.push(Cue { start, end, text, speaker, id, settings: settings.to_string() }); + } + if !has_sig && doc.cues.is_empty() && !text.trim().is_empty() { + return Err(Error::NotFormat("WebVTT")); + } + Ok(doc) +} + +pub fn write(doc: &Document) -> String { + let mut out = String::from("WEBVTT\n\n"); + for b in &doc.blocks { + let b = b.trim_end(); + if !b.is_empty() { + out.push_str(b); + out.push_str("\n\n"); + } + } + for c in &doc.cues { + if let Some(id) = c.id.as_deref().map(str::trim).filter(|i| !i.is_empty() && !i.contains("-->")) { + out.push_str(id); + out.push('\n'); + } + out.push_str(&format!("{} --> {}", format_clock(c.start, '.'), format_clock(c.end, '.'))); + if !c.settings.trim().is_empty() { + out.push(' '); + out.push_str(c.settings.trim()); + } + out.push('\n'); + let mut text = String::new(); + if let Some(s) = c.speaker.as_deref().filter(|s| !s.is_empty()) { + text.push_str(&format!("")); + } + text.push_str(&c.text); + for l in text.lines().filter(|l| !l.trim().is_empty()) { + out.push_str(&l.replace("-->", "-->")); + out.push('\n'); + } + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TICKS_PER_MS; + + const SAMPLE: &str = "WEBVTT - with header\nKind: captions\n\nSTYLE\n::cue { color: yellow }\n\nNOTE a comment\nspanning lines\n\nintro\n00:01.000 --> 00:00:03.500 line:90% align:start\nHello there\nsecond line\n\n00:00:04.000 --> 00:00:05.000\nplain & tags\n"; + + #[test] + fn parse_sample() { + let d = parse(SAMPLE).unwrap(); + assert_eq!(d.blocks, vec!["STYLE\n::cue { color: yellow }".to_string(), "NOTE a comment\nspanning lines".to_string()]); + assert_eq!(d.cues.len(), 2); + let c = &d.cues[0]; + assert_eq!(c.id.as_deref(), Some("intro")); + assert_eq!(c.start.0, 1000 * TICKS_PER_MS); + assert_eq!(c.end.0, 3500 * TICKS_PER_MS); + assert_eq!(c.settings, "line:90% align:start"); + assert_eq!(c.speaker.as_deref(), Some("Ann Smith")); + assert_eq!(c.text, "Hello there\nsecond line"); + assert_eq!(d.cues[1].text, "plain & tags"); + let w = write(&d); + assert!(w.starts_with("WEBVTT\n\nSTYLE\n::cue { color: yellow }\n\nNOTE a comment\nspanning lines\n\nintro\n00:00:01.000 --> 00:00:03.500 line:90% align:start\nHello there\n")); + assert_eq!(parse(&w).unwrap().cues, d.cues); + } + + #[test] + fn voices() { + assert_eq!(split_voice("hi"), (Some("Bob".into()), "hi".into())); + assert_eq!(split_voice("hi yo"), (None, "hi yo".into())); + assert_eq!(split_voice("hi"), (None, "hi".into())); + } + + #[test] + fn without_signature() { + let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap(); + assert_eq!(d.cues.len(), 1); + assert!(parse("just text").is_err()); + } +} diff --git a/crates/captions/tests/roundtrip.rs b/crates/captions/tests/roundtrip.rs new file mode 100644 index 00000000..18aece36 --- /dev/null +++ b/crates/captions/tests/roundtrip.rs @@ -0,0 +1,143 @@ +//! Round-trip property tests: write(parse(write(doc))) is stable and parse(write(doc)) == doc for +//! documents each format can represent exactly. + +use filmcraft_captions::{Cue, Document, Format, WriteOptions, parse, scc, write}; +use filmcraft_time::{TICKS_PER_SECOND, Tick}; +use proptest::prelude::*; + +const MS: i64 = TICKS_PER_SECOND / 1000; + +/// A text line: printable words without markup-significant sequences. +fn line() -> impl Strategy { + proptest::collection::vec("[A-Za-z0-9éñü,.!?'\"()&%-]{1,10}", 1..6).prop_map(|w| w.join(" ")) +} + +fn text() -> impl Strategy { + proptest::collection::vec(line(), 1..4).prop_map(|l| l.join("\n")) +} + +/// Cues on a millisecond grid, non-overlapping, sorted. +fn ms_cues(max: usize) -> impl Strategy> { + proptest::collection::vec((0i64..5_000, 1i64..8_000, text()), 0..max).prop_map(|v| { + let mut t = 0i64; + v.into_iter() + .map(|(gap, dur, text)| { + let start = t + gap; + t = start + dur; + Cue { start: Tick(start * MS), end: Tick(t * MS), text, ..Default::default() } + }) + .collect() + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn srt_roundtrip(cues in ms_cues(12)) { + let doc = Document { cues, ..Default::default() }; + let bytes = write(&doc, Format::Srt, WriteOptions::default()); + let back = parse(&bytes, Format::Srt).unwrap(); + prop_assert_eq!(&back.cues, &doc.cues); + prop_assert_eq!(write(&back, Format::Srt, WriteOptions::default()), bytes); + } + + #[test] + fn srt_crlf_bom_roundtrip(cues in ms_cues(6)) { + let doc = Document { cues, ..Default::default() }; + let s = String::from_utf8(write(&doc, Format::Srt, WriteOptions::default())).unwrap(); + let mut bytes = vec![0xef, 0xbb, 0xbf]; + bytes.extend(s.replace('\n', "\r\n").into_bytes()); + let back = parse(&bytes, Format::Srt).unwrap(); + prop_assert_eq!(back.cues, doc.cues); + } + + #[test] + fn vtt_roundtrip( + cues in ms_cues(12), + ids in proptest::collection::vec(proptest::option::of("[a-z][a-z0-9-]{0,8}"), 12), + speakers in proptest::collection::vec(proptest::option::of("[A-Z][a-z]{1,6}( [A-Z][a-z]{1,6})?"), 12), + settings in proptest::collection::vec(prop_oneof![Just(String::new()), Just("line:90%".to_string()), Just("align:start position:10%".to_string())], 12), + ) { + let cues: Vec = cues + .into_iter() + .enumerate() + .map(|(i, mut c)| { + c.id = ids[i].clone(); + c.speaker = speakers[i].clone(); + c.settings = settings[i].clone(); + c + }) + .collect(); + let doc = Document { cues, blocks: vec!["STYLE\n::cue { color: #ff0 }".into()], ..Default::default() }; + let bytes = write(&doc, Format::WebVtt, WriteOptions::default()); + let back = parse(&bytes, Format::WebVtt).unwrap(); + prop_assert_eq!(&back.cues, &doc.cues); + prop_assert_eq!(&back.blocks, &doc.blocks); + prop_assert_eq!(write(&back, Format::WebVtt, WriteOptions::default()), bytes); + } + + /// SCC: frame-exact at 29.97 fps for cues with room to load (≥ 2 s apart) and 608-safe text. + #[test] + fn scc_roundtrip( + v in proptest::collection::vec((60i64..400, 3i64..300, proptest::collection::vec("[A-Za-z0-9,.!?'()é-]{1,8}", 1..4)), 1..8), + drop_frame in any::(), + ) { + let mut f = 0i64; + let cues: Vec = v + .into_iter() + .map(|(gap, dur, words)| { + let start = f + gap; + f = start + dur; + Cue { start: scc::RATE.tick_of(start), end: scc::RATE.tick_of(f), text: words.join(" "), ..Default::default() } + }) + .collect(); + let doc = Document { cues, ..Default::default() }; + let bytes = write(&doc, Format::Scc, WriteOptions { drop_frame }); + let back = parse(&bytes, Format::Scc).unwrap(); + prop_assert_eq!(&back.cues, &doc.cues); + prop_assert_eq!(write(&back, Format::Scc, WriteOptions { drop_frame }), bytes); + } + + /// Back-to-back SCC captions (no gap) still come back frame-exact when each lasts long enough + /// for the next one to load while it is showing. + #[test] + fn scc_back_to_back(v in proptest::collection::vec((45i64..200, "[A-Z][a-z]{1,8}( [a-z]{1,8}){0,2}"), 1..6)) { + let mut f = 90i64; + let cues: Vec = v + .into_iter() + .map(|(dur, text)| { + let start = f; + f += dur; + Cue { start: scc::RATE.tick_of(start), end: scc::RATE.tick_of(f), text, ..Default::default() } + }) + .collect(); + let doc = Document { cues, ..Default::default() }; + let back = parse(&write(&doc, Format::Scc, WriteOptions::default()), Format::Scc).unwrap(); + prop_assert_eq!(back.cues, doc.cues); + } + + /// Readers never panic on arbitrary input. + #[test] + fn readers_are_total(bytes in proptest::collection::vec(any::(), 0..400)) { + for f in Format::ALL { + let _ = parse(&bytes, f); + } + } +} + +#[test] +fn srt_to_vtt_to_scc_keeps_text_and_frames() { + let srt = b"1\r\n00:00:02,002 --> 00:00:04,004\r\nHello there\r\n\r\n2\r\n00:00:05,005 --> 00:00:07,007\r\nGeneral Kenobi\r\n"; + let mut doc = parse(srt, Format::Srt).unwrap(); + doc.snap_to_frames(scc::RATE); + let vtt = write(&doc, Format::WebVtt, WriteOptions::default()); + let doc2 = parse(&vtt, Format::WebVtt).unwrap(); + let scc_bytes = write(&doc2, Format::Scc, WriteOptions::default()); + let doc3 = parse(&scc_bytes, Format::Scc).unwrap(); + let texts: Vec<&str> = doc3.cues.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, vec!["Hello there", "General Kenobi"]); + // 2.002 s = frame 60 at 29.97 + assert_eq!(scc::frame_of(doc3.cues[0].start), 60); + assert_eq!(scc::frame_of(doc3.cues[1].end), 210); +} diff --git a/crates/edit/src/captions.rs b/crates/edit/src/captions.rs new file mode 100644 index 00000000..952a6d6e --- /dev/null +++ b/crates/edit/src/captions.rs @@ -0,0 +1,394 @@ +//! Caption edits. +//! +//! Captions live on caption tracks ([`CaptionTrack`]); like track items they never overlap. Every +//! function validates first and returns an error leaving the sequence untouched, or applies the +//! whole edit. Locked caption tracks refuse edits. Sync-locked caption tracks follow insert and +//! extract edits on the media tracks (see [`crate::insert`] / [`crate::extract`]). + +use filmcraft_project::{Caption, CaptionTrack, ClipId, Sequence, TrackId}; +use filmcraft_time::{Tick, TimeRange}; + +use crate::{Edge, EditCtx, EditError, Result}; + +/// Default duration of a new caption (Premiere uses about three seconds). +pub const DEFAULT_SECONDS: i64 = 3; + +fn track_mut(seq: &mut Sequence, id: TrackId) -> Result<&mut CaptionTrack> { + let t = seq.caption_track_mut(id).ok_or(EditError::NoTrack(id))?; + if t.locked { + return Err(EditError::Locked); + } + Ok(t) +} + +fn owner(seq: &Sequence, id: ClipId) -> Result { + seq.find_caption(id).map(|(t, _)| t).ok_or(EditError::NoItem(id)) +} + +/// Previous end / next start around caption `id` on its track. +fn neighbours(t: &CaptionTrack, id: ClipId) -> (Tick, Tick) { + let i = t.captions.iter().position(|c| c.id == id).unwrap_or(0); + let prev = if i > 0 { t.captions[i - 1].end() } else { Tick::ZERO }; + let next = t.captions.get(i + 1).map(|c| c.start).unwrap_or(Tick::MAX); + (prev, next) +} + +/// Add a caption at `start`. The duration is cut short at the next caption; fails if a caption +/// already covers `start` or less than one frame would remain. +pub fn add_caption(seq: &mut Sequence, track: TrackId, start: Tick, duration: Tick, text: &str, ctx: &mut EditCtx) -> Result { + let start = start.max(Tick::ZERO); + let t = track_mut(seq, track)?; + if t.caption_at(start).is_some() { + return Err(EditError::Other("a caption already covers this time".into())); + } + let next = t.captions.iter().map(|c| c.start).filter(|s| *s > start).min().unwrap_or(Tick::MAX); + let dur = duration.min(next - start); + if dur < ctx.min_duration { + return Err(EditError::TooShort); + } + let id = ClipId(ctx.alloc()); + t.captions.push(Caption { id, start, duration: dur, text: text.to_string(), speaker: None, cue_id: None, settings: String::new() }); + t.sort(); + Ok(id) +} + +/// Split caption `id` at `t` (strictly inside it); both halves keep the text. Returns the new +/// (right) caption. +pub fn split_caption(seq: &mut Sequence, id: ClipId, t: Tick, ctx: &mut EditCtx) -> Result { + let tid = owner(seq, id)?; + let min = ctx.min_duration; + let new_id = ClipId(ctx.alloc()); + let tr = track_mut(seq, tid)?; + let c = tr.caption_mut(id).ok_or(EditError::NoItem(id))?; + if t - c.start < min || c.end() - t < min { + return Err(EditError::TooShort); + } + let mut right = c.clone(); + right.id = new_id; + right.start = t; + right.duration = c.end() - t; + right.cue_id = None; + c.duration = t - c.start; + tr.captions.push(right); + tr.sort(); + Ok(new_id) +} + +/// Split every caption strictly containing `t` on unlocked caption tracks (all when `tracks` is +/// empty). Returns the new captions. +pub fn split_captions_at(seq: &mut Sequence, tracks: &[TrackId], t: Tick, ctx: &mut EditCtx) -> Vec { + let ids: Vec = seq + .caption_tracks + .iter() + .filter(|tr| !tr.locked && (tracks.is_empty() || tracks.contains(&tr.id))) + .filter_map(|tr| tr.caption_at(t).filter(|c| c.start < t).map(|c| c.id)) + .collect(); + ids.into_iter().filter_map(|id| split_caption(seq, id, t, ctx).ok()).collect() +} + +/// Merge captions on one track into the first: it spans from the first start to the last end and +/// joins the texts with line breaks. The captions must be consecutive (none in between). +pub fn merge_captions(seq: &mut Sequence, ids: &[ClipId]) -> Result { + if ids.len() < 2 { + return Err(EditError::Nothing); + } + let tid = owner(seq, ids[0])?; + for id in ids { + if owner(seq, *id)? != tid { + return Err(EditError::Other("captions to merge must be on one track".into())); + } + } + let tr = track_mut(seq, tid)?; + let mut idx: Vec = ids.iter().filter_map(|id| tr.captions.iter().position(|c| c.id == *id)).collect(); + idx.sort_unstable(); + idx.dedup(); + if idx.windows(2).any(|w| w[1] != w[0] + 1) { + return Err(EditError::Other("captions to merge must be next to each other".into())); + } + let first = idx[0]; + let last = *idx.last().expect("non-empty"); + let end = tr.captions[last].end(); + let text: Vec = idx.iter().map(|&i| tr.captions[i].text.trim().to_string()).filter(|t| !t.is_empty()).collect(); + let keep = tr.captions[first].id; + let c = &mut tr.captions[first]; + c.duration = end - c.start; + c.text = text.join("\n"); + tr.captions.drain(first + 1..=last); + Ok(keep) +} + +/// Change a caption's text and/or speaker. +pub fn set_caption(seq: &mut Sequence, id: ClipId, text: Option<&str>, speaker: Option>) -> Result<()> { + let tid = owner(seq, id)?; + let c = track_mut(seq, tid)?.caption_mut(id).ok_or(EditError::NoItem(id))?; + if let Some(t) = text { + c.text = t.replace("\r\n", "\n"); + } + if let Some(s) = speaker { + c.speaker = s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + } + Ok(()) +} + +/// Set a caption's in and out points directly (Captions panel timecode fields). +pub fn set_caption_times(seq: &mut Sequence, id: ClipId, start: Tick, end: Tick, ctx: &EditCtx) -> Result<()> { + let tid = owner(seq, id)?; + let tr = track_mut(seq, tid)?; + if end - start < ctx.min_duration || start < Tick::ZERO { + return Err(EditError::TooShort); + } + if tr.captions.iter().any(|c| c.id != id && c.start < end && start < c.end()) { + return Err(EditError::Other("captions would overlap".into())); + } + let c = tr.caption_mut(id).ok_or(EditError::NoItem(id))?; + c.start = start; + c.duration = end - start; + tr.sort(); + Ok(()) +} + +/// Trim a caption edge by `delta`, limited by its neighbours and a one-frame minimum. Returns the +/// delta applied. +pub fn trim_caption(seq: &mut Sequence, id: ClipId, edge: Edge, delta: Tick, ctx: &EditCtx) -> Result { + let tid = owner(seq, id)?; + let tr = track_mut(seq, tid)?; + let (prev, next) = neighbours(tr, id); + let c = tr.caption_mut(id).ok_or(EditError::NoItem(id))?; + let d = match edge { + Edge::In => delta.clamp(prev - c.start, c.duration - ctx.min_duration), + Edge::Out => delta.clamp(ctx.min_duration - c.duration, next - c.end()), + }; + if d == Tick::ZERO { + return Err(EditError::Nothing); + } + match edge { + Edge::In => { + c.start += d; + c.duration -= d; + } + Edge::Out => c.duration += d, + } + Ok(d) +} + +/// Move captions by `delta`, clamped so they stay at or after zero and do not overlap captions +/// that are not moving. Returns the delta applied. +pub fn move_captions(seq: &mut Sequence, ids: &[ClipId], delta: Tick) -> Result { + let mut lo = Tick::MIN; + let mut hi = Tick::MAX; + let mut tracks = Vec::new(); + for id in ids { + let tid = owner(seq, *id)?; + let tr = seq.caption_track(tid).ok_or(EditError::NoTrack(tid))?; + if tr.locked { + return Err(EditError::Locked); + } + let c = tr.caption(*id).ok_or(EditError::NoItem(*id))?; + lo = lo.max(-c.start); + // nearest non-moving neighbours on each side + for o in tr.captions.iter().filter(|o| !ids.contains(&o.id)) { + if o.end() <= c.start { + lo = lo.max(o.end() - c.start); + } else if o.start >= c.end() { + hi = hi.min(o.start - c.end()); + } else { + return Err(EditError::Other("captions overlap".into())); + } + } + if !tracks.contains(&tid) { + tracks.push(tid); + } + } + // a gap between moving and non-moving captions is jumped over only when it fits entirely + let d = delta.clamp(lo.min(Tick::ZERO), hi.max(Tick::ZERO)); + if d == Tick::ZERO { + return Err(EditError::Nothing); + } + for tid in tracks { + let tr = seq.caption_track_mut(tid).expect("checked"); + for c in tr.captions.iter_mut().filter(|c| ids.contains(&c.id)) { + c.start += d; + } + tr.sort(); + } + Ok(d) +} + +/// Delete captions. With `ripple`, later captions on the same track move left to close each gap. +pub fn delete_captions(seq: &mut Sequence, ids: &[ClipId], ripple: bool) -> Result { + let mut n = 0; + for id in ids { + let tid = owner(seq, *id)?; + if seq.caption_track(tid).is_some_and(|t| t.locked) { + return Err(EditError::Locked); + } + } + for tr in seq.caption_tracks.iter_mut().filter(|t| !t.locked) { + let mut removed: Vec = tr.captions.iter().filter(|c| ids.contains(&c.id)).map(Caption::range).collect(); + if removed.is_empty() { + continue; + } + n += removed.len(); + tr.captions.retain(|c| !ids.contains(&c.id)); + if ripple { + removed.sort_by_key(|r| std::cmp::Reverse(r.start)); + for r in removed { + shift_from(tr, r.end(), -r.duration); + } + } + } + Ok(n) +} + +/// Shift every caption starting at or after `at` by `delta`. +pub fn shift_from(tr: &mut CaptionTrack, at: Tick, delta: Tick) { + for c in tr.captions.iter_mut().filter(|c| c.start >= at) { + c.start += delta; + } +} + +/// Open a gap of `dur` at `at` (insert edit): a caption straddling `at` is split. +pub fn insert_gap(tr: &mut CaptionTrack, at: Tick, dur: Tick, ctx: &mut EditCtx) { + if let Some(c) = tr.caption_at(at).filter(|c| c.start < at).cloned() { + let mut right = c.clone(); + right.id = ClipId(ctx.alloc()); + right.start = at; + right.duration = c.end() - at; + right.cue_id = None; + if let Some(l) = tr.caption_mut(c.id) { + l.duration = at - c.start; + } + tr.captions.push(right); + tr.sort(); + } + shift_from(tr, at, dur); +} + +/// Remove `range` from a caption track (captions inside go, captions across it are cut) and close +/// the gap (extract). +pub fn extract_range(tr: &mut CaptionTrack, range: TimeRange) { + let (a, b) = (range.start, range.end()); + let mut out = Vec::with_capacity(tr.captions.len()); + for mut c in tr.captions.drain(..) { + let (s, e) = (c.start, c.end()); + if e <= a { + out.push(c); + } else if s >= b { + c.start = s - range.duration; + out.push(c); + } else { + // overlaps the range: keep what lies outside it, joined across the closed gap + let left = (a - s).max(Tick::ZERO); + let right = (e - b).max(Tick::ZERO); + if left + right > Tick::ZERO { + c.start = s.min(a); + c.duration = left + right; + out.push(c); + } + } + } + tr.captions = out; + tr.sort(); +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::CaptionFormat; + + fn seq_with(captions: &[(i64, i64, &str)]) -> (Sequence, TrackId) { + let mut p = filmcraft_project::Project::new("t"); + let sid = p.new_sequence("s", Default::default(), 1, 1, None); + let mut seq = p.sequence(sid).unwrap().clone(); + let tid = TrackId(900); + let mut tr = CaptionTrack::new(tid, "Subtitle".into(), CaptionFormat::Subtitle); + for (i, (s, d, t)) in captions.iter().enumerate() { + tr.captions.push(Caption { + id: ClipId(1000 + i as u64), + start: Tick(*s), + duration: Tick(*d), + text: t.to_string(), + speaker: None, + cue_id: None, + settings: String::new(), + }); + } + seq.caption_tracks.push(tr); + (seq, tid) + } + + fn ctx(next: &mut u64) -> EditCtx<'_> { + static NONE: fn(filmcraft_project::ItemId) -> Option = |_| None; + EditCtx { next_id: next, media_duration: &NONE, min_duration: Tick(10) } + } + + fn spans(seq: &Sequence, tid: TrackId) -> Vec<(i64, i64, String)> { + seq.caption_track(tid).unwrap().captions.iter().map(|c| (c.start.0, c.end().0, c.text.clone())).collect() + } + + #[test] + fn add_clamps_to_next() { + let (mut seq, tid) = seq_with(&[(100, 50, "b")]); + let mut n = 1; + let id = add_caption(&mut seq, tid, Tick(40), Tick(1000), "a", &mut ctx(&mut n)).unwrap(); + assert_eq!(seq.find_caption(id).unwrap().1.end(), Tick(100)); + assert!(add_caption(&mut seq, tid, Tick(120), Tick(10), "x", &mut ctx(&mut n)).is_err()); + assert!(add_caption(&mut seq, tid, Tick(95), Tick(10), "x", &mut ctx(&mut n)).is_err(), "would be shorter than min"); + assert!(seq.check().is_ok()); + } + + #[test] + fn split_and_merge() { + let (mut seq, tid) = seq_with(&[(0, 100, "hello"), (100, 50, "world")]); + let mut n = 1; + let r = split_caption(&mut seq, ClipId(1000), Tick(40), &mut ctx(&mut n)).unwrap(); + assert_eq!(spans(&seq, tid), vec![(0, 40, "hello".into()), (40, 100, "hello".into()), (100, 150, "world".into())]); + assert!(split_caption(&mut seq, r, Tick(45), &mut ctx(&mut n)).is_err()); + let m = merge_captions(&mut seq, &[r, ClipId(1001)]).unwrap(); + assert_eq!(m, r); + assert_eq!(spans(&seq, tid), vec![(0, 40, "hello".into()), (40, 150, "hello\nworld".into())]); + // not adjacent + let (mut seq, _) = seq_with(&[(0, 10, "a"), (20, 10, "b"), (40, 10, "c")]); + assert!(merge_captions(&mut seq, &[ClipId(1000), ClipId(1002)]).is_err()); + } + + #[test] + fn trim_and_move_clamp() { + let (mut seq, tid) = seq_with(&[(0, 100, "a"), (150, 50, "b"), (300, 50, "c")]); + let n = &mut 1; + assert_eq!(trim_caption(&mut seq, ClipId(1001), Edge::In, Tick(-500), &ctx(n)).unwrap(), Tick(-50)); + assert_eq!(trim_caption(&mut seq, ClipId(1001), Edge::Out, Tick(500), &ctx(n)).unwrap(), Tick(100)); + assert_eq!(spans(&seq, tid)[1], (100, 300, "b".into())); + assert_eq!(trim_caption(&mut seq, ClipId(1001), Edge::Out, Tick(-1000), &ctx(n)).unwrap(), Tick(-190)); + // move c left: stops at b's end (110) + assert_eq!(move_captions(&mut seq, &[ClipId(1002)], Tick(-1000)).unwrap(), Tick(-190)); + assert_eq!(spans(&seq, tid)[2], (110, 160, "c".into())); + // a cannot move right (b is flush against it); b and c move together freely + assert_eq!(move_captions(&mut seq, &[ClipId(1000)], Tick(5)), Err(EditError::Nothing)); + assert_eq!(move_captions(&mut seq, &[ClipId(1001), ClipId(1002)], Tick(5)).unwrap(), Tick(5)); + assert_eq!(spans(&seq, tid)[1..], [(105, 115, "b".into()), (115, 165, "c".into())]); + assert!(seq.check().is_ok()); + } + + #[test] + fn delete_ripple_and_sync() { + let (mut seq, tid) = seq_with(&[(0, 100, "a"), (100, 50, "b"), (200, 50, "c")]); + delete_captions(&mut seq, &[ClipId(1001)], true).unwrap(); + assert_eq!(spans(&seq, tid), vec![(0, 100, "a".into()), (150, 200, "c".into())]); + let mut n = 1; + let tr = seq.caption_track_mut(tid).unwrap(); + insert_gap(tr, Tick(50), Tick(30), &mut ctx(&mut n)); + assert_eq!(spans(&seq, tid), vec![(0, 50, "a".into()), (80, 130, "a".into()), (180, 230, "c".into())]); + let tr = seq.caption_track_mut(tid).unwrap(); + extract_range(tr, TimeRange::new(Tick(40), Tick(150))); + assert_eq!(spans(&seq, tid), vec![(0, 40, "a".into()), (40, 80, "c".into())]); + } + + #[test] + fn locked_refuses() { + let (mut seq, tid) = seq_with(&[(0, 100, "a")]); + seq.caption_track_mut(tid).unwrap().locked = true; + assert_eq!(set_caption(&mut seq, ClipId(1000), Some("x"), None), Err(EditError::Locked)); + assert_eq!(delete_captions(&mut seq, &[ClipId(1000)], false), Err(EditError::Locked)); + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 79fc5d18..272d6f53 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -15,6 +15,8 @@ //! //! Keyframes are stored in media time, so trims and splits never need to move them. +pub mod captions; + use std::collections::HashMap; use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionId}; @@ -222,6 +224,9 @@ pub fn insert(seq: &mut Sequence, placements: Vec<(TrackId, TrackItem)>, ctx: &m insert_track_gap(t, at, dur, ctx, &mut links); } } + for ct in seq.caption_tracks.iter_mut().filter(|c| !c.locked && c.sync_lock) { + captions::insert_gap(ct, at, dur, ctx); + } let mut ids = Vec::new(); for (tid, item) in placements { let t = track_mut(seq, tid)?; @@ -288,6 +293,9 @@ pub fn extract(seq: &mut Sequence, tracks: &[TrackId], range: TimeRange, ctx: &m removed.extend(clear_track_range(tr, range, ctx, &mut links)); shift_track_from(tr, range.end(), -range.duration); } + for ct in seq.caption_tracks.iter_mut().filter(|c| !c.locked && c.sync_lock) { + captions::extract_range(ct, range); + } removed } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 90c329c1..ce60fb2c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,6 +21,7 @@ filmcraft-edit = { workspace = true } filmcraft-render = { workspace = true } filmcraft-export = { workspace = true } filmcraft-interchange = { workspace = true } +filmcraft-captions = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 94a524b8..a8b63526 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -469,6 +469,7 @@ pub(crate) fn empty_sequence(settings: SequenceSettings) -> Sequence { start_timecode: 0, master_volume_db: 0.0, master_effects: Vec::new(), + caption_tracks: Vec::new(), } } diff --git a/crates/project/src/caption.rs b/crates/project/src/caption.rs new file mode 100644 index 00000000..0583ade9 --- /dev/null +++ b/crates/project/src/caption.rs @@ -0,0 +1,313 @@ +//! Caption tracks. +//! +//! Premiere keeps captions in their own track area above the video tracks. A [`CaptionTrack`] has a +//! format (Subtitle, CEA-608, CEA-708, Teletext), a track style (font, size, colour, background box, +//! position) and a list of [`Caption`] blocks with exact in/out times in sequence ticks. Captions on +//! one track never overlap (like track items); [`CaptionTrack::check`] verifies it. +//! +//! Caption text is kept as written (lines separated by `\n`, inline ``/``/`` tags and +//! WebVTT cue settings preserved) so files round-trip; renderers strip markup for display. + +use filmcraft_time::{Tick, TimeRange}; +use serde::{Deserialize, Serialize}; + +use crate::{ClipId, TrackId}; + +/// Caption stream format of a track (Premiere's "Caption Format" choices). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CaptionFormat { + /// Open/sidecar subtitles (SRT, WebVTT). + #[default] + Subtitle, + /// CEA-608 (line 21) closed captions. + Cea608, + /// CEA-708 (DTVCC) closed captions. + Cea708, + /// EBU Teletext subtitles. + Teletext, +} + +impl CaptionFormat { + pub const ALL: [CaptionFormat; 4] = [CaptionFormat::Subtitle, CaptionFormat::Cea608, CaptionFormat::Cea708, CaptionFormat::Teletext]; + pub fn label(self) -> &'static str { + match self { + CaptionFormat::Subtitle => "Subtitle", + CaptionFormat::Cea608 => "CEA-608", + CaptionFormat::Cea708 => "CEA-708", + CaptionFormat::Teletext => "Teletext", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace(['-', ' ', '_'], ""); + Self::ALL.iter().copied().find(|f| f.label().to_ascii_lowercase().replace(['-', ' '], "") == n) + } + /// Characters per line limit used when wrapping (608 is fixed at 32 columns). + pub fn max_columns(self) -> Option { + match self { + CaptionFormat::Cea608 => Some(32), + CaptionFormat::Teletext => Some(40), + CaptionFormat::Cea708 => Some(42), + CaptionFormat::Subtitle => None, + } + } +} + +/// Horizontal text alignment. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CaptionAlign { + Left, + #[default] + Center, + Right, +} + +/// Vertical placement of the caption block in the frame. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CaptionAnchor { + Top, + Middle, + #[default] + Bottom, +} + +/// Track-level caption style (Premiere's caption track style / Essential Graphics text settings). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CaptionStyle { + /// Font family name. Only the bundled Inter is rendered today; other names fall back to it. + pub font: String, + /// Font size in pixels for a 1080-line frame (scaled with the frame height). + pub size: f32, + /// Text colour, sRGB + alpha. + pub color: [u8; 4], + /// Draw a background box behind each line. + pub background: bool, + /// Background box colour, sRGB + alpha. + pub background_color: [u8; 4], + pub align: CaptionAlign, + pub anchor: CaptionAnchor, + /// Distance from the anchored frame edge, as a fraction of the frame height. + pub margin: f32, + /// Line height as a multiple of the font size. + pub line_spacing: f32, + /// Text outline width in pixels at 1080 lines (0 = none). + pub outline: f32, + pub outline_color: [u8; 4], +} + +impl Default for CaptionStyle { + fn default() -> Self { + Self { + font: "Inter".into(), + size: 54.0, + color: [255, 255, 255, 255], + background: true, + background_color: [0, 0, 0, 191], + align: CaptionAlign::Center, + anchor: CaptionAnchor::Bottom, + margin: 0.08, + line_spacing: 1.25, + outline: 0.0, + outline_color: [0, 0, 0, 255], + } + } +} + +/// One caption block on a caption track. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Caption { + pub id: ClipId, + /// Timeline start (sequence ticks). + pub start: Tick, + pub duration: Tick, + /// Caption text; lines separated by `\n`. + pub text: String, + /// Speaker name (WebVTT `` voice span). + #[serde(default)] + pub speaker: Option, + /// WebVTT cue identifier, kept for round trips. + #[serde(default)] + pub cue_id: Option, + /// WebVTT cue settings (`line:90% align:start`…), kept verbatim. + #[serde(default)] + pub settings: String, +} + +impl Caption { + pub fn end(&self) -> Tick { + self.start + self.duration + } + pub fn range(&self) -> TimeRange { + TimeRange::new(self.start, self.duration) + } + /// The caption's text with markup removed and entities decoded (what is drawn). + pub fn plain_lines(&self) -> Vec { + plain_text(&self.text).lines().map(str::to_string).collect() + } +} + +/// A caption track (shown in the caption area above the video tracks). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CaptionTrack { + pub id: TrackId, + pub name: String, + pub format: CaptionFormat, + /// BCP-47 language tag (`en`, `fr-CA`…). + #[serde(default)] + pub language: String, + #[serde(default)] + pub style: CaptionStyle, + /// Track output (eye): shown in the Program monitor and burned in on export. + pub enabled: bool, + pub locked: bool, + /// Follows inserts / extracts on other tracks. + #[serde(default = "yes")] + pub sync_lock: bool, + /// Captions sorted by start, non-overlapping. + pub captions: Vec, + /// WebVTT header blocks (STYLE / REGION / NOTE) kept for round trips. + #[serde(default)] + pub vtt_blocks: Vec, +} + +fn yes() -> bool { + true +} + +impl CaptionTrack { + pub fn new(id: TrackId, name: String, format: CaptionFormat) -> Self { + Self { + id, + name, + format, + language: "en".into(), + style: CaptionStyle::default(), + enabled: true, + locked: false, + sync_lock: true, + captions: Vec::new(), + vtt_blocks: Vec::new(), + } + } + pub fn end(&self) -> Tick { + self.captions.iter().map(Caption::end).max().unwrap_or(Tick::ZERO) + } + /// Caption covering `t`. + pub fn caption_at(&self, t: Tick) -> Option<&Caption> { + let idx = self.captions.partition_point(|c| c.start <= t); + idx.checked_sub(1).map(|i| &self.captions[i]).filter(|c| t < c.end()) + } + pub fn caption(&self, id: ClipId) -> Option<&Caption> { + self.captions.iter().find(|c| c.id == id) + } + pub fn caption_mut(&mut self, id: ClipId) -> Option<&mut Caption> { + self.captions.iter_mut().find(|c| c.id == id) + } + pub fn sort(&mut self) { + self.captions.sort_by_key(|c| c.start); + } + /// Invariant check: captions sorted, positive duration, no overlaps. + pub fn check(&self) -> Result<(), String> { + for c in &self.captions { + if c.duration.0 <= 0 { + return Err(format!("{}: caption {:?} has non-positive duration", self.name, c.id)); + } + } + for w in self.captions.windows(2) { + if w[0].end() > w[1].start { + return Err(format!("{}: captions {:?} and {:?} overlap", self.name, w[0].id, w[1].id)); + } + } + Ok(()) + } +} + +/// Strip `<…>` tags and decode the common HTML/WebVTT entities. +pub fn plain_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '<' => { + // a tag runs to the next '>' (if none, keep the '<' literally) + let rest: String = chars.clone().collect(); + if let Some(end) = rest.find('>') { + for _ in 0..rest[..=end].chars().count() { + chars.next(); + } + } else { + out.push('<'); + } + } + '{' if chars.peek() == Some(&'\\') => { + // SSA-style override blocks some SRT files carry: {\an8} + let rest: String = chars.clone().collect(); + if let Some(end) = rest.find('}') { + for _ in 0..rest[..=end].chars().count() { + chars.next(); + } + } else { + out.push('{'); + } + } + '&' => { + let rest: String = chars.clone().take(8).collect(); + let ents = [ + ("amp;", '&'), + ("lt;", '<'), + ("gt;", '>'), + ("nbsp;", '\u{a0}'), + ("quot;", '"'), + ("apos;", '\''), + ("lrm;", '\u{200e}'), + ("rlm;", '\u{200f}'), + ]; + if let Some((name, ch)) = ents.iter().find(|(n, _)| rest.starts_with(n)) { + for _ in 0..name.len() { + chars.next(); + } + out.push(*ch); + } else { + out.push('&'); + } + } + '\r' => {} + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain() { + assert_eq!(plain_text("Hello & bye"), "Hello & bye"); + assert_eq!(plain_text("{\\an8}Top"), "Top"); + assert_eq!(plain_text("a < b"), "a < b"); + assert_eq!(plain_text("Hi"), "Hi"); + } + + #[test] + fn caption_at() { + let mut t = CaptionTrack::new(TrackId(1), "Subtitle".into(), CaptionFormat::Subtitle); + for (i, s) in [0i64, 100, 300].iter().enumerate() { + t.captions.push(Caption { + id: ClipId(i as u64), + start: Tick(*s), + duration: Tick(50), + text: format!("c{i}"), + speaker: None, + cue_id: None, + settings: String::new(), + }); + } + assert_eq!(t.caption_at(Tick(120)).unwrap().text, "c1"); + assert!(t.caption_at(Tick(160)).is_none()); + assert!(t.check().is_ok()); + assert_eq!(CaptionFormat::from_name("cea608"), Some(CaptionFormat::Cea608)); + assert_eq!(CaptionFormat::from_name("CEA-708"), Some(CaptionFormat::Cea708)); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 93adb572..af6d3876 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -7,6 +7,7 @@ //! //! Time: timeline positions are sequence ticks; `source_in` is media time. +pub mod caption; pub mod effect; pub mod keyframe; @@ -17,6 +18,7 @@ use filmcraft_media::{Generator, MediaInfo}; use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; use serde::{Deserialize, Serialize}; +pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text}; pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect}; pub use keyframe::{Interpolation, Keyframe, Param, ParamValue}; @@ -640,11 +642,25 @@ pub struct Sequence { pub master_volume_db: f64, #[serde(default)] pub master_effects: Vec, + /// Caption tracks (drawn above the video tracks; first = top). Added in project version 2. + #[serde(default)] + pub caption_tracks: Vec, } impl Sequence { pub fn duration(&self) -> Tick { - self.video_tracks.iter().chain(&self.audio_tracks).map(Track::end).max().unwrap_or(Tick::ZERO) + let media = self.video_tracks.iter().chain(&self.audio_tracks).map(Track::end).max().unwrap_or(Tick::ZERO); + media.max(self.caption_tracks.iter().map(CaptionTrack::end).max().unwrap_or(Tick::ZERO)) + } + pub fn caption_track(&self, id: TrackId) -> Option<&CaptionTrack> { + self.caption_tracks.iter().find(|t| t.id == id) + } + pub fn caption_track_mut(&mut self, id: TrackId) -> Option<&mut CaptionTrack> { + self.caption_tracks.iter_mut().find(|t| t.id == id) + } + /// Find a caption anywhere: (caption track id, caption). + pub fn find_caption(&self, id: ClipId) -> Option<(TrackId, &Caption)> { + self.caption_tracks.iter().find_map(|t| t.caption(id).map(|c| (t.id, c))) } pub fn tracks(&self, kind: TrackKind) -> &Vec { match kind { @@ -694,6 +710,9 @@ impl Sequence { for t in self.all_tracks() { t.check()?; } + for t in &self.caption_tracks { + t.check()?; + } Ok(()) } } @@ -734,7 +753,8 @@ pub struct Project { pub version: u32, } -pub const PROJECT_VERSION: u32 = 1; +/// 2: sequences gained `caption_tracks` (older projects load with none). +pub const PROJECT_VERSION: u32 = 2; impl Default for Project { fn default() -> Self { @@ -812,6 +832,7 @@ impl Project { start_timecode: 0, master_volume_db: 0.0, master_effects: Vec::new(), + caption_tracks: Vec::new(), }; for i in 0..v { let id = TrackId(self.alloc_id()); @@ -860,10 +881,12 @@ impl Project { serde_json::to_string_pretty(self).unwrap_or_default() } pub fn from_json(s: &str) -> Result { - let p: Project = serde_json::from_str(s).map_err(|e| e.to_string())?; + let mut p: Project = serde_json::from_str(s).map_err(|e| e.to_string())?; if p.version > PROJECT_VERSION { return Err(format!("project version {} is newer than this FilmCraft ({PROJECT_VERSION})", p.version)); } + // Older versions load with serde defaults for new fields; saving writes the current version. + p.version = PROJECT_VERSION; Ok(p) } } @@ -940,6 +963,43 @@ mod tests { assert_eq!(a.source_time_at(rate.tick_of(10)), Tick(1000) + rate.tick_of(20)); } + #[test] + fn version1_projects_load_without_captions() { + let (mut p, _, seq) = demo_project(); + p.version = 1; + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + // a v1 file has no `caption_tracks` key + let items = v["items"].as_object_mut().unwrap(); + for it in items.values_mut() { + if let Some(s) = it["kind"].get_mut("Sequence") { + s.as_object_mut().unwrap().remove("caption_tracks"); + } + } + let q = Project::from_json(&v.to_string()).unwrap(); + assert_eq!(q.version, PROJECT_VERSION); + assert!(q.sequence(seq).unwrap().caption_tracks.is_empty()); + } + + #[test] + fn caption_tracks_roundtrip() { + let (mut p, _, seq) = demo_project(); + let id = TrackId(p.alloc_id()); + let mut ct = CaptionTrack::new(id, "Subtitle".into(), CaptionFormat::Cea608); + ct.captions.push(Caption { + id: ClipId(p.alloc_id()), + start: Tick(10), + duration: Tick(TICKS_PER_SECOND), + text: "Hello\nworld".into(), + speaker: Some("Ann".into()), + cue_id: Some("1".into()), + settings: "line:90%".into(), + }); + p.sequence_mut(seq).unwrap().caption_tracks.push(ct); + let q = Project::from_json(&p.to_json()).unwrap(); + assert_eq!(p, q); + assert_eq!(q.sequence(seq).unwrap().duration(), Tick(10 + TICKS_PER_SECOND)); + } + #[test] fn bins() { let mut p = Project::new("x");