diff --git a/Cargo.lock b/Cargo.lock index f1044aa2..137c14b8 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" @@ -1393,6 +1405,7 @@ dependencies = [ name = "filmcraft-engine" version = "0.1.0" dependencies = [ + "filmcraft-captions", "filmcraft-codecs", "filmcraft-color", "filmcraft-edit", @@ -1569,6 +1582,7 @@ name = "filmcraft-render" version = "0.1.0" dependencies = [ "filmcraft-audio-dsp", + "filmcraft-captions", "filmcraft-color", "filmcraft-frame", "filmcraft-geom", diff --git a/Cargo.toml b/Cargo.toml index 47fe0f4f..66be7ece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,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/ROADMAP.md b/ROADMAP.md index d239015e..ffdd7a60 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M7 | Audio | 🟡 | Mixer basics, peak meters, BS.1770 loudness meters (M/S/I LUFS, true peak; matches ffmpeg), DSP crate, 12 clip audio effects running on it (EQ, filters, dynamics, limiter, delay, reverb, DeNoise, DeHum, pitch) with keyframes | Effects UI polish (EQ curve), remaining effects (multiband, convolution reverb), track effects/mixers with automation, Essential Sound, audio transitions, 5.1 | 6–10 | | M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette; basic scopes | LUT import UI, colour match, colour management + HDR (PQ/HLG, log, tone mapping) | 8–12 | | M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), Matroska/WebM import (H.264/HEVC/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4) | VP9 (in progress), Ogg Opus files, AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 | -| M10 | Graphics & captions | ⬜ | — | Text engine, Type tool, Essential Graphics, captions (SRT/VTT/SCC), speech-to-text | 20–30 | +| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export | Text engine with shaping, Type tool, Essential Graphics, rolls/crawls, MCC/STL/TTML, 608/708 embedding, speech-to-text | 18–27 | | M11 | Interchange & project management | 🟡 | `.fcproj` save/open | Autosave/recovery, FCP7 XML, FCPXML, EDL, OTIO, relink, project manager, proxies | 15 | | M12–M16 | Web (WASM), platform, long tail | 🟡 | L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), ~850 remaining commands and dialogs, performance hardening | 40–65 | diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index f2de5865..34d8f3cf 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -75,6 +75,9 @@ fn main() -> eframe::Result { app.hooks.pick_save = Some(Box::new(|name: &str| { rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); + app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { + rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) + })); app.hooks.pick_open_project = Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); #[cfg(target_os = "macos")] 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/engine/src/captions.rs b/crates/engine/src/captions.rs new file mode 100644 index 00000000..2bcb9763 --- /dev/null +++ b/crates/engine/src/captions.rs @@ -0,0 +1,551 @@ +//! Caption commands: caption tracks, caption editing, caption file import/export. +//! +//! Ids (`captions.*`) follow Premiere's Sequence ▸ Captions menu and the Text panel's Captions tab. +//! Captions are addressed by id (`caption`, `captions`); without one, commands use the caption +//! selection (`EditorState::caption_selection`) or the caption under the playhead. Caption tracks +//! are addressed by id or `"C1"`, `"C2"`… (top first); the default is the first caption track. + +use serde_json::{Value, json}; + +use filmcraft_captions::{Document, Format, WriteOptions}; +use filmcraft_edit as edit; +use filmcraft_edit::Edge; +use filmcraft_project::{CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, ClipId, SequenceSettings, TrackId}; +use filmcraft_time::{Tick, TimeDisplay, format_time}; + +use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +fn spec( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true } +} + +fn has_caption_track(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty()) { Ok(()) } else { Err("the sequence has no caption track".into()) } +} + +fn has_captions(s: &Session) -> std::result::Result<(), String> { + has_caption_track(s)?; + if s.active_sequence().is_some_and(|q| q.caption_tracks.iter().any(|t| !t.captions.is_empty())) { Ok(()) } else { Err("there are no captions".into()) } +} + +/// The caption track named by `track` (id or "C1"…), else the first one. +pub fn track_param(s: &Session, p: &Value) -> Option { + let seq = s.active_sequence()?; + match p.get("track") { + Some(Value::Number(n)) => n.as_u64().map(TrackId).filter(|t| seq.caption_track(*t).is_some()), + Some(Value::String(name)) => { + let idx: usize = name.trim_start_matches(['C', 'c']).parse().ok()?; + seq.caption_tracks.get(idx.checked_sub(1)?).map(|t| t.id) + } + _ => seq.caption_tracks.first().map(|t| t.id), + } +} + +fn caption_ids(s: &Session, p: &Value) -> Vec { + if let Some(a) = p.get("captions").and_then(Value::as_array) { + return a.iter().filter_map(Value::as_u64).map(ClipId).collect(); + } + if let Some(c) = u64_p(p, "caption") { + return vec![ClipId(c)]; + } + if !s.state.caption_selection.is_empty() { + return s.state.caption_selection.clone(); + } + // the caption under the playhead on the first caption track that has one + let t = s.playhead(); + s.active_sequence().and_then(|q| q.caption_tracks.iter().find_map(|tr| tr.caption_at(t).map(|c| c.id))).into_iter().collect() +} + +fn one_caption(s: &Session, p: &Value, cmd: &str) -> Result { + caption_ids(s, p).first().copied().ok_or_else(|| bad(cmd, "no caption (pass `caption` or select one)")) +} + +fn parse_color(v: &Value) -> Option<[u8; 4]> { + if let Some(a) = v.as_array() { + let c: Vec = a.iter().filter_map(|x| x.as_u64().map(|n| n.min(255) as u8)).collect(); + return match c.len() { + 3 => Some([c[0], c[1], c[2], 255]), + 4 => Some([c[0], c[1], c[2], c[3]]), + _ => None, + }; + } + let h = v.as_str()?.trim_start_matches('#'); + let b = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + match h.len() { + 6 => Some([b(0)?, b(2)?, b(4)?, 255]), + 8 => Some([b(0)?, b(2)?, b(4)?, b(6)?]), + _ => None, + } +} + +fn hex(c: [u8; 4]) -> String { + format!("#{:02x}{:02x}{:02x}{:02x}", c[0], c[1], c[2], c[3]) +} + +/// The caption format of a file, if it is one (extension `.srt` / `.vtt` / `.scc`, confirmed by +/// content). +pub fn detect(path: &str, bytes: &[u8]) -> Option { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + if !matches!(ext.as_deref(), Some("srt" | "vtt" | "scc" | "webvtt")) { + return None; + } + filmcraft_captions::detect(bytes, ext.as_deref()) +} + +/// Import a caption file as a new caption track in the active sequence (a new sequence is made +/// when none is open), snapped to the sequence's frames. One undo step. +pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format, name: Option<&str>) -> Result { + let mut doc = filmcraft_captions::parse(bytes, format).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let stem = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Captions".into()); + let track_name = name.map(str::to_string).unwrap_or_else(|| stem.clone()); + let seq_id = match s.state.active_sequence.filter(|id| s.project.sequence(*id).is_some()) { + Some(id) => id, + None => { + let id = s.edit("New Sequence", |p, _| Ok(p.new_sequence(&stem, SequenceSettings::default(), 3, 3, None)))?; + s.state.active_sequence = Some(id); + s.state.open_sequences.push(id); + id + } + }; + let rate = s.project.sequence(seq_id).map(|q| q.settings.frame_rate).unwrap_or_default(); + doc.snap_to_frames(rate); + let n = doc.cues.len(); + let warnings = doc.warnings.clone(); + let tid = s.edit(&format!("Import Captions {stem}"), |p, st| { + let tid = TrackId(p.alloc_id()); + let mut next = p.next_id; + let mut alloc = || { + let v = next; + next += 1; + v + }; + let mut track = filmcraft_captions::track_from_document(&doc, tid, &track_name, format.track_format(), &mut alloc); + if format == Format::Scc { + track.style.background_color = [0, 0, 0, 255]; + } + p.next_id = next; + let q = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?; + q.caption_tracks.insert(0, track); + q.check().map_err(EngineError::Other)?; + st.caption_selection.clear(); + Ok(tid) + })?; + Ok(json!({"format": format.name(), "sequence": seq_id.0, "track": tid.0, "captions": n, "warnings": warnings})) +} + +/// Delete captions (Clear / Ripple Delete with captions selected). +pub fn delete(s: &mut Session, ids: &[ClipId], ripple: bool) -> Result { + if ids.is_empty() { + return Err(bad("captions.delete", "no captions")); + } + let ids = ids.to_vec(); + let n = s.edit_sequence(if ripple { "Ripple Delete Captions" } else { "Delete Captions" }, |q, _, st| { + let n = edit::captions::delete_captions(q, &ids, ripple)?; + st.caption_selection.retain(|c| !ids.contains(c)); + Ok(n) + })?; + Ok(json!({"deleted": n})) +} + +fn caption_json(s: &Session, c: &filmcraft_project::Caption) -> Value { + let q = s.active_sequence(); + let (rate, df) = q.map(|q| (q.settings.frame_rate, q.settings.drop_frame)).unwrap_or_default(); + let tc = |t: Tick| format_time(t, rate, df, TimeDisplay::Timecode, 48_000); + json!({ + "id": c.id.0, + "start": c.start.0, + "end": c.end().0, + "in": tc(c.start), + "out": tc(c.end()), + "duration": tc(c.duration), + "text": c.text, + "speaker": c.speaker, + "settings": c.settings, + }) +} + +fn track_json(s: &Session, t: &CaptionTrack, index: usize) -> Value { + json!({ + "id": t.id.0, + "label": format!("C{}", index + 1), + "name": t.name, + "format": t.format.label(), + "language": t.language, + "enabled": t.enabled, + "locked": t.locked, + "syncLock": t.sync_lock, + "style": { + "font": t.style.font, "size": t.style.size, "color": hex(t.style.color), + "background": t.style.background, "backgroundColor": hex(t.style.background_color), + "align": format!("{:?}", t.style.align).to_lowercase(), "anchor": format!("{:?}", t.style.anchor).to_lowercase(), + "margin": t.style.margin, "lineSpacing": t.style.line_spacing, "outline": t.style.outline, "outlineColor": hex(t.style.outline_color), + }, + "captions": t.captions.iter().map(|c| caption_json(s, c)).collect::>(), + }) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("captions.export", "need `path`"))?.to_string(); + let ext = std::path::Path::new(&path).extension().and_then(|e| e.to_str()).unwrap_or(""); + let format = str_p(p, "format").and_then(Format::from_name).or_else(|| Format::from_name(ext)).unwrap_or(Format::Srt); + let tid = track_param(s, p).ok_or_else(|| bad("captions.export", "no caption track"))?; + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let track = seq.caption_track(tid).ok_or_else(|| bad("captions.export", "no such caption track"))?; + let doc: Document = filmcraft_captions::document_from_track(track, Tick::ZERO); + let df = bool_p(p, "dropFrame").unwrap_or(true); + let bytes = filmcraft_captions::write(&doc, format, WriteOptions { drop_frame: df }); + let n = doc.cues.len(); + s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(e.to_string()))?; + Ok(json!({"path": path, "format": format.name(), "captions": n, "bytes": bytes.len()})) +} + +fn delta_p(s: &Session, p: &Value) -> Option { + if let Some(d) = p.get("delta").and_then(Value::as_i64) { + return Some(Tick(d)); + } + p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)) +} + +pub fn commands() -> Vec { + vec![ + spec( + "captions.newTrack", + "Add New Caption Track…", + &["Sequence", "Captions"], + Some("Cmd+Alt+A"), + r#"{"format":"Subtitle|CEA-608|CEA-708|Teletext","name":str?,"language":str?}"#, + has_seq, + |s, p| { + let format = str_p(p, "format").and_then(CaptionFormat::from_name).unwrap_or_default(); + let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format.label().to_string()); + let lang = str_p(p, "language").unwrap_or("en").to_string(); + let seq = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let id = s.edit("Add Caption Track", |pr, _| { + let id = TrackId(pr.alloc_id()); + let mut t = CaptionTrack::new(id, name, format); + t.language = lang; + if format == CaptionFormat::Cea608 { + t.style.background_color = [0, 0, 0, 255]; + } + pr.sequence_mut(seq).ok_or(EngineError::NoSequence)?.caption_tracks.insert(0, t); + Ok(id) + })?; + Ok(json!({"track": id.0})) + }, + ), + spec("captions.deleteTrack", "Delete Caption Track", &[], None, r#"{"track":id|"C1"}"#, has_caption_track, |s, p| { + let tid = track_param(s, p).ok_or_else(|| bad("captions.deleteTrack", "no such caption track"))?; + s.edit_sequence("Delete Caption Track", |q, _, st| { + q.caption_tracks.retain(|t| t.id != tid); + st.caption_selection.retain(|c| q.find_caption(*c).is_some()); + Ok(()) + })?; + Ok(Value::Null) + }), + spec( + "captions.setTrack", + "Caption Track Settings", + &[], + None, + r#"{"track":id|"C1","name":str?,"format":str?,"language":str?,"enabled":bool?,"locked":bool?,"syncLock":bool?}"#, + has_caption_track, + |s, p| { + let tid = track_param(s, p).ok_or_else(|| bad("captions.setTrack", "no such caption track"))?; + let p = p.clone(); + s.edit("Caption Track Settings", |pr, st| { + let seq = st.active_sequence.ok_or(EngineError::NoSequence)?; + let t = pr.sequence_mut(seq).and_then(|q| q.caption_track_mut(tid)).ok_or(EngineError::NoSequence)?; + if let Some(n) = str_p(&p, "name") { + t.name = n.to_string(); + } + if let Some(f) = str_p(&p, "format") { + t.format = CaptionFormat::from_name(f).ok_or_else(|| bad("captions.setTrack", "unknown format"))?; + } + if let Some(l) = str_p(&p, "language") { + t.language = l.to_string(); + } + if let Some(b) = bool_p(&p, "enabled") { + t.enabled = b; + } + if let Some(b) = bool_p(&p, "locked") { + t.locked = b; + } + if let Some(b) = bool_p(&p, "syncLock") { + t.sync_lock = b; + } + Ok(()) + })?; + Ok(Value::Null) + }, + ), + spec( + "captions.setStyle", + "Caption Track Style", + &[], + None, + r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":f32?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##, + has_caption_track, + |s, p| { + let tid = track_param(s, p).ok_or_else(|| bad("captions.setStyle", "no such caption track"))?; + let p = p.clone(); + s.edit("Caption Style", |pr, st| { + let seq = st.active_sequence.ok_or(EngineError::NoSequence)?; + let t = pr.sequence_mut(seq).and_then(|q| q.caption_track_mut(tid)).ok_or(EngineError::NoSequence)?; + let y = &mut t.style; + if bool_p(&p, "reset").unwrap_or(false) { + *y = CaptionStyle::default(); + } + if let Some(v) = str_p(&p, "font") { + y.font = v.to_string(); + } + if let Some(v) = f64_p(&p, "size") { + y.size = (v as f32).clamp(4.0, 400.0); + } + if let Some(c) = p.get("color").and_then(parse_color) { + y.color = c; + } + if let Some(b) = bool_p(&p, "background") { + y.background = b; + } + if let Some(c) = p.get("backgroundColor").and_then(parse_color) { + y.background_color = c; + } + if let Some(c) = p.get("outlineColor").and_then(parse_color) { + y.outline_color = c; + } + if let Some(v) = f64_p(&p, "outline") { + y.outline = (v as f32).clamp(0.0, 40.0); + } + if let Some(v) = f64_p(&p, "margin") { + y.margin = (v as f32).clamp(0.0, 0.45); + } + if let Some(v) = f64_p(&p, "lineSpacing") { + y.line_spacing = (v as f32).clamp(0.8, 4.0); + } + match str_p(&p, "align") { + Some("left") => y.align = CaptionAlign::Left, + Some("right") => y.align = CaptionAlign::Right, + Some("center") => y.align = CaptionAlign::Center, + Some(o) => return Err(bad("captions.setStyle", format!("unknown align `{o}`"))), + None => {} + } + match str_p(&p, "anchor") { + Some("top") => y.anchor = CaptionAnchor::Top, + Some("middle") => y.anchor = CaptionAnchor::Middle, + Some("bottom") => y.anchor = CaptionAnchor::Bottom, + Some(o) => return Err(bad("captions.setStyle", format!("unknown anchor `{o}`"))), + None => {} + } + Ok(()) + })?; + Ok(Value::Null) + }, + ), + spec( + "captions.add", + "Add Caption at Playhead", + &["Sequence", "Captions"], + Some("Cmd+Alt+C"), + r#"{"track":id|"C1"?,"text":str?,"time":ticks?,"seconds":f64?,"durationSeconds":f64=3}"#, + has_seq, + |s, p| { + // a caption track is created when the sequence has none + if s.active_sequence().is_some_and(|q| q.caption_tracks.is_empty()) { + s.execute("captions.newTrack", json!({}))?; + } + let tid = track_param(s, p).ok_or_else(|| bad("captions.add", "no such caption track"))?; + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let rate = s.sequence_rate(); + let dur = Tick::from_seconds_f64(f64_p(p, "durationSeconds").unwrap_or(edit::captions::DEFAULT_SECONDS as f64)); + let dur = rate.snap_nearest(dur).max(rate.frame_duration()); + let text = str_p(p, "text").unwrap_or("New caption").to_string(); + let t = rate.snap(t); + let id = s.edit_sequence("Add Caption", |q, ctx, st| { + let id = edit::captions::add_caption(q, tid, t, dur, &text, ctx)?; + st.caption_selection = vec![id]; + st.selection.clear(); + Ok(id) + })?; + Ok(json!({"caption": id.0})) + }, + ), + spec("captions.split", "Split Caption", &[], None, r#"{"caption":id?,"time":ticks?}"#, has_captions, |s, p| { + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let explicit = u64_p(p, "caption").map(ClipId); + let ids = s.edit_sequence("Split Caption", |q, ctx, st| { + let ids = match explicit { + Some(c) => vec![edit::captions::split_caption(q, c, t, ctx)?], + None => edit::captions::split_captions_at(q, &[], t, ctx), + }; + if ids.is_empty() { + return Err(edit::EditError::Nothing.into()); + } + st.caption_selection = ids.clone(); + Ok(ids) + })?; + Ok(json!({"captions": ids.iter().map(|c| c.0).collect::>()})) + }), + spec("captions.merge", "Merge Captions", &[], None, r#"{"captions":[id]?}"#, has_captions, |s, p| { + let ids = caption_ids(s, p); + let id = s.edit_sequence("Merge Captions", |q, _, st| { + let id = edit::captions::merge_captions(q, &ids)?; + st.caption_selection = vec![id]; + Ok(id) + })?; + Ok(json!({"caption": id.0})) + }), + spec("captions.setText", "Edit Caption Text", &[], None, r#"{"caption":id?,"text":str?,"speaker":str|null?}"#, has_captions, |s, p| { + let id = one_caption(s, p, "captions.setText")?; + let text = str_p(p, "text").map(str::to_string); + let speaker: Option> = match p.get("speaker") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => Some(v.as_str().map(str::to_string)), + }; + if text.is_none() && speaker.is_none() { + return Err(bad("captions.setText", "need `text` or `speaker`")); + } + s.edit_sequence("Edit Caption", |q, _, _| Ok(edit::captions::set_caption(q, id, text.as_deref(), speaker.as_ref().map(|o| o.as_deref()))?))?; + Ok(Value::Null) + }), + spec( + "captions.setTimes", + "Set Caption In/Out", + &[], + None, + r#"{"caption":id?,"startTime|startFrame|startSeconds|startTimecode":…,"endTime|endFrame|endSeconds|endTimecode":…}"#, + has_captions, + |s, p| { + let id = one_caption(s, p, "captions.setTimes")?; + let cur = s + .active_sequence() + .and_then(|q| q.find_caption(id)) + .map(|(_, c)| (c.start, c.end())) + .ok_or(EngineError::Edit(edit::EditError::NoItem(id)))?; + let rate = s.sequence_rate(); + let start = time_p(s, p, "start").map(|t| rate.snap_nearest(t)).unwrap_or(cur.0); + let end = time_p(s, p, "end").map(|t| rate.snap_nearest(t)).unwrap_or(cur.1); + s.edit_sequence("Caption Timing", |q, ctx, _| Ok(edit::captions::set_caption_times(q, id, start, end, ctx)?))?; + Ok(json!({"start": start.0, "end": end.0})) + }, + ), + spec("captions.trim", "Trim Caption", &[], None, r#"{"caption":id,"edge":"in|out","delta":ticks|"deltaFrames":i64}"#, has_captions, |s, p| { + let id = one_caption(s, p, "captions.trim")?; + let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out }; + let d = delta_p(s, p).ok_or_else(|| bad("captions.trim", "need `delta` or `deltaFrames`"))?; + let applied = s.edit_sequence("Trim Caption", |q, ctx, _| Ok(edit::captions::trim_caption(q, id, edge, d, ctx)?))?; + Ok(json!({"applied": applied.0})) + }), + spec("captions.move", "Move Captions", &[], None, r#"{"captions":[id]?,"delta":ticks|"deltaFrames":i64}"#, has_captions, |s, p| { + let ids = caption_ids(s, p); + let d = delta_p(s, p).ok_or_else(|| bad("captions.move", "need `delta` or `deltaFrames`"))?; + let applied = s.edit_sequence("Move Captions", |q, _, _| Ok(edit::captions::move_captions(q, &ids, d)?))?; + Ok(json!({"applied": applied.0})) + }), + spec("captions.delete", "Delete Captions", &[], None, r#"{"captions":[id]?,"ripple":bool=false}"#, has_captions, |s, p| { + let ids = caption_ids(s, p); + delete(s, &ids, bool_p(p, "ripple").unwrap_or(false)) + }), + spec("captions.select", "Select Captions", &[], None, r#"{"captions":[id],"add":bool?}"#, has_seq, |s, p| { + let ids: Vec = + p.get("captions").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(ClipId).collect()).unwrap_or_default(); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let ids: Vec = ids.into_iter().filter(|c| seq.find_caption(*c).is_some()).collect(); + if bool_p(p, "add").unwrap_or(false) { + for c in ids { + if !s.state.caption_selection.contains(&c) { + s.state.caption_selection.push(c); + } + } + } else { + s.state.caption_selection = ids; + s.state.selection.clear(); + } + Ok(json!({"captions": s.state.caption_selection.iter().map(|c| c.0).collect::>()})) + }), + spec("captions.goTo", "Go to Caption", &[], None, r#"{"caption":id}"#, has_captions, |s, p| { + let id = one_caption(s, p, "captions.goTo")?; + let start = s.active_sequence().and_then(|q| q.find_caption(id)).map(|(_, c)| c.start).ok_or(EngineError::Edit(edit::EditError::NoItem(id)))?; + s.set_playhead(start); + s.state.caption_selection = vec![id]; + s.state.selection.clear(); + Ok(json!({"time": start.0})) + }), + spec("captions.next", "Go to Next Caption Segment", &["Sequence", "Captions"], Some("Cmd+Alt+Down"), "{}", has_captions, |s, _| step(s, true)), + spec("captions.previous", "Go to Previous Caption Segment", &["Sequence", "Captions"], Some("Cmd+Alt+Up"), "{}", has_captions, |s, _| step(s, false)), + spec("captions.showAll", "Show All Caption Tracks", &["Sequence", "Captions"], None, "{}", has_caption_track, |s, _| show_all(s, true)), + spec("captions.hideAll", "Hide All Caption Tracks", &["Sequence", "Captions"], None, "{}", has_caption_track, |s, _| show_all(s, false)), + spec("captions.import", "Import Captions…", &[], None, r#"{"path":str,"name":str?}"#, always, |s, p| { + let path = str_p(p, "path").ok_or_else(|| bad("captions.import", "need `path`"))?.to_string(); + let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let ext = std::path::Path::new(&path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + let format = filmcraft_captions::detect(&bytes, ext.as_deref()).ok_or_else(|| EngineError::Other(format!("{path}: not a caption file")))?; + import(s, &path, &bytes, format, str_p(p, "name")) + }), + spec( + "captions.export", + "Captions…", + &["File", "Export"], + None, + r#"{"path":str,"format":"srt|vtt|scc"?,"track":id|"C1"?,"dropFrame":bool=true}"#, + has_caption_track, + export, + ), + CommandSpec { + id: "captions.list", + label: "List Captions", + menu: &[], + shortcut: None, + params: r#"{"track":id|"C1"?}"#, + enabled: always, + run: |s, p| { + let Some(seq) = s.active_sequence() else { return Ok(json!({"tracks": []})) }; + let only = p.get("track").and_then(|_| track_param(s, p)); + let tracks: Vec = + seq.caption_tracks.iter().enumerate().filter(|(_, t)| only.is_none_or(|o| o == t.id)).map(|(i, t)| track_json(s, t, i)).collect(); + Ok(json!({"tracks": tracks, "selection": s.state.caption_selection.iter().map(|c| c.0).collect::>()})) + }, + journal: false, + }, + ] +} + +fn step(s: &mut Session, forward: bool) -> Result { + let t = s.playhead(); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let mut starts: Vec<(Tick, ClipId)> = seq.caption_tracks.iter().filter(|t| t.enabled).flat_map(|t| t.captions.iter().map(|c| (c.start, c.id))).collect(); + starts.sort(); + let hit = if forward { starts.iter().find(|(st, _)| *st > t).copied() } else { starts.iter().rev().find(|(st, _)| *st < t).copied() }; + let (time, id) = hit.ok_or_else(|| EngineError::Other("no more captions".into()))?; + s.set_playhead(time); + s.state.caption_selection = vec![id]; + Ok(json!({"time": time.0, "caption": id.0})) +} + +fn show_all(s: &mut Session, on: bool) -> Result { + s.edit(if on { "Show All Caption Tracks" } else { "Hide All Caption Tracks" }, |p, st| { + let seq = st.active_sequence.ok_or(EngineError::NoSequence)?; + for t in &mut p.sequence_mut(seq).ok_or(EngineError::NoSequence)?.caption_tracks { + t.enabled = on; + } + Ok(()) + })?; + Ok(Value::Null) +} + +#[cfg(test)] +#[path = "captions_tests.rs"] +mod tests; diff --git a/crates/engine/src/captions_tests.rs b/crates/engine/src/captions_tests.rs new file mode 100644 index 00000000..2167d4b7 --- /dev/null +++ b/crates/engine/src/captions_tests.rs @@ -0,0 +1,139 @@ +use serde_json::json; + +use crate::Session; +use filmcraft_project::ClipId; +use filmcraft_time::{TICKS_PER_SECOND, Tick}; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +#[test] +fn captions_import_edit_export_and_burn_in() { + let dir = std::env::temp_dir().join(format!("fc-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let srt = dir.join("talk.srt"); + std::fs::write( + &srt, + "\u{feff}1\r\n00:00:00,500 --> 00:00:02,000\r\nHello there\r\n\r\n2\r\n00:00:02,500 --> 00:00:04,000\r\nSecond line\r\nwith two rows\r\n", + ) + .unwrap(); + let mut s = demo(); + let r = s.execute("file.import", json!({"paths": [srt.to_string_lossy()]})).unwrap(); + assert_eq!(r["documents"][0]["captions"], 2, "{r}"); + let q = s.active_sequence().unwrap(); + let rate = q.settings.frame_rate; + let tr = q.caption_tracks[0].clone(); + assert_eq!(tr.name, "talk"); + assert_eq!(tr.captions[0].start, rate.snap_nearest(Tick(TICKS_PER_SECOND / 2)), "snapped to frames"); + let first = tr.captions[0].id; + let second = tr.captions[1].id; + + // edit text, split, merge, trim, move, add at playhead, delete — each one undoable + s.execute("captions.setText", json!({"caption": first.0, "text": "Hi!", "speaker": "Ann"})).unwrap(); + let split_at = rate.tick_of(rate.frame_at(tr.captions[1].start) + 10); + let r = s.execute("captions.split", json!({"caption": second.0, "time": split_at.0})).unwrap(); + let right = ClipId(r["captions"][0].as_u64().unwrap()); + assert_eq!(s.active_sequence().unwrap().caption_tracks[0].captions.len(), 3); + s.execute("captions.merge", json!({"captions": [second.0, right.0]})).unwrap(); + assert_eq!(s.active_sequence().unwrap().caption_tracks[0].captions.len(), 2); + s.execute("captions.trim", json!({"caption": second.0, "edge": "out", "deltaFrames": 12})).unwrap(); + s.execute("captions.move", json!({"captions": [second.0], "deltaFrames": 24})).unwrap(); + s.execute("playhead.set", json!({"seconds": 8.0})).unwrap(); + let r = s.execute("captions.add", json!({"text": "Added"})).unwrap(); + let added = ClipId(r["caption"].as_u64().unwrap()); + assert_eq!(s.state.caption_selection, vec![added]); + s.execute("edit.clear", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_caption(added).is_none(), "Clear deletes selected captions"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_caption(added).is_some()); + let list = s.execute("captions.list", json!({})).unwrap(); + assert_eq!(list["tracks"][0]["captions"][0]["text"], "Hi!"); + assert_eq!(list["tracks"][0]["captions"][0]["speaker"], "Ann"); + s.active_sequence().unwrap().check().unwrap(); + + // export in all three formats and read back: same texts + for ext in ["srt", "vtt", "scc"] { + let path = dir.join(format!("out.{ext}")).to_string_lossy().to_string(); + let r = s.execute("captions.export", json!({"path": path})).unwrap(); + assert_eq!(r["captions"], 3, "{ext}: {r}"); + let back = filmcraft_captions::parse(&std::fs::read(&path).unwrap(), filmcraft_captions::Format::from_name(ext).unwrap()).unwrap(); + let texts: Vec = back.cues.iter().map(|c| filmcraft_project::plain_text(&c.text)).collect(); + assert_eq!(texts.len(), 3, "{ext}"); + assert_eq!(texts[0], "Hi!", "{ext}"); + } + + // the program renders captions; hiding the track removes them + s.execute("playhead.set", json!({"seconds": 1.0})).unwrap(); + let with = s.render_program(0.25).unwrap(); + s.execute("captions.hideAll", json!({})).unwrap(); + let without = s.render_program(0.25).unwrap(); + let diff = with.px.iter().zip(&without.px).filter(|(a, b)| (*a - *b).abs() > 0.05).count(); + assert!(diff > 200, "captions change the picture ({diff} samples)"); + let half = with.w * 4 * (with.h / 2); + let top_diff = with.px[..half].iter().zip(&without.px[..half]).filter(|(a, b)| (*a - *b).abs() > 0.05).count(); + assert_eq!(top_diff, 0, "bottom-anchored captions stay in the lower half"); + s.execute("captions.showAll", json!({})).unwrap(); + let plan = filmcraft_render::plan::plan_frame( + &s.project, + s.state.active_sequence.unwrap(), + s.playhead(), + filmcraft_render::RenderOptions { scale: 0.25, captions: true, ..Default::default() }, + &s.media.provider(s.project.clone(), s.services.clone()), + ); + let cpu = filmcraft_render::plan::execute_cpu(&plan); + let pd = cpu.px.iter().zip(&with.px).filter(|(a, b)| (*a - *b).abs() > 0.02).count(); + assert!(pd < cpu.px.len() / 200, "the GPU plan matches the CPU render ({pd})"); + + // save/open keeps caption tracks + let proj = dir.join("cap.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": proj})).unwrap(); + let before = s.active_sequence().unwrap().caption_tracks.clone(); + s.execute("file.open", json!({"path": proj})).unwrap(); + assert_eq!(s.active_sequence().unwrap().caption_tracks, before); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn caption_track_follows_sync_locked_insert() { + let mut s = demo(); + s.execute("captions.newTrack", json!({"format": "CEA-608"})).unwrap(); + s.execute("playhead.set", json!({"seconds": 4.0})).unwrap(); + s.execute("captions.add", json!({"text": "later"})).unwrap(); + let start = s.active_sequence().unwrap().caption_tracks[0].captions[0].start; + let item = s.project.items.values().find(|i| i.as_media().is_some_and(|m| m.info.has_video())).unwrap().id; + s.execute("source.open", json!({"item": item.0})).unwrap(); + s.execute("playhead.set", json!({"seconds": 0.0})).unwrap(); + s.execute("source.insert", json!({})).unwrap(); + let after = s.active_sequence().unwrap().caption_tracks[0].captions[0].start; + assert!(after > start, "caption moved right with the insert"); + s.active_sequence().unwrap().check().unwrap(); +} + +#[test] +fn caption_commands_need_tracks_and_navigate() { + let mut s = demo(); + assert!(s.execute("captions.split", json!({})).is_err(), "disabled without captions"); + for (sec, text) in [(1.0, "one"), (3.0, "two"), (6.0, "three")] { + s.execute("playhead.set", json!({"seconds": sec})).unwrap(); + s.execute("captions.add", json!({"text": text, "durationSeconds": 1.0})).unwrap(); + } + s.execute("playhead.set", json!({"seconds": 0.0})).unwrap(); + s.execute("captions.next", json!({})).unwrap(); + s.execute("captions.next", json!({})).unwrap(); + let id = s.state.caption_selection[0]; + assert_eq!(s.active_sequence().unwrap().find_caption(id).unwrap().1.text, "two"); + s.execute("captions.previous", json!({})).unwrap(); + let id = s.state.caption_selection[0]; + assert_eq!(s.active_sequence().unwrap().find_caption(id).unwrap().1.text, "one"); + s.execute("captions.setStyle", json!({"color": "#ffff00", "anchor": "top", "size": 40})).unwrap(); + let st = &s.active_sequence().unwrap().caption_tracks[0].style; + assert_eq!(st.color, [255, 255, 0, 255]); + assert!(s.execute("captions.setStyle", json!({"anchor": "sideways"})).is_err()); + s.execute("captions.setTimes", json!({"caption": id.0, "startSeconds": 0.5, "endSeconds": 2.5})).unwrap(); + assert!(s.execute("captions.setTimes", json!({"caption": id.0, "endSeconds": 3.5})).is_err(), "would overlap `two`"); + s.execute("captions.deleteTrack", json!({"track": "C1"})).unwrap(); + assert!(s.active_sequence().unwrap().caption_tracks.is_empty()); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 956dd8ca..097b7b93 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -57,7 +57,7 @@ pub fn find(id: &str) -> Option<&'static CommandSpec> { // ---------- enablement ---------- -fn always(_: &Session) -> std::result::Result<(), String> { +pub(crate) fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } fn has_edit_points(s: &Session) -> std::result::Result<(), String> { @@ -65,13 +65,18 @@ fn has_edit_points(s: &Session) -> std::result::Result<(), String> { if s.state.edit_points.is_empty() { Err("no edit points selected".into()) } else { Ok(()) } } -fn has_seq(s: &Session) -> std::result::Result<(), String> { +pub(crate) fn has_seq(s: &Session) -> std::result::Result<(), String> { s.active_sequence().map(|_| ()).ok_or_else(|| "no sequence is open".into()) } fn has_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; if s.state.selection.is_empty() { Err("no clips selected".into()) } else { Ok(()) } } +/// Clips or captions selected (Clear / Ripple Delete work on either). +fn has_any_selection(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } +} fn has_source(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; s.state.source_item.map(|_| ()).ok_or_else(|| "no clip in the Source monitor".into()) @@ -96,24 +101,24 @@ fn has_clipboard(s: &Session) -> std::result::Result<(), String> { // ---------- param helpers ---------- -fn bad(cmd: &str, msg: impl Into) -> EngineError { +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } -fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { +pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } -fn f64_p(p: &Value, k: &str) -> Option { +pub(crate) fn f64_p(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64) } -fn bool_p(p: &Value, k: &str) -> Option { +pub(crate) fn bool_p(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_bool) } -fn u64_p(p: &Value, k: &str) -> Option { +pub(crate) fn u64_p(p: &Value, k: &str) -> Option { p.get(k).and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))) } /// Parse a time from params: `time` (ticks), `frame`, `seconds` or `timecode`, with `prefix`. -fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { +pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { let rate = s.sequence_rate(); let k = |n: &str| if prefix.is_empty() { n.to_string() } else { format!("{prefix}{}{}", n[..1].to_uppercase(), &n[1..]) }; if let Some(t) = p.get(k("time")).and_then(Value::as_i64) { @@ -496,6 +501,13 @@ fn build() -> Vec { let mut reports = Vec::new(); for path in paths { match s.services.read_file(&path) { + Ok(b) if crate::captions::detect(&path, &b).is_some() => { + let fmt = crate::captions::detect(&path, &b).expect("detected"); + match crate::captions::import(s, &path, &b, fmt, None) { + Ok(r) => reports.push(r), + Err(e) => errors.push(format!("{path}: {e}")), + } + } Ok(b) if crate::interchange::detect(&path, &b).is_some() => { let fmt = crate::interchange::detect(&path, &b).expect("detected"); match crate::interchange::import(s, &path, &b, fmt) { @@ -513,7 +525,7 @@ fn build() -> Vec { Err(e) => errors.push(format!("{path}: {e}")), } } - if ids.is_empty() && sequences.is_empty() && !errors.is_empty() { + if ids.is_empty() && sequences.is_empty() && reports.is_empty() && !errors.is_empty() { return Err(EngineError::Other(errors.join("; "))); } if reports.is_empty() { @@ -583,7 +595,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"format":"h264|prores|mjpeg|png|gif|wav","scale":f32=1,"audio":bool=true,"quality":0..100}"#, + r#"{"path":str,"format":"h264|prores|mjpeg|png|gif|wav","scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false}"#, has_seq, |s, p| export_media(s, p) ), @@ -613,7 +625,11 @@ fn build() -> Vec { }), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clipboard, |s, _| paste(s, false)), cmd!("edit.pasteInsert", "Paste Insert", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, _| paste(s, true)), - cmd!("edit.clear", "Clear", ["Edit"], Some("Backspace"), r#"{"clips":[id]?}"#, has_selection, |s, p| { + cmd!("edit.clear", "Clear", ["Edit"], Some("Backspace"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| { + if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() { + let caps = s.state.caption_selection.clone(); + return crate::captions::delete(s, &caps, false); + } let sel = with_links(s, &clips_p(s, p)); s.edit_sequence("Clear", |q, _, st| { edit::delete_items(q, &sel); @@ -622,7 +638,11 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_selection, |s, p| { + cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| { + if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() { + let caps = s.state.caption_selection.clone(); + return crate::captions::delete(s, &caps, true); + } let sel = with_links(s, &clips_p(s, p)); s.edit_sequence("Ripple Delete", |q, _, st| { edit::ripple_delete_items(q, &sel)?; @@ -638,6 +658,7 @@ fn build() -> Vec { }), cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { s.state.selection.clear(); + s.state.caption_selection.clear(); Ok(Value::Null) }), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Shift+/"), "{}", has_project_selection, |s, _| { @@ -834,7 +855,11 @@ fn build() -> Vec { }), cmd!("sequence.addEditAllTracks", "Add Edit to All Tracks", ["Sequence"], Some("Cmd+Shift+K"), r#"{"time":ticks?}"#, has_seq, |s, p| { let t = time_p(s, p, "").unwrap_or(s.playhead()); - let n = s.edit_sequence("Add Edit to All Tracks", |q, ctx, _| Ok(edit::razor(q, &[], t, ctx)))?; + let n = s.edit_sequence("Add Edit to All Tracks", |q, ctx, _| { + let mut n = edit::razor(q, &[], t, ctx); + n.extend(edit::captions::split_captions_at(q, &[], t, ctx)); + Ok(n) + })?; Ok(json!({"cuts": n.len()})) }), cmd!("sequence.lift", "Lift", ["Sequence"], Some(";"), "{}", has_in_out, |s, _| { @@ -1212,6 +1237,7 @@ fn build() -> Vec { } } else { s.state.selection = clips; + s.state.caption_selection.clear(); } Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::>()})) }), @@ -1695,6 +1721,7 @@ fn build() -> Vec { json!({"undo": s.history.undo.iter().map(|h| &h.0).collect::>(), "redo": s.history.redo.iter().map(|h| &h.0).collect::>()}) )), ]; + v.extend(crate::captions::commands()); // Labels as individual commands (Edit ▸ Label ▸ ) for l in Label::ALL { let _ = l; @@ -1718,6 +1745,7 @@ fn export_media(s: &mut Session, p: &Value) -> Result { include_audio: bool_p(p, "audio").unwrap_or(true), quality: u64_p(p, "quality").unwrap_or(90).min(100) as u8, bitrate_kbps: u64_p(p, "bitrateKbps").unwrap_or(20_000) as u32, + burn_captions: bool_p(p, "burnCaptions").unwrap_or(false), }; let id = s.jobs.len() as u64 + 1; let job = crate::Job { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index afab02fb..b491c8b9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ //! The project is an `Arc` edited copy-on-write; undo keeps whole-project snapshots (cheap //! thanks to structural sharing of untouched items). +pub mod captions; pub mod commands; pub mod demo; pub mod interchange; @@ -123,6 +124,9 @@ pub struct EditorState { /// Selected edit points (trim mode). #[serde(default)] pub edit_points: Vec, + /// Selected captions (caption tracks / Captions panel). + #[serde(default)] + pub caption_selection: Vec, } /// Events for frontends (drained each frame). @@ -299,8 +303,10 @@ impl Session { } if let Some(seq) = self.state.active_sequence.and_then(|s| p.sequence(s)) { self.state.selection.retain(|c| seq.find_item(*c).is_some()); + self.state.caption_selection.retain(|c| seq.find_caption(*c).is_some()); } else { self.state.selection.clear(); + self.state.caption_selection.clear(); } self.state.project_selection.retain(|i| p.item(*i).is_some()); } @@ -362,7 +368,8 @@ impl Session { pub fn render_program(&self, scale: f32) -> Option { let seq = self.state.active_sequence?; let provider = self.media.provider(self.project.clone(), self.services.clone()); - Some(filmcraft_render::render_sequence(&self.project, seq, self.playhead(), filmcraft_render::RenderOptions { scale, ..Default::default() }, &provider)) + let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() }; + Some(filmcraft_render::render_sequence(&self.project, seq, self.playhead(), opts, &provider)) } } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index dd032b46..0fa6f944 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -94,11 +94,23 @@ pub struct ExportSettings { pub quality: u8, /// Target video bitrate (kbps) for bitrate-driven encoders. pub bitrate_kbps: u32, + /// Burn the visible caption tracks into the picture (Export ▸ Captions ▸ Burn Captions Into + /// Video). + pub burn_captions: bool, } impl Default for ExportSettings { fn default() -> Self { - Self { format: Format::H264, path: String::new(), range: None, scale: 1.0, include_audio: true, quality: 90, bitrate_kbps: 20_000 } + Self { + format: Format::H264, + path: String::new(), + range: None, + scale: 1.0, + include_audio: true, + quality: 90, + bitrate_kbps: 20_000, + burn_captions: false, + } } } @@ -439,7 +451,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let h = (((q.settings.height as f32 * settings.scale).round() as u32).max(2)) & !1; progress.total.store(if settings.format == Format::Wav { 1 } else { nframes }, Ordering::Relaxed); progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label())); - let opts = RenderOptions { scale: w as f32 / q.settings.width as f32, ..Default::default() }; + let opts = RenderOptions { scale: w as f32 / q.settings.width as f32, captions: settings.burn_captions, ..Default::default() }; let render = |f: i64| -> Vec { let img = filmcraft_render::render_sequence(project, seq, rate.tick_of(f), opts, sources); let mut rgba = img.over_black_rgba8(); diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 7747a75b..5ef76d18 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -99,3 +99,79 @@ fn prores_export_roundtrip() { let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap().to_rgba8(); assert!(f[0] > 240 && f[1] < 15 && f[2] < 15, "{:?}", &f[..4]); } + +/// Burn-in: an H.264 export of a dark-blue matte with a caption, decoded by ffmpeg (external test +/// oracle only), has bright caption pixels in the lower part of the frame, and none without +/// `burn_captions` or above the caption. +#[test] +fn caption_burn_in_h264_ffmpeg_oracle() { + let Some(ffmpeg) = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].into_iter().find(|p| std::path::Path::new(p).exists()) else { + eprintln!("ffmpeg not found; skipping oracle test"); + return; + }; + let mut p = Project::new("cap"); + let (w, h) = (640u32, 360u32); + let g = GeneratorSource::new(Generator::ColorMatte { color: [0.0, 0.0, 0.2, 1.0] }, w, h, FrameRate::FPS_24, Tick(2 * TICKS_PER_SECOND)); + let info = g.info().clone(); + let matte = p.add_item( + "matte", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + }), + None, + ); + let r = FrameRate::FPS_24; + let seq = p.new_sequence("s", SequenceSettings { width: w, height: h, frame_rate: r, ..Default::default() }, 1, 0, None); + let v = p.make_track_item(matte, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap(); + let tid = filmcraft_project::TrackId(p.alloc_id()); + let cid = filmcraft_project::ClipId(p.alloc_id()); + let mut ct = filmcraft_project::CaptionTrack::new(tid, "Subtitle".into(), filmcraft_project::CaptionFormat::Subtitle); + ct.style.background = false; + ct.captions.push(filmcraft_project::Caption { + id: cid, + start: Tick::ZERO, + duration: r.tick_of(24), + text: "BURNED IN".into(), + speaker: None, + cue_id: None, + settings: String::new(), + }); + let q = p.sequence_mut(seq).unwrap(); + q.video_tracks[0].items.push(v); + q.caption_tracks.push(ct); + let mut m = SourceMap::default(); + m.0.insert(matte, Arc::new(g)); + let p = Arc::new(p); + let decode = |burn: bool| -> Vec { + let path = tmp(if burn { "burn.mp4" } else { "noburn.mp4" }); + let s = ExportSettings { format: Format::H264, path: path.clone(), include_audio: false, burn_captions: burn, ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let out = std::process::Command::new(ffmpeg) + .args(["-v", "error", "-i", &path, "-vf", "select=eq(n\\,12)", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(out.stdout.len(), (w * h * 3) as usize); + out.stdout + }; + let bright = |rgb: &[u8], y0: u32, y1: u32, x0: u32, x1: u32| -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| ((y * w + x) * 3) as usize)).filter(|&i| rgb[i] > 200 && rgb[i + 1] > 200 && rgb[i + 2] > 200).count() + }; + let with = decode(true); + let without = decode(false); + let lower = bright(&with, h * 2 / 3, h, 0, w); + assert!(lower > 300, "white caption text in the lower third: {lower}"); + assert_eq!(bright(&with, 0, h / 2, 0, w), 0, "nothing above the caption"); + assert_eq!(bright(&without, 0, h, 0, w), 0, "no burn-in unless asked"); + // centred: text on both sides of the centre line + let (left, right) = (bright(&with, h * 2 / 3, h, 0, w / 2), bright(&with, h * 2 / 3, h, w / 2, w)); + assert!(left > 100 && right > 100, "{left} / {right}"); +} 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"); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 4b5594b2..9d2ee00c 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -17,6 +17,7 @@ filmcraft-frame = { workspace = true } filmcraft-media = { workspace = true } filmcraft-project = { workspace = true } filmcraft-audio-dsp = { workspace = true } +filmcraft-captions = { workspace = true } rayon = { workspace = true } [dev-dependencies] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index aca70ee0..2d2c8ba0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -44,11 +44,14 @@ pub struct RenderOptions { pub effects: bool, /// Nesting depth guard. pub depth: u32, + /// Draw the sequence's visible caption tracks over the picture (Program monitor, burn-in on + /// export). Never applies to nested sequences. + pub captions: bool, } impl Default for RenderOptions { fn default() -> Self { - Self { scale: 1.0, effects: true, depth: 0 } + Self { scale: 1.0, effects: true, depth: 0, captions: false } } } @@ -124,9 +127,22 @@ fn render_seq(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, s blend::composite(&mut canvas, &layer, op, bl); } } + if opts.captions && opts.depth == 0 { + for o in caption_overlays(seq, t, w, h) { + o.composite_onto(&mut canvas.px, w, h); + } + } canvas } +/// Rendered captions of the visible caption tracks at `t` for a `w`×`h` output. +pub fn caption_overlays(seq: &Sequence, t: Tick, w: usize, h: usize) -> Vec { + if seq.caption_tracks.is_empty() { + return Vec::new(); + } + filmcraft_captions::burn::sequence_overlays(seq, t, w, h) +} + fn with_opacity(mut img: Image, op: f32) -> Image { img.scale_alpha(op); img @@ -214,7 +230,7 @@ pub(crate) fn item_layer( Image { w, h, px } } ItemKind::Sequence(nested) => { - let sub = RenderOptions { scale: want, effects: opts.effects, depth: opts.depth + 1 }; + let sub = RenderOptions { scale: want, effects: opts.effects, depth: opts.depth + 1, captions: false }; render_seq(project, nested, mt, sub, sources) } ItemKind::AdjustmentLayer { .. } => return None, diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 993a24d2..3732f045 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -116,6 +116,15 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption } push_item(project, seq, item, t, opts, sources, 1.0, &mut layers); } + if opts.captions { + for o in crate::caption_overlays(seq, t, w, h) { + layers.push(PlanLayer { + frame: Arc::new(VideoFrame::rgba_f32(o.w as u32, o.h as u32, o.px)), + matrix: Affine::translate(o.x as f64, o.y as f64), + opacity: 1.0, + }); + } + } FramePlan::Layers { width: w, height: h, layers } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 20925140..79642695 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -193,7 +193,7 @@ impl FrameServer { &job.project, seq, job.time, - filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() }, + filmcraft_render::RenderOptions { scale: job.scale, captions: true, ..Default::default() }, &provider, ); self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, (Arc::new(plan), 0)); @@ -226,7 +226,7 @@ fn worker(sh: Arc, pool: Arc, services: Arc, re &job.project, seq, job.time, - filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() }, + filmcraft_render::RenderOptions { scale: job.scale, captions: true, ..Default::default() }, &provider, ); let mut g = sh.plans.lock().unwrap_or_else(|e| e.into_inner()); @@ -257,7 +257,7 @@ fn render_job(job: &Job, pool: &Arc, services: &Arc) -> &job.project, seq, job.time, - filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() }, + filmcraft_render::RenderOptions { scale: job.scale, captions: true, ..Default::default() }, &provider, )), Target::Item(item) => filmcraft_render::render_item(&job.project, item, job.time, job.scale, &provider), @@ -265,7 +265,7 @@ fn render_job(job: &Job, pool: &Arc, services: &Arc) -> &job.project, seq, job.time, - filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() }, + filmcraft_render::RenderOptions { scale: job.scale, captions: true, ..Default::default() }, &provider, )), }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6bd56fd3..e2874457 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -48,6 +48,8 @@ pub struct HostHooks { pub pick_files: Option Vec>>, pub pick_save: Option Option>>, pub pick_open_project: Option Option>>, + /// Save dialog with a filter: (filter name, extensions, suggested file name) → path. + pub pick_save_as: Option Option>>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -432,13 +434,14 @@ impl FilmcraftApp { // ---------------------------------------------------------------- files - pub fn file_dialog(&mut self, id: &str) -> Result { + pub fn file_dialog(&mut self, id: &str, params: &Value) -> Result { match id { "file.import" => { let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS .iter() .chain(filmcraft_media::AUDIO_EXTENSIONS) .chain(filmcraft_media::STILL_EXTENSIONS) + .chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio"]) .copied() .collect(); let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default(); @@ -463,6 +466,21 @@ impl FilmcraftApp { let Some(path) = self.hooks.pick_open_project.as_mut().and_then(|f| f()) else { return Ok(Value::Null) }; self.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string()) } + "captions.import" => { + let paths = self.hooks.pick_files.as_mut().map(|f| f(&["srt", "vtt", "scc"])).unwrap_or_default(); + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + self.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) + } + "captions.export" => { + let name = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); + let suggested = format!("{}.srt", name.replace(' ', "_")); + let Some(path) = self.hooks.pick_save_as.as_mut().and_then(|f| f("Captions (SRT, WebVTT, SCC)", &["srt", "vtt", "scc"], &suggested)) else { + return Ok(Value::Null); + }; + let mut p = params.clone(); + p["path"] = json!(path); + self.session.execute("captions.export", p).map_err(|e| e.to_string()) + } _ => Err(format!("no dialog for {id}")), } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4c76afe6..950212a3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -180,8 +180,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val || (id == "file.saveAs" && params.get("path").is_none()) || (id == "file.open" && params.get("path").is_none()) || (id == "file.save" && params.get("path").is_none() && app.session.path.is_none()) + || (matches!(id, "captions.import" | "captions.export") && params.get("path").is_none()) { - return app.file_dialog(id); + return app.file_dialog(id, ¶ms); } let r = app.session.execute(id, params).map_err(|e| e.to_string()); if let Err(e) = &r { diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index a7f8f037..7131857e 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -73,6 +73,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(v); }); } + if !q.caption_tracks.is_empty() { + sui.add_space(6.0); + sui.label(egui::RichText::new("Captions").color(t.text_dim)); + let r = sui.checkbox(&mut app.ui.export_burn_captions, "Burn Captions Into Video"); + app.auto.add("export.burnCaptions", r.rect, "Burn Captions Into Video"); + } sui.add_space(12.0); // jobs let jobs: Vec = app.session.jobs.iter().rev().take(4).map(|j| j.to_json()).collect(); @@ -97,7 +103,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(b.center(), Align2::CENTER_CENTER, "Export", Tokens::semibold(13.0), Color32::WHITE); app.auto.add("export.button", b, "Export"); if resp.clicked() { - let r = app.session.execute("file.exportMedia", serde_json::json!({"path": expand_home(&app.ui.export_path), "format": app.ui.export_format})); + let r = app.session.execute( + "file.exportMedia", + serde_json::json!({"path": expand_home(&app.ui.export_path), "format": app.ui.export_format, "burnCaptions": app.ui.export_burn_captions}), + ); if let Err(e) = r { app.ui.status = e.to_string(); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ca9cca98..b9eed02a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -11,7 +11,9 @@ pub mod meters; pub mod misc; pub mod monitor; pub mod project; +pub mod text; pub mod timeline; +pub mod timeline_captions; pub mod tools; use egui::{Align2, Color32, Rect}; @@ -39,6 +41,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::MediaBrowser => misc::media_browser(app, ui, rect), PanelKind::AudioTrackMixer => meters::track_mixer(app, ui, rect), PanelKind::LumetriScopes => misc::scopes(app, ui, rect), + PanelKind::Text => text::show(app, ui, rect), other => crate::dock::placeholder(ui, rect, &app.tokens, &format!("{} — coming in a later milestone", other.title())), } } diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs new file mode 100644 index 00000000..7189c650 --- /dev/null +++ b/crates/ui-egui/src/panels/text.rs @@ -0,0 +1,312 @@ +//! The Text panel: Transcript / Captions / Graphics tabs. The Captions tab lists the caption +//! segments of a caption track with editable in/out timecodes and text, a toolbar (add, split, +//! merge, delete), track choice and the track style. Every edit dispatches a `captions.*` engine +//! command; every widget registers an automation id (`text.*`). + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_project::{CaptionAlign, CaptionAnchor, CaptionFormat}; +use filmcraft_time::{TimeDisplay, format_time}; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; + +const TABS: [&str; 3] = ["Transcript", "Captions", "Graphics"]; + +pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + ui.painter().rect_filled(rect, 0.0, t.panel_bg); + // tabs + let mut x = rect.min.x + 12.0; + for tab in TABS { + let w = tab.len() as f32 * 7.0 + 16.0; + let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0)); + let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click()); + let active = app.ui.text_tab == tab; + ui.painter().text( + pos2(r.min.x, r.center().y), + Align2::LEFT_CENTER, + tab, + if active { Tokens::semibold(12.5) } else { Tokens::ui(12.5) }, + if active { t.text } else { t.text_dim }, + ); + if active { + ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + w - 16.0, r.max.y)], Stroke::new(2.0, t.text)); + } + app.auto.add(&format!("text.tab.{tab}"), r, tab); + if resp.clicked() { + app.ui.text_tab = tab.to_string(); + } + x += w + 6.0; + } + let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 34.0), rect.max); + match app.ui.text_tab.as_str() { + "Captions" => captions(app, ui, body), + "Transcript" => crate::dock::placeholder(ui, body, &t, "Transcript (speech-to-text) arrives in M10.5"), + _ => crate::dock::placeholder(ui, body, &t, "Graphics text search arrives with M10.1–M10.2"), + } +} + +fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, id: &str, label: &str, enabled: bool) -> bool { + let t = app.tokens; + let resp = ui.interact(r, egui::Id::new(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() }); + if enabled && resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + icons::paint(ui.painter(), r.shrink(5.0), icon, if enabled { t.icon } else { t.text_faint }); + app.auto.add(id, r, label); + resp.on_hover_text(label).clicked() && enabled +} + +fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let Some(seq) = app.session.active_sequence().cloned() else { + crate::dock::placeholder(ui, rect, &t, "Open a sequence to work with captions"); + return; + }; + let mut actions: Vec<(String, Value)> = Vec::new(); + if seq.caption_tracks.is_empty() { + let c = rect.center(); + icons::paint(ui.painter(), Rect::from_center_size(c - vec2(0.0, 70.0), vec2(40.0, 40.0)), Icon::Captions, t.text_dim); + ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, "Add captions", Tokens::semibold(16.0), t.text); + ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, "Create a caption track or import a caption file.", Tokens::ui(12.0), t.text_dim); + for (i, (id, label, cmd)) in + [("text.captions.newTrack", "Create new caption track", "captions.newTrack"), ("text.captions.import", "Import captions file…", "captions.import")] + .into_iter() + .enumerate() + { + let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0)); + let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + ui.painter().rect_filled(r, 13.0, if i == 0 { if resp.hovered() { t.accent_hover } else { t.accent } } else { t.field_bg }); + ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(12.0), Color32::WHITE); + app.auto.add(id, r, label); + if resp.clicked() { + actions.push((cmd.into(), json!({}))); + } + } + run(app, ui, actions); + return; + } + let rate = seq.settings.frame_rate; + let df = seq.settings.drop_frame; + let tc = |x: filmcraft_time::Tick| format_time(x, rate, df, TimeDisplay::Timecode, 48_000); + // the track shown: the one holding the first selected caption, else C1 + let sel = app.session.state.caption_selection.clone(); + let track_idx = sel.first().and_then(|c| seq.caption_tracks.iter().position(|tr| tr.caption(*c).is_some())).unwrap_or(0); + let track_idx = ui.ctx().data(|d| d.get_temp::(egui::Id::new("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx); + let track = &seq.caption_tracks[track_idx]; + + // ---- toolbar: search, track picker, add / split / merge / delete + let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(170.0f32.min(bar.width() * 0.4), 24.0)))); + let sresp = crate::widgets::search_field(&mut child, &mut app.ui.caption_search, "Search", 170.0f32.min(bar.width() * 0.4), &t); + app.auto.add("text.captions.search", sresp.rect, "Search captions"); + let mut x = bar.min.x + 180.0f32.min(bar.width() * 0.4 + 10.0); + let picker = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(130.0, 22.0)); + let label = format!("C{} · {}", track_idx + 1, track.name); + let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::new("text-cap-track-picker")); + app.auto.add("text.captions.track", picker, "Caption track"); + egui::Popup::menu(&presp).show(|ui| { + for (i, tr) in seq.caption_tracks.iter().enumerate() { + if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, tr.format.label())).clicked() { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("text-cap-track"), i)); + } + } + ui.separator(); + for f in CaptionFormat::ALL { + if ui.button(format!("New {} track", f.label())).clicked() { + actions.push(("captions.newTrack".into(), json!({"format": f.label()}))); + } + } + }); + x = picker.max.x + 8.0; + let ph = app.session.playhead(); + let any_sel = !sel.is_empty(); + let under = track.caption_at(ph).filter(|c| c.start < ph).map(|c| c.id); + let tools: [(Icon, &str, &str, bool, &str, Value); 5] = [ + (Icon::Plus, "text.captions.add", "Add caption at playhead", track.caption_at(ph).is_none(), "captions.add", json!({"track": track.id.0})), + ( + Icon::Razor, + "text.captions.split", + "Split caption at playhead", + under.is_some(), + "captions.split", + json!({"caption": under.map(|c| c.0), "time": ph.0}), + ), + (Icon::Link, "text.captions.merge", "Merge selected captions", sel.len() > 1, "captions.merge", json!({})), + (Icon::Trash, "text.captions.delete", "Delete selected captions", any_sel, "captions.delete", json!({})), + (Icon::Export, "text.captions.export", "Export captions…", !track.captions.is_empty(), "captions.export", json!({"track": track.id.0})), + ]; + for (icon, id, label, enabled, cmd, params) in tools { + let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); + if r.max.x > rect.max.x { + break; + } + if tool_button(app, ui, r, icon, id, label, enabled) { + actions.push((cmd.into(), params)); + } + x += 28.0; + } + + // ---- style strip + let style_h = 30.0; + let style_rect = Rect::from_min_max(pos2(rect.min.x + 10.0, rect.max.y - style_h), pos2(rect.max.x - 10.0, rect.max.y - 2.0)); + style_strip(app, ui, style_rect, track_idx, &mut actions); + + // ---- segment list + let list = Rect::from_min_max(pos2(rect.min.x + 6.0, bar.max.y + 8.0), pos2(rect.max.x - 6.0, style_rect.min.y - 6.0)); + ui.painter().rect_filled(list, 3.0, t.app_bg); + let q = app.ui.caption_search.to_lowercase(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink(4.0)).id_salt("caption-list")); + let current = track.caption_at(ph).map(|c| c.id); + egui::ScrollArea::vertical().auto_shrink([false, false]).id_salt("caption-scroll").show(&mut child, |ui| { + ui.set_width(list.width() - 12.0); + let mut shown = 0; + for (n, c) in track.captions.iter().enumerate() { + if !q.is_empty() && !c.text.to_lowercase().contains(&q) && !c.speaker.as_deref().unwrap_or("").to_lowercase().contains(&q) { + continue; + } + shown += 1; + let selected = sel.contains(&c.id); + let fill = if selected { + t.row_selected + } else if n % 2 == 1 { + t.row_alt + } else { + Color32::TRANSPARENT + }; + let frame = egui::Frame::NONE.fill(fill).inner_margin(egui::Margin::symmetric(6, 4)).corner_radius(3.0); + let fr = frame.show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(92.0); + let num = ui.add(egui::Button::new(egui::RichText::new(format!("{}", n + 1)).size(11.0).color(t.text_dim)).frame(false)); + app.auto.add(&format!("text.captions.{}.goto", c.id.0), num.rect, "Go to caption"); + if num.clicked() { + if ui.input(|i| i.modifiers.command || i.modifiers.shift) { + actions.push(("captions.select".into(), json!({"captions": [c.id.0], "add": true}))); + } else { + actions.push(("captions.goTo".into(), json!({"caption": c.id.0}))); + } + } + for (edge, val) in [("in", c.start), ("out", c.end())] { + let key = egui::Id::new(("cap-tc", c.id.0, edge)); + let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| tc(val)); + let resp = ui.add( + egui::TextEdit::singleline(&mut buf) + .id(key.with("te")) + .desired_width(88.0) + .font(Tokens::mono(11.0)) + .text_color(if edge == "in" { t.hot_text } else { t.text_dim }), + ); + app.auto.add(&format!("text.captions.{}.{edge}", c.id.0), resp.rect, if edge == "in" { "Caption in" } else { "Caption out" }); + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(key, buf.clone())); + } else { + ui.data_mut(|d| d.remove::(key)); + } + if resp.lost_focus() && buf.trim() != tc(val) { + let k = if edge == "in" { "startTimecode" } else { "endTimecode" }; + actions.push(("captions.setTimes".into(), json!({"caption": c.id.0, k: buf.trim()}))); + } + } + }); + ui.vertical(|ui| { + if let Some(sp) = &c.speaker { + ui.label(egui::RichText::new(sp).size(11.0).color(t.text_dim).strong()); + } + let key = egui::Id::new(("cap-text", c.id.0)); + let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| c.text.clone()); + let resp = ui.add( + egui::TextEdit::multiline(&mut buf) + .id(key.with("te")) + .desired_rows(1) + .desired_width(ui.available_width()) + .font(Tokens::ui(12.5)) + .frame(egui::Frame::NONE), + ); + app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text"); + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(key, buf.clone())); + if !selected { + actions.push(("captions.select".into(), json!({"captions": [c.id.0]}))); + } + } else { + ui.data_mut(|d| d.remove::(key)); + } + if resp.lost_focus() && buf != c.text { + actions.push(("captions.setText".into(), json!({"caption": c.id.0, "text": buf}))); + } + }); + }); + }); + let row = fr.response.rect; + if current == Some(c.id) { + ui.painter().rect_stroke(row, 3.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + app.auto.add(&format!("text.captions.{}.row", c.id.0), row, &c.text); + ui.add_space(2.0); + } + if shown == 0 { + ui.label( + egui::RichText::new(if q.is_empty() { "No captions on this track. Press + to add one at the playhead." } else { "No matching captions." }) + .color(t.text_faint), + ); + } + }); + run(app, ui, actions); +} + +fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: usize, actions: &mut Vec<(String, Value)>) { + let Some(tr) = app.session.active_sequence().and_then(|q| q.caption_tracks.get(track_idx)).cloned() else { return }; + let st = tr.style.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r).id_salt("caption-style")); + child.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let mut size = st.size; + let resp = ui.add(egui::DragValue::new(&mut size).range(8.0..=200.0).speed(0.5).suffix(" px")); + app.auto.add("text.captions.style.size", resp.rect, "Caption size"); + if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "size": size}))); + } + let mut col = Color32::from_rgba_unmultiplied(st.color[0], st.color[1], st.color[2], st.color[3]); + let resp = egui::color_picker::color_edit_button_srgba(ui, &mut col, egui::color_picker::Alpha::Opaque); + app.auto.add("text.captions.style.color", resp.rect, "Text colour"); + if resp.changed() { + let c = col.to_srgba_unmultiplied(); + actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "color": c}))); + } + let mut bg = st.background; + let resp = ui.checkbox(&mut bg, "Box"); + app.auto.add("text.captions.style.background", resp.rect, "Background box"); + if resp.changed() { + actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "background": bg}))); + } + ui.label(egui::RichText::new("Align").size(11.0).color(app.tokens.text_dim)); + for (a, icon_txt, name) in [(CaptionAlign::Left, "L", "left"), (CaptionAlign::Center, "C", "center"), (CaptionAlign::Right, "R", "right")] { + let resp = ui.selectable_label(st.align == a, icon_txt).on_hover_text(format!("Align {name}")); + app.auto.add(&format!("text.captions.style.align.{name}"), resp.rect, name); + if resp.clicked() { + actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "align": name}))); + } + } + ui.label(egui::RichText::new("Position").size(11.0).color(app.tokens.text_dim)); + for (a, name) in [(CaptionAnchor::Top, "top"), (CaptionAnchor::Middle, "middle"), (CaptionAnchor::Bottom, "bottom")] { + let resp = ui.selectable_label(st.anchor == a, name[..1].to_uppercase()).on_hover_text(format!("Position: {name}")); + app.auto.add(&format!("text.captions.style.anchor.{name}"), resp.rect, name); + if resp.clicked() { + actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "anchor": name}))); + } + } + }); +} + +fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) { + let ctx = ui.ctx().clone(); + for (cmd, p) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &cmd, p) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 6ef671f7..7f4ad459 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -162,7 +162,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let scroll = app.ui.timeline.scroll; // ---- rows - let tracks_area = Rect::from_min_max(content.min, content.max); + // caption tracks sit in their own area above the video tracks + let cap_n = seq.caption_tracks.len(); + let caption_area = Rect::from_min_max(pos2(rect.min.x, content.min.y), pos2(content.max.x, content.min.y + cap_n as f32 * super::timeline_captions::ROW_H)); + let cap_h = if cap_n > 0 { caption_area.height() + DIVIDER_H } else { 0.0 }; + let tracks_area = Rect::from_min_max(pos2(content.min.x, content.min.y + cap_h), content.max); let split_y = tracks_area.min.y + (tracks_area.height() - DIVIDER_H) * app.ui.timeline.split; let video_area = Rect::from_min_max(pos2(rect.min.x, tracks_area.min.y), pos2(content.max.x, split_y)); let audio_area = Rect::from_min_max(pos2(rect.min.x, split_y + DIVIDER_H), pos2(content.max.x, tracks_area.max.y - MASTER_H)); @@ -284,6 +288,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { t.hot_text, ); + // ---- caption tracks + if cap_n > 0 { + painter.rect_filled(Rect::from_min_max(pos2(rect.min.x, caption_area.max.y), pos2(content.max.x, caption_area.max.y + DIVIDER_H)), 0.0, t.app_bg); + super::timeline_captions::paint(app, ui, &seq, caption_area, &layout, &t); + } + // ---- top block: timecode + toggles, ruler draw_top(app, ui, rect, &seq, &layout, &t, seq_id); @@ -333,6 +343,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- interaction interact(app, ui, &seq, &layout, rect); + if cap_n > 0 { + super::timeline_captions::interact(app, ui, &seq, caption_area, &layout); + } let _ = (visible, TICKS_PER_SECOND); } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs new file mode 100644 index 00000000..b154f554 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -0,0 +1,268 @@ +//! Caption track rows of the Timeline: drawn in their own area above the video tracks (Premiere's +//! layout). Each row has a header (C1…, name, format, output eye, lock) and caption blocks in a +//! distinct colour showing their text. Click selects (Cmd/Shift adds), dragging the body moves, +//! dragging an edge trims, double-click opens the Text panel's Captions tab. Every gesture ends in +//! one `captions.*` command; every element registers an automation id (`timeline.caption.*`, +//! `timeline.captionTrack.C1.*`). + +use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_project::{CaptionTrack, ClipId, Sequence}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use super::timeline::Layout; +use crate::FilmcraftApp; +use crate::dock::PanelKind; +use crate::icons::Icon; +use crate::theme::Tokens; + +/// Height of one caption track row. +pub const ROW_H: f32 = 28.0; +/// Caption block colours (fill, selected fill). +const FILL: Color32 = Color32::from_rgb(0x6b, 0x4f, 0x8f); +const FILL_SEL: Color32 = Color32::from_rgb(0x8c, 0x6c, 0xb4); +const ROW_BG: Color32 = Color32::from_rgb(0x23, 0x20, 0x2a); + +/// Row rects (full width incl. header) for each caption track, top first. +pub fn rows(area: Rect, n: usize) -> Vec { + (0..n).map(|i| Rect::from_min_size(pos2(area.min.x, area.min.y + i as f32 * ROW_H), vec2(area.width(), ROW_H))).collect() +} + +#[derive(Clone, Copy, Debug)] +enum Gesture { + Move, + TrimIn, + TrimOut, +} + +fn drag_id() -> egui::Id { + egui::Id::new("caption-drag") +} + +/// Live drag state: (caption, gesture, delta ticks). +fn drag_state(ctx: &egui::Context) -> Option<(u64, u8, i64)> { + ctx.data(|d| d.get_temp::<(u64, u8, i64)>(drag_id())) +} + +fn block_rect(c: &filmcraft_project::Caption, row: Rect, layout: &Layout, drag: Option<(u64, u8, i64)>) -> Rect { + let (mut s, mut e) = (c.start, c.end()); + if let Some((id, g, d)) = drag.filter(|d| d.0 == c.id.0) { + let _ = id; + match g { + 0 => { + s += Tick(d); + e += Tick(d); + } + 1 => s = (s + Tick(d)).min(e), + _ => e = (e + Tick(d)).max(s), + } + } + Rect::from_min_max(pos2(layout.x_of(s), row.min.y + 2.0), pos2(layout.x_of(e).max(layout.x_of(s) + 1.0), row.max.y - 2.0)) +} + +/// Paint headers and caption blocks. +pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Rect, layout: &Layout, t: &Tokens) { + if seq.caption_tracks.is_empty() { + return; + } + let hw = app.ui.timeline.header_w; + let content = layout.content; + let painter = ui.painter().with_clip_rect(area); + let sel = app.session.state.caption_selection.clone(); + let drag = drag_state(ui.ctx()); + let mut actions: Vec<(String, Value)> = Vec::new(); + for (i, (tr, row)) in seq.caption_tracks.iter().zip(rows(area, seq.caption_tracks.len())).enumerate() { + let lane = Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y)); + painter.rect_filled(lane, 0.0, ROW_BG); + painter.line_segment([pos2(row.min.x, row.max.y - 0.5), pos2(row.max.x, row.max.y - 0.5)], Stroke::new(1.0, t.tl_bg)); + // header + let label = format!("C{}", i + 1); + let hrect = Rect::from_min_max(row.min, pos2(row.min.x + hw, row.max.y)); + painter.rect_filled(hrect, 0.0, t.tl_header_bg); + painter.line_segment([pos2(hrect.min.x, hrect.max.y - 0.5), pos2(hrect.max.x, hrect.max.y - 0.5)], Stroke::new(1.0, t.separator)); + let badge = Rect::from_min_size(pos2(hrect.min.x + 13.0, row.min.y + 3.0), vec2(24.0, ROW_H - 6.0)); + painter.rect_filled(badge, 3.0, if tr.enabled { FILL } else { t.field_bg }); + painter.text(badge.center(), Align2::CENTER_CENTER, &label, Tokens::semibold(10.5), Color32::WHITE); + app.auto.add(&format!("timeline.captionTrack.{label}"), badge, &tr.name); + let lock_r = Rect::from_center_size(pos2(hrect.min.x + 49.0, row.center().y), vec2(18.0, 18.0)); + let lresp = crate::widgets::icon_toggle( + ui, + lock_r, + if tr.locked { Icon::Lock } else { Icon::Unlock }, + true, + t, + egui::Id::new(("cap-lock", tr.id.0)), + Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }), + ); + app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock"); + if lresp.clicked() { + actions.push(("captions.setTrack".into(), json!({"track": tr.id.0, "locked": !tr.locked}))); + } + let eye_r = Rect::from_center_size(pos2(hrect.min.x + 73.0, row.center().y), vec2(18.0, 18.0)); + let eresp = crate::widgets::icon_toggle( + ui, + eye_r, + if tr.enabled { Icon::Eye } else { Icon::EyeOff }, + true, + t, + egui::Id::new(("cap-eye", tr.id.0)), + Some(t.text_dim), + ); + app.auto.add(&format!("timeline.captionTrack.{label}.enabled"), eye_r, "Toggle Caption Track Output"); + if eresp.clicked() { + actions.push(("captions.setTrack".into(), json!({"track": tr.id.0, "enabled": !tr.enabled}))); + } + let name = format!("{} · {}", tr.name, tr.format.label()); + painter.with_clip_rect(Rect::from_min_max(pos2(hrect.min.x + 86.0, row.min.y), pos2(hrect.max.x - 4.0, row.max.y))).text( + pos2(hrect.min.x + 90.0, row.center().y), + Align2::LEFT_CENTER, + name, + Tokens::ui(11.0), + t.text_dim, + ); + // caption blocks + let p = painter.with_clip_rect(lane); + let (v0, v1) = (layout.tick_at(content.min.x - 2.0), layout.tick_at(content.max.x + 2.0)); + for c in tr.captions.iter().filter(|c| c.end() >= v0 && c.start <= v1) { + let r = block_rect(c, row, layout, drag); + let selected = sel.contains(&c.id); + p.rect_filled(r, 3.0, if selected { FILL_SEL } else { FILL }); + if selected { + p.rect_stroke(r, 3.0, Stroke::new(1.0, t.clip_selected_border), StrokeKind::Inside); + } + if r.width() > 14.0 { + let text = filmcraft_project::plain_text(&c.text).replace('\n', " "); + p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)).text( + pos2(r.min.x + 5.0, r.center().y), + Align2::LEFT_CENTER, + text, + Tokens::ui(11.0), + if tr.enabled { Color32::WHITE } else { t.text_dim }, + ); + } + app.auto.add(&format!("timeline.caption.{}", c.id.0), r.intersect(lane), &c.text); + } + if tr.locked { + p.rect_filled(lane, 0.0, Color32::from_black_alpha(70)); + } + } + // column separator + painter.line_segment([pos2(area.min.x + hw - 0.5, area.min.y), pos2(area.min.x + hw - 0.5, area.max.y)], Stroke::new(1.0, t.separator)); + run(app, ui, actions); +} + +/// Pointer interaction on caption blocks (call after the timeline's own interaction so captions +/// are on top). +pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Rect, layout: &Layout) { + let ctx = ui.ctx().clone(); + let rate = seq.settings.frame_rate; + let drag = drag_state(&ctx); + let mut actions: Vec<(String, Value)> = Vec::new(); + for (tr, row) in seq.caption_tracks.iter().zip(rows(area, seq.caption_tracks.len())) { + let lane = Rect::from_min_max(pos2(layout.content.min.x, row.min.y), pos2(layout.content.max.x, row.max.y)); + for c in &tr.captions { + let r = block_rect(c, row, layout, drag).intersect(lane); + if r.width() <= 0.0 { + continue; + } + interact_block(app, ui, tr, c, r, rate, &mut actions); + } + } + run(app, ui, actions); +} + +fn interact_block( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + tr: &CaptionTrack, + c: &filmcraft_project::Caption, + r: Rect, + rate: filmcraft_time::FrameRate, + actions: &mut Vec<(String, Value)>, +) { + let ctx = ui.ctx().clone(); + let resp = ui.interact(r, egui::Id::new(("caption-block", c.id.0)), Sense::click_and_drag()); + let edge = 5.0f32.min(r.width() / 4.0); + let gesture_at = |x: f32| { + if x - r.min.x < edge { + Gesture::TrimIn + } else if r.max.x - x < edge { + Gesture::TrimOut + } else { + Gesture::Move + } + }; + if let Some(p) = resp.hover_pos() + && !tr.locked + { + ctx.set_cursor_icon(match gesture_at(p.x) { + Gesture::Move => CursorIcon::Default, + _ => CursorIcon::ResizeColumn, + }); + } + if resp.double_clicked() { + app.show_panel(PanelKind::Text); + app.ui.text_tab = "Captions".into(); + actions.push(("captions.goTo".into(), json!({"caption": c.id.0}))); + return; + } + if resp.clicked() { + let add = ctx.input(|i| i.modifiers.command || i.modifiers.shift); + actions.push(("captions.select".into(), json!({"captions": [c.id.0], "add": add}))); + } + if tr.locked { + return; + } + if resp.drag_started() + && let Some(p) = resp.interact_pointer_pos() + { + let g = match gesture_at(p.x) { + Gesture::Move => 0u8, + Gesture::TrimIn => 1, + Gesture::TrimOut => 2, + }; + ctx.data_mut(|d| d.insert_temp(egui::Id::new("caption-drag-origin"), p.x)); + ctx.data_mut(|d| d.insert_temp(drag_id(), (c.id.0, g, 0i64))); + if !app.session.state.caption_selection.contains(&c.id) { + actions.push(("captions.select".into(), json!({"captions": [c.id.0]}))); + } + } + if resp.dragged() + && let (Some((id, g, _)), Some(p)) = (drag_state(&ctx), resp.interact_pointer_pos()) + && id == c.id.0 + { + let x0 = ctx.data(|d| d.get_temp::(egui::Id::new("caption-drag-origin"))).unwrap_or(p.x); + let pps = app.ui.timeline.pps; + let d = rate.snap_nearest(Tick::from_seconds_f64((p.x - x0) as f64 / pps)); + ctx.data_mut(|dd| dd.insert_temp(drag_id(), (id, g, d.0))); + } + if resp.drag_stopped() + && let Some((id, g, d)) = drag_state(&ctx) + && id == c.id.0 + { + ctx.data_mut(|dd| dd.remove::<(u64, u8, i64)>(drag_id())); + if d != 0 { + match g { + 0 => { + let sel: Vec = if app.session.state.caption_selection.contains(&ClipId(id)) { + app.session.state.caption_selection.iter().map(|c| c.0).collect() + } else { + vec![id] + }; + actions.push(("captions.move".into(), json!({"captions": sel, "delta": d}))); + } + 1 => actions.push(("captions.trim".into(), json!({"caption": id, "edge": "in", "delta": d}))), + _ => actions.push(("captions.trim".into(), json!({"caption": id, "edge": "out", "delta": d}))), + } + } + } +} + +fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) { + let ctx = ui.ctx().clone(); + for (cmd, p) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &cmd, p) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8c214ff4..7e89c264 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -263,6 +263,19 @@ pub struct UiState { pub export_format: String, #[serde(default)] pub export_path: String, + /// Export mode: burn visible caption tracks into the video. + #[serde(default)] + pub export_burn_captions: bool, + /// Text panel: active tab ("Transcript" / "Captions" / "Graphics"). + #[serde(default = "captions_tab")] + pub text_tab: String, + /// Text panel: caption search filter. + #[serde(default)] + pub caption_search: String, +} + +fn captions_tab() -> String { + "Captions".into() } impl Default for UiState { @@ -289,6 +302,9 @@ impl Default for UiState { status: String::new(), export_format: "h264".into(), export_path: String::new(), + export_burn_captions: false, + text_tab: captions_tab(), + caption_search: String::new(), } } } diff --git a/docs/captions.md b/docs/captions.md new file mode 100644 index 00000000..0cac44d7 --- /dev/null +++ b/docs/captions.md @@ -0,0 +1,79 @@ +# Captions + +FilmCraft keeps captions on **caption tracks** in the sequence, shown in their own area above the +video tracks (as Premiere does). Caption tracks are saved in the `.fcproj` (project format +version 2; version 1 projects open with no caption tracks). + +## Model + +- **Caption track** (`CaptionTrack`, `crates/project/src/caption.rs`): name, format (`Subtitle`, + `CEA-608`, `CEA-708`, `Teletext`), language, output eye (shown in the Program monitor and + burned in on export), lock, sync lock, and a **track style**: + font size (pixels at 1080 lines, scaled to the frame), text colour, background box and its colour, + outline width and colour, alignment (left/centre/right), position (top/middle/bottom) and + margin, line spacing. +- **Caption**: in/out in exact `Tick`s, text (lines separated by `\n`; ``, ``, `` kept + as written), optional speaker, and the WebVTT cue id and cue settings, kept for round trips. + Captions on a track never overlap. + +## Files + +| Format | Import | Export | Notes | +|---|---|---|---| +| SubRip `.srt` | ✓ | ✓ | Forgiving reader (BOM, UTF-16, Windows-1252, CRLF, missing indexes, `,`/`.` ms, missing hours). | +| WebVTT `.vtt` | ✓ | ✓ | Keeps cue ids, cue settings, ``, STYLE/REGION/NOTE blocks. `line:N%` and `align:` are honoured when drawing. | +| Scenarist SCC `.scc` | ✓ | ✓ | CEA-608 at 29.97 fps. Reads pop-on, roll-up and paint-on; writes pop-on with drop-frame (default) or non-drop timecode. | + +`File ▸ Import` (or dropping a file) recognises caption files by extension and content and adds a +new caption track to the open sequence (a sequence is created when none is open). Cue times are +snapped to the sequence's frames. `File ▸ Export ▸ Captions…` writes the chosen caption track in +the format given by the file extension (`.srt`, `.vtt`, `.scc`). + +Details of each format and the SCC encoder are in [`crates/captions/README.md`](../crates/captions/README.md). + +## Editing + +| Command | Menu / shortcut | Params | +|---|---|---| +| `captions.newTrack` | Sequence ▸ Captions ▸ Add New Caption Track… (⌥⌘A) | `format`, `name`, `language` | +| `captions.add` | Sequence ▸ Captions ▸ Add Caption at Playhead (⌥⌘C) | `track`, `text`, `time`, `durationSeconds` (3) | +| `captions.next` / `captions.previous` | Sequence ▸ Captions ▸ Go to Next/Previous Caption Segment (⌥⌘↓/↑) | | +| `captions.showAll` / `captions.hideAll` | Sequence ▸ Captions | | +| `captions.split` | Text panel ✂ | `caption`, `time` (default: every caption under the playhead) | +| `captions.merge` | Text panel | `captions` (consecutive, one track) | +| `captions.setText` | Text panel text field | `caption`, `text`, `speaker` | +| `captions.setTimes` | Text panel in/out fields | `caption`, `start…`/`end…` (`Time` ticks, `Frame`, `Seconds`, `Timecode`) | +| `captions.trim` | drag a caption edge in the timeline | `caption`, `edge` (`in`/`out`), `delta` or `deltaFrames` | +| `captions.move` | drag a caption in the timeline | `captions`, `delta` or `deltaFrames` | +| `captions.delete` | Edit ▸ Clear / Ripple Delete with captions selected | `captions`, `ripple` | +| `captions.select`, `captions.goTo` | click / number button | `captions`, `add` / `caption` | +| `captions.setTrack` | caption track header (eye, lock) | `track`, `name`, `format`, `language`, `enabled`, `locked`, `syncLock` | +| `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` | +| `captions.deleteTrack` | | `track` | +| `captions.import` / `captions.export` | File ▸ Export ▸ Captions… | `path`, `format`, `track`, `dropFrame` | +| `captions.list` | (query) | `track` — tracks, styles and captions with timecodes | + +Tracks are addressed by id or `"C1"`, `"C2"`… (top first). Edits are limited by neighbouring +captions and a one-frame minimum. Sync-locked caption tracks follow insert and extract edits on +the media tracks (a caption across an insert point is split). `Add Edit to All Tracks` (⇧⌘K) +also splits captions. + +## Timeline and Text panel + +- Caption rows: header with `C1…` badge, lock, output eye, name and format; caption blocks in + violet showing their text. Click selects (⌘/⇧ adds), drag moves, edge drag trims, double-click + opens the Text panel. Automation ids: `timeline.caption.`, + `timeline.captionTrack.C1[.locked|.enabled]`. +- **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add / + split / merge / delete / export buttons, the list of segments with editable in/out timecodes + and text (committed when the field loses focus), and the track style strip (size, colour, box, + alignment, position). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`, + `text.captions..in|out|text|goto|row`, `text.captions.style.*`. + +## Burn-in + +The Program monitor shows the visible caption tracks. On export, `file.exportMedia` +`{"burnCaptions": true}` (Export mode: **Burn Captions Into Video**) draws them into every frame. +Captions are set in Inter SemiBold (OFL, bundled) with our own rasteriser; italics/bold markup is +not yet drawn differently, and text is not shaped (no kerning or complex scripts) until the M10.1 +text engine lands. Nested sequences do not burn in their own captions.