Merge branch 'worktree-agent-a726528c34396e2d2'

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas 2026-10-01 00:07:18 -04:00
commit b0ddca7169
41 changed files with 4683 additions and 32 deletions

14
Cargo.lock generated
View File

@ -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",

View File

@ -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" }

View File

@ -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 |

View File

@ -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")]

View File

@ -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).

View File

@ -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 }

55
crates/captions/README.md Normal file
View File

@ -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 `<v Speaker>` 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.

324
crates/captions/src/burn.rs Normal file
View File

@ -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<f32>,
}
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<String> {
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<f32>, Option<CaptionAlign>) {
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::<f32>().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<Overlay> {
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<String> = 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<f32> = lines.iter().map(|l| font::measure(l, px)).collect();
let xs: Vec<f32> = 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<f32> {
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<Overlay> {
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<Overlay> {
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());
}
}

View File

@ -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<char> {
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<u8> },
}
fn basic_byte(c: char) -> Option<u8> {
(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<Encoded> {
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);
}
}

137
crates/captions/src/font.rs Normal file
View File

@ -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<'static>> {
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<HashMap<Key, Option<Arc<Glyph>>>> {
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<Glyph>>>>> = 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<Arc<Glyph>> {
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<Glyph> {
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<f32> = (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);
}
}

341
crates/captions/src/lib.rs Normal file
View File

@ -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, `<v Speaker>` 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<Format> {
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<T> = std::result::Result<T, Error>;
/// 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 (`<v Name>`).
pub speaker: Option<String>,
/// WebVTT cue identifier.
pub id: Option<String>,
/// WebVTT cue settings, verbatim.
pub settings: String,
}
/// A parsed caption file.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Document {
pub cues: Vec<Cue>,
/// WebVTT STYLE / REGION / NOTE blocks (verbatim, without trailing blank lines).
pub blocks: Vec<String>,
/// Non-fatal problems found while reading (skipped cues…).
pub warnings: Vec<String>,
}
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<Cue> = 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<Format> {
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<Document> {
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<u8> {
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<u16> =
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<Tick> {
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<i64> { 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:SS<sep>mmm` (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"<xml/>", 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)]);
}
}

View File

@ -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<f32>,
}
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}");
}
}

514
crates/captions/src/scc.rs Normal file
View File

@ -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<i64> {
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::<i64>().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<char>; 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::<String>().trim().to_string()).filter(|r| !r.is_empty()).collect::<Vec<_>>().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<Document> {
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<String> {
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<Encoded> = 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<u8> = None;
let flush = |pending: &mut Option<u8>, 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<i64, (u8, u8)> = BTreeMap::new();
let mut cursor = 0i64; // first frame this cue's load may use
let mut prev_end: Option<i64> = None;
let edm = (0x14, misc::EDM);
let eoc = (0x14, misc::EOC);
let place_free_from = |occ: &mut BTreeMap<i64, (u8, u8)>, 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<i64, (u8, u8)>, 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<String>)> = 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);
}
}

134
crates/captions/src/srt.rs Normal file
View File

@ -0,0 +1,134 @@
//! SubRip (`.srt`).
//!
//! ```text
//! 1
//! 00:00:01,000 --> 00:00:03,500
//! First line
//! <i>second line</i>
//!
//! 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<Document> {
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\n<i>World</i>\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\n<i>World</i>");
assert_eq!(write(&d), "1\n00:00:01,000 --> 00:00:02,500\nHello\n<i>World</i>\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);
}
}

186
crates/captions/src/vtt.rs Normal file
View File

@ -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
//! <v Ann>Hello there
//! ```
//!
//! Kept on read: cue identifiers, cue settings (verbatim), a leading `<v Speaker>` 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 `<v Name>` / `<v.class Name>` voice span off cue text.
pub fn split_voice(text: &str) -> (Option<String>, String) {
let t = text.trim_start();
if let Some(rest) = t.strip_prefix("<v")
&& (rest.starts_with(' ') || rest.starts_with('.') || rest.starts_with('\t'))
&& let Some(close) = rest.find('>')
{
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 </v> at the very end goes too; one in the middle means several voices: keep all
let body = match body.find("</v>") {
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("<v ") || body.contains("<v.") {
return (None, text.to_string());
}
return ((!name.is_empty()).then(|| name.to_string()), body);
}
(None, text.to_string())
}
pub fn parse(text: &str) -> Result<Document> {
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<&str>> = 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::<Vec<_>>().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!("<v {s}>"));
}
text.push_str(&c.text);
for l in text.lines().filter(|l| !l.trim().is_empty()) {
out.push_str(&l.replace("-->", "--&gt;"));
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\n<v.loud Ann Smith>Hello there\nsecond line\n\n00:00:04.000 --> 00:00:05.000\n<i>plain</i> &amp; 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, "<i>plain</i> &amp; 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\n<v Ann Smith>Hello there\n"));
assert_eq!(parse(&w).unwrap().cues, d.cues);
}
#[test]
fn voices() {
assert_eq!(split_voice("<v Bob>hi</v>"), (Some("Bob".into()), "hi".into()));
assert_eq!(split_voice("<v Bob>hi</v> <v Ann>yo</v>"), (None, "<v Bob>hi</v> <v Ann>yo</v>".into()));
assert_eq!(split_voice("<vx>hi"), (None, "<vx>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());
}
}

View File

@ -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<Value = String> {
proptest::collection::vec("[A-Za-z0-9éñü,.!?'\"()&%-]{1,10}", 1..6).prop_map(|w| w.join(" "))
}
fn text() -> impl Strategy<Value = String> {
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<Value = Vec<Cue>> {
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<Cue> = 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::<bool>(),
) {
let mut f = 0i64;
let cues: Vec<Cue> = 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<Cue> = 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::<u8>(), 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\n<i>General</i> 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);
}

394
crates/edit/src/captions.rs Normal file
View File

@ -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<TrackId> {
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<ClipId> {
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<ClipId> {
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<ClipId> {
let ids: Vec<ClipId> = 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<ClipId> {
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<usize> = 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<String> = 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<Option<&str>>) -> 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<Tick> {
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<Tick> {
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<usize> {
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<TimeRange> = 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<Tick> = |_| 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));
}
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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<Value>;
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<TrackId> {
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<ClipId> {
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<ClipId> {
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<u8> = 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<Format> {
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<Value> {
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<Value> {
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::<Vec<_>>(),
})
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
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<Tick> {
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<CommandSpec> {
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::<Vec<_>>()}))
}),
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<Option<String>> = 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<ClipId> =
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<ClipId> = 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::<Vec<_>>()}))
}),
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<Value> =
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::<Vec<_>>()}))
},
journal: false,
},
]
}
fn step(s: &mut Session, forward: bool) -> Result<Value> {
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<Value> {
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;

View File

@ -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<String> = 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());
}

