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:
parent
5303af9112
commit
421af286dd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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("-->", "-->"));
|
||||
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> & 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> & 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> & <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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue