Files
filmcraft/crates/engine/src/captions.rs
T

564 lines
28 KiB
Rust

//! 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` / `.mcc` /
/// `.stl` / `.ttml` / `.dfxp` / `.xml`, 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" | "mcc" | "stl" | "ttml" | "dfxp" | "xml")) {
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, rate: Some(seq.settings.frame_rate) });
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":0..0.45 (fraction of frame height)?,"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",
format!(
"no such caption track ({}) — pass the track id from captions.list, or \"C1\"…",
p.get("track").map(|v| v.to_string()).unwrap_or_else(|| "no `track` given".into())
),
)
})?;
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|mcc|stl|ttml|dfxp"? (default: from the extension),"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": []})) };
// a `track` that names no caption track is an error, never every track
let only = match p.get("track") {
Some(_) => Some(track_param(s, p).ok_or_else(|| bad("captions.list", "no such caption track"))?),
None => None,
};
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;