View File

@ -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<String>) -> EngineError {
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> 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<f64> {
pub(crate) fn f64_p(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64)
}
fn bool_p(p: &Value, k: &str) -> Option<bool> {
pub(crate) fn bool_p(p: &Value, k: &str) -> Option<bool> {
p.get(k).and_then(Value::as_bool)
}
fn u64_p(p: &Value, k: &str) -> Option<u64> {
pub(crate) fn u64_p(p: &Value, k: &str) -> Option<u64> {
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<Tick> {
pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option<Tick> {
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<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
}),
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<CommandSpec> {
})?;
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<CommandSpec> {
}),
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<CommandSpec> {
}),
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<CommandSpec> {
}
} else {
s.state.selection = clips;
s.state.caption_selection.clear();
}
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
}),
@ -1695,6 +1721,7 @@ fn build() -> Vec<CommandSpec> {
json!({"undo": s.history.undo.iter().map(|h| &h.0).collect::<Vec<_>>(), "redo": s.history.redo.iter().map(|h| &h.0).collect::<Vec<_>>()})
)),
];
v.extend(crate::captions::commands());
// Labels as individual commands (Edit ▸ Label ▸ <name>)
for l in Label::ALL {
let _ = l;
@ -1718,6 +1745,7 @@ fn export_media(s: &mut Session, p: &Value) -> Result<Value> {
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 {

View File

@ -8,6 +8,7 @@
//! The project is an `Arc<Project>` 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<trim::EditPoint>,
/// Selected captions (caption tracks / Captions panel).
#[serde(default)]
pub caption_selection: Vec<ClipId>,
}
/// 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<filmcraft_render::Image> {
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))
}
}

