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