M10.4: caption tracks in the sequence model (project v2) + filmcraft-captions (SRT/WebVTT/SCC read+write, CEA-608 pop-on/roll-up/paint-on decoder, pop-on encoder with frame-exact load scheduling, burn-in layout + own rasteriser over skrifa outlines with Inter) + caption edit ops (add/split/merge/trim/move/delete, sync-locked insert/extract)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 23:42:06 -04:00
parent 5303af9112
commit 421af286dd
20 changed files with 3094 additions and 5 deletions

13
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"
@ -1392,6 +1404,7 @@ dependencies = [
name = "filmcraft-engine"
version = "0.1.0"
dependencies = [
"filmcraft-captions",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-edit",

View File

@ -30,6 +30,7 @@ filmcraft-codecs = { path = "crates/codecs" }
filmcraft-project = { path = "crates/project" }
filmcraft-edit = { path = "crates/edit" }
filmcraft-interchange = { path = "crates/interchange" }
filmcraft-captions = { path = "crates/captions" }
filmcraft-render = { path = "crates/render" }
filmcraft-gpu = { path = "crates/gpu" }
filmcraft-export = { path = "crates/export" }

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

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