View File

@ -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<Project>, 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<u8> {
let img = filmcraft_render::render_sequence(project, seq, rate.tick_of(f), opts, sources);
let mut rgba = img.over_black_rgba8();

View File

@ -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<u8> {
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}");
}

View File

@ -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(),
}
}

View File

@ -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 `<i>`/`<b>`/`<u>` 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<CaptionFormat> {
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<usize> {
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 `<v Name>` voice span).
#[serde(default)]
pub speaker: Option<String>,
/// WebVTT cue identifier, kept for round trips.
#[serde(default)]
pub cue_id: Option<String>,
/// 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<String> {
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<Caption>,
/// WebVTT header blocks (STYLE / REGION / NOTE) kept for round trips.
#[serde(default)]
pub vtt_blocks: Vec<String>,
}
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("<i>Hello</i> &amp; <b>bye</b>"), "Hello & bye");
assert_eq!(plain_text("{\\an8}Top"), "Top");
assert_eq!(plain_text("a < b"), "a < b");
assert_eq!(plain_text("<v Bob>Hi</v>"), "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));
}
}

View File

@ -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<EffectInstance>,
/// Caption tracks (drawn above the video tracks; first = top). Added in project version 2.
#[serde(default)]
pub caption_tracks: Vec<CaptionTrack>,
}
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<Track> {
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<Project, String> {
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\n<i>world</i>".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");

View File

@ -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]

View File

@ -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<filmcraft_captions::burn::Overlay> {
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,

View File

@ -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 }
}

View File

@ -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<Shared>, pool: Arc<MediaPool>, services: Arc<dyn Services>, 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<MediaPool>, services: &Arc<dyn Services>) ->
&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<MediaPool>, services: &Arc<dyn Services>) ->
&job.project,
seq,
job.time,
filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() },
filmcraft_render::RenderOptions { scale: job.scale, captions: true, ..Default::default() },
&provider,
)),
};

View File

@ -48,6 +48,8 @@ pub struct HostHooks {
pub pick_files: Option<Box<dyn FnMut(&[&str]) -> Vec<String>>>,
pub pick_save: Option<Box<dyn FnMut(&str) -> Option<String>>>,
pub pick_open_project: Option<Box<dyn FnMut() -> Option<String>>>,
/// Save dialog with a filter: (filter name, extensions, suggested file name) → path.
pub pick_save_as: Option<Box<dyn FnMut(&str, &[&str], &str) -> Option<String>>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -432,13 +434,14 @@ impl FilmcraftApp {
// ---------------------------------------------------------------- files
pub fn file_dialog(&mut self, id: &str) -> Result<Value, String> {
pub fn file_dialog(&mut self, id: &str, params: &Value) -> Result<Value, String> {
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}")),
}
}

View File

@ -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, &params);
}
let r = app.session.execute(id, params).map_err(|e| e.to_string());
if let Err(e) = &r {

View File

@ -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<serde_json::Value> = 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();
}

View File

@ -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())),
}
}

View File

@ -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::<usize>(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::<String>(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::<String>(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::<String>(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::<String>(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;
}
}
}

View File

@ -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);
}

View File

@ -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<Rect> {
(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::<f32>(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<u64> = 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;
}
}
}

View File

@ -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(),
}
}
}

79
docs/captions.md Normal file
View File

@ -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`; `<i>`, `<b>`, `<u>` 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, `<v Speaker>`, 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.<id>`,
`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.<id>.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.