filmcraft/crates/project/src/caption.rs

336 lines
11 KiB
Rust

//! 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 !crate::bounded_time_range(c.start, c.duration) {
return Err(format!("{}: caption {:?} is outside supported time bounds", self.name, c.id));
}
if c.duration.0 <= 0 {
return Err(format!("{}: caption {:?} has non-positive duration", self.name, c.id));
}
}
for w in self.captions.windows(2) {
if w[0].end() > w[1].start {
return Err(format!("{}: captions {:?} and {:?} overlap", self.name, w[0].id, w[1].id));
}
}
Ok(())
}
}
/// Strip `<…>` tags and decode the common HTML/WebVTT entities.
pub fn plain_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'<' => {
// a tag runs to the next '>' (if none, keep the '<' literally)
let rest: String = chars.clone().collect();
if let Some(end) = rest.find('>') {
for _ in 0..rest[..=end].chars().count() {
chars.next();
}
} else {
out.push('<');
}
}
'{' if chars.peek() == Some(&'\\') => {
// SSA-style override blocks some SRT files carry: {\an8}
let rest: String = chars.clone().collect();
if let Some(end) = rest.find('}') {
for _ in 0..rest[..=end].chars().count() {
chars.next();
}
} else {
out.push('{');
}
}
'&' => {
let rest: String = chars.clone().take(8).collect();
let ents = [
("amp;", '&'),
("lt;", '<'),
("gt;", '>'),
("nbsp;", '\u{a0}'),
("quot;", '"'),
("apos;", '\''),
("lrm;", '\u{200e}'),
("rlm;", '\u{200f}'),
];
if let Some((name, ch)) = ents.iter().find(|(n, _)| rest.starts_with(n)) {
for _ in 0..name.len() {
chars.next();
}
out.push(*ch);
} else {
out.push('&');
}
}
'\r' => {}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain() {
assert_eq!(plain_text("<i>Hello</i> &amp; <b>bye</b>"), "Hello & bye");
assert_eq!(plain_text("{\\an8}Top"), "Top");
assert_eq!(plain_text("a < b"), "a < b");
assert_eq!(plain_text("<v Bob>Hi</v>"), "Hi");
}
#[test]
fn caption_at() {
let mut t = CaptionTrack::new(TrackId(1), "Subtitle".into(), CaptionFormat::Subtitle);
for (i, s) in [0i64, 100, 300].iter().enumerate() {
t.captions.push(Caption {
id: ClipId(i as u64),
start: Tick(*s),
duration: Tick(50),
text: format!("c{i}"),
speaker: None,
cue_id: None,
settings: String::new(),
});
}
assert_eq!(t.caption_at(Tick(120)).unwrap().text, "c1");
assert!(t.caption_at(Tick(160)).is_none());
assert!(t.check().is_ok());
assert_eq!(CaptionFormat::from_name("cea608"), Some(CaptionFormat::Cea608));
assert_eq!(CaptionFormat::from_name("CEA-708"), Some(CaptionFormat::Cea708));
}
#[test]
fn corrupt_caption_times_do_not_overflow_the_overlap_check() {
let mut track = CaptionTrack::new(TrackId(1), "Subtitles".into(), CaptionFormat::Subtitle);
for (id, start) in [(1, i64::MAX), (2, 10)] {
track.captions.push(Caption {
id: ClipId(id),
start: Tick(start),
duration: Tick(50),
text: String::new(),
speaker: None,
cue_id: None,
settings: String::new(),
});
}
let result = std::panic::catch_unwind(|| track.check());
assert!(result.is_ok());
assert!(result.unwrap().is_err());
}
}