Files
filmcraft/crates/engine/src/transcript.rs
T
Brandon ThomasandClaude Opus 5.5 a69148d5e7 M12.5: Settings dialog with every Premiere category, wired to real behaviour
One Preferences window (app menu ▸ Settings ▸ <category>, Edit ▸ Preferences in-window, General =
Cmd+,): category list, schema-driven pages, Help / Reset… / Cancel / OK. Settings live in the
per-user preferences.json (version 2, v1 migrated, bad values repaired), reachable via
prefs.get/set/reset{category} and the new prefs.schema; app.settings.<category> opens a page;
every control has a settings.<key> automation id.

Wired: still/transition default durations, auto-scroll, snap playhead, return to beginning, play
after rendering, step many, selection-tool roll/ripple, theme/highlight/contrast, tooltips, at
startup + recent projects, label names/colours/defaults, indeterminate timebase, default media
scaling, media cache location/policy/clean, frame cache budget, cpal device/buffer/rate/output
mapping, transcription defaults + auto-transcribe on import, text smart quotes/ligatures/font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:10:46 -04:00

481 lines
20 KiB
Rust

//! Text-based editing commands (`transcript.*`): the Text panel ▸ Transcript tab.
//!
//! Transcripts belong to media items (`Project::transcripts`, media time); the sequence transcript
//! is derived from them ([`filmcraft_edit::transcript::sequence_words`]). Words of the sequence
//! transcript are addressed by index (`from`, `to`, inclusive), as `transcript.inspect` lists them.
//!
//! Speech recognition goes through a [`Transcriber`]: [`Session::transcriber`] when a host or a
//! test installed one, else the Whisper model named by `model` from `<data dir>/models` (needs the
//! engine feature `whisper`; without it `transcript.generate` fails with a clear error, and agents
//! can still bring their own transcript with `transcript.set`).
use std::sync::Arc;
use serde_json::{Value, json};
use filmcraft_edit as edit;
use filmcraft_edit::transcript::{self as tx, CaptionRules, SeqWord};
use filmcraft_project::{CaptionFormat, CaptionTrack, ItemId, ItemKind, TrackId, Transcript};
use filmcraft_speech::{Options, SpeechError, Transcriber};
use filmcraft_time::{TICKS_PER_SECOND, Tick, TimeRange};
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_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], params: &'static str, enabled: Enabled, run: Run, journal: bool) -> CommandSpec {
CommandSpec { id, label, menu, shortcut: None, params, enabled, run, journal }
}
/// Where downloaded speech models live (`<data dir>/models`).
pub fn models_dir() -> Option<std::path::PathBuf> {
crate::autosave::default_data_dir().map(|d| d.join("models"))
}
/// Whether this build can transcribe with Whisper (feature `whisper`).
pub fn speech_available() -> bool {
filmcraft_speech::available()
}
/// The words of the active sequence's transcript.
pub fn sequence_words(s: &Session) -> Vec<SeqWord> {
match s.active_sequence() {
Some(q) => tx::sequence_words(q, &s.project.transcripts),
None => Vec::new(),
}
}
fn has_transcript(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if sequence_words(s).is_empty() { Err("the sequence has no transcript (Transcribe first)".into()) } else { Ok(()) }
}
fn has_transcripts(s: &Session) -> std::result::Result<(), String> {
if s.project.transcripts.is_empty() { Err("there are no transcripts".into()) } else { Ok(()) }
}
/// The media item behind a project item (subclips resolve to their parent).
fn media_item(s: &Session, item: ItemId) -> Option<ItemId> {
match &s.project.item(item)?.kind {
ItemKind::Media(_) => Some(item),
ItemKind::Subclip { parent, .. } => media_item(s, *parent),
_ => None,
}
}
fn ids_p(p: &Value, k: &str) -> Option<Vec<ItemId>> {
p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect())
}
/// Items to transcribe: `items` / `item`, else the Project panel selection, else the media of the
/// active sequence's enabled audio clips. Subclips resolve to their media; duplicates are removed.
fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
let mut raw = ids_p(p, "items").or_else(|| u64_p(p, "item").map(|i| vec![ItemId(i)])).unwrap_or_default();
if raw.is_empty() {
raw = s.state.project_selection.clone();
}
if raw.is_empty()
&& let Some(q) = s.active_sequence()
{
raw = q.audio_tracks.iter().flat_map(|t| t.items.iter()).filter(|it| it.enabled).map(|it| it.item).collect();
}
let mut out = Vec::new();
for i in raw {
if let Some(m) = media_item(s, i)
&& !out.contains(&m)
{
out.push(m);
}
}
out
}
/// Mono 16 kHz audio of a media item (None: no audio).
fn item_audio(s: &Session, item: ItemId) -> Option<Vec<f32>> {
let dur = match &s.project.item(item)?.kind {
ItemKind::Media(m) => m.duration(),
_ => return None,
};
let src = s.source(item)?;
if !src.info().has_audio() {
return None;
}
let sr = filmcraft_speech::SAMPLE_RATE;
let len = dur.to_units_floor(sr as i64).max(0) as usize;
let buf = src.audio(0, len, sr).ok()?;
Some(filmcraft_speech::downmix(&buf.channels))
}
fn speech_err(e: SpeechError) -> EngineError {
EngineError::Other(e.to_string())
}
/// The transcriber to use: the installed one, else the named catalogue model.
fn transcriber(s: &Session, p: &Value) -> Result<Arc<dyn Transcriber>> {
if let Some(t) = &s.transcriber {
return Ok(t.clone());
}
// Settings ▸ Media Analysis & Transcription ▸ Speech model
let model = str_p(p, "model").unwrap_or(&s.prefs.media_analysis.whisper_model);
if filmcraft_speech::models::find(model).is_none() {
return Err(speech_err(SpeechError::UnknownModel(model.into())));
}
if !filmcraft_speech::available() {
return Err(EngineError::Other(
"speech-to-text is not available in this build (built without the `whisper` feature); import a transcript with transcript.set instead".into(),
));
}
let dir = models_dir().ok_or_else(|| EngineError::Other("no data directory for speech models".into()))?;
filmcraft_speech::load(&dir, model).map_err(speech_err)
}
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
let items = targets(s, p);
if items.is_empty() {
return Err(bad("transcript.generate", "nothing to transcribe (pass `items`, select clips, or open a sequence with audio)"));
}
let t = transcriber(s, p)?;
// Settings ▸ Media Analysis & Transcription: language (or auto-detect) and speaker labelling
let ma = &s.prefs.media_analysis;
let default_language = if ma.language_auto_detect { None } else { Some(ma.default_language.clone()) };
let opts = Options {
language: match str_p(p, "language") {
Some(l) => Some(l).filter(|l| !l.is_empty() && *l != "auto").map(str::to_string),
None => default_language,
},
diarize: bool_p(p, "diarize").unwrap_or(ma.speaker_labeling != "off"),
max_speakers: u64_p(p, "maxSpeakers").map(|n| n.clamp(1, 32) as usize).unwrap_or(Options::default().max_speakers),
};
let mut done: Vec<(ItemId, Transcript)> = Vec::new();
let mut skipped = Vec::new();
for item in items {
let Some(audio) = item_audio(s, item) else {
skipped.push(item.0);
continue;
};
let mut tr = t.transcribe(&audio, &opts, &mut |_, _| true).map_err(speech_err)?;
tr.normalize();
done.push((item, tr));
}
if done.is_empty() {
return Err(EngineError::Other("none of the clips has audio to transcribe".into()));
}
let report: Vec<Value> = done
.iter()
.map(|(i, t)| json!({"item": i.0, "words": t.words.len(), "speakers": t.speakers.len(), "language": t.language, "source": t.source}))
.collect();
s.edit("Transcribe", move |pr, _| {
for (i, t) in done {
pr.transcripts.insert(i, Arc::new(t));
}
Ok(())
})?;
Ok(json!({"items": report, "skipped": skipped}))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let item = u64_p(p, "item").map(ItemId).ok_or_else(|| bad("transcript.set", "`item` is required"))?;
let item = media_item(s, item).ok_or_else(|| bad("transcript.set", "no such media item"))?;
let v = p.get("transcript").cloned().ok_or_else(|| bad("transcript.set", "`transcript` is required"))?;
let mut t: Transcript = serde_json::from_value(v).map_err(|e| bad("transcript.set", e.to_string()))?;
if t.source.is_empty() {
t.source = "imported".into();
}
t.normalize();
t.check().map_err(|e| bad("transcript.set", e))?;
let n = t.words.len();
s.edit("Set Transcript", move |pr, _| {
pr.transcripts.insert(item, Arc::new(t));
Ok(())
})?;
Ok(json!({"item": item.0, "words": n}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let items: Vec<ItemId> = match ids_p(p, "items").or_else(|| u64_p(p, "item").map(|i| vec![ItemId(i)])) {
Some(v) => v.into_iter().filter_map(|i| media_item(s, i)).collect(),
None => s.project.transcripts.keys().copied().collect(),
};
let n = items.iter().filter(|i| s.project.transcripts.contains_key(i)).count();
if n == 0 {
return Err(EngineError::Other("no transcript to delete".into()));
}
s.edit("Delete Transcript", move |pr, _| {
for i in items {
pr.transcripts.remove(&i);
}
Ok(())
})?;
Ok(json!({"deleted": n}))
}
fn word_json(i: usize, w: &SeqWord) -> Value {
json!({"i": i, "text": w.text, "start": w.start.0, "end": w.end.0, "speaker": w.speaker, "clip": w.clip.0, "item": w.item.0, "confidence": w.confidence})
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let words = sequence_words(s);
let gap = Tick::from_seconds_f64(f64_p(p, "paragraphGapSeconds").unwrap_or(1.5));
let paras: Vec<Value> = tx::paragraphs(&words, gap)
.into_iter()
.map(|r| {
let text = words[r.clone()].iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" ");
json!({"from": r.start, "to": r.end - 1, "speaker": words[r.start].speaker, "start": words[r.start].start.0, "end": words[r.end - 1].end.0, "text": text})
})
.collect();
let speakers: Vec<String> = {
let mut v: Vec<String> = Vec::new();
for w in &words {
if let Some(n) = &w.speaker
&& !v.contains(n)
{
v.push(n.clone());
}
}
v
};
let current = tx::word_at(&words, s.playhead());
Ok(json!({
"words": words.iter().enumerate().map(|(i, w)| word_json(i, w)).collect::<Vec<_>>(),
"paragraphs": paras,
"speakers": speakers,
"current": current,
"items": s.project.transcripts.iter().map(|(i, t)| json!({"item": i.0, "words": t.words.len(), "language": t.language, "source": t.source, "speakers": t.speakers.iter().map(|k| &k.name).collect::<Vec<_>>()})).collect::<Vec<_>>(),
}))
}
fn search(s: &mut Session, p: &Value) -> Result<Value> {
let q = str_p(p, "query").ok_or_else(|| bad("transcript.search", "`query` is required"))?;
let words = sequence_words(s);
let hits: Vec<Value> = tx::search(&words, q)
.into_iter()
.map(|r| json!({"from": r.start, "to": r.end - 1, "start": words[r.start].start.0, "end": words[r.end - 1].end.0}))
.collect();
Ok(json!({"matches": hits}))
}
/// Timeline range of the words `from..=to` (frame-snapped outward).
fn range_p(s: &Session, p: &Value, cmd: &str) -> Result<TimeRange> {
let words = sequence_words(s);
let from = u64_p(p, "from").ok_or_else(|| bad(cmd, "`from` (word index) is required"))? as usize;
let to = u64_p(p, "to").map(|n| n as usize).unwrap_or(from);
tx::word_range(&words, from, to, s.sequence_rate()).ok_or_else(|| bad(cmd, format!("word index out of range (the transcript has {} words)", words.len())))
}
fn range_json(r: TimeRange) -> Value {
json!({"start": r.start.0, "end": r.end().0})
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let r = range_p(s, p, "transcript.select")?;
let fd = s.sequence_rate().frame_duration();
s.edit_sequence("Mark Transcript Selection", |q, _, _| {
q.mark_in = Some(r.start);
q.mark_out = Some(r.end() - fd);
Ok(())
})?;
s.set_playhead(r.start);
Ok(range_json(r))
}
fn extract_or_lift(s: &mut Session, p: &Value, extract: bool) -> Result<Value> {
let cmd = if extract { "transcript.extract" } else { "transcript.lift" };
let r = range_p(s, p, cmd)?;
let tg = s.targeting().targeted;
s.edit_sequence(if extract { "Extract Text" } else { "Lift Text" }, |q, ctx, _| {
if extract {
edit::extract(q, &tg, r, ctx);
} else {
edit::lift(q, &tg, r, ctx);
}
q.mark_in = None;
q.mark_out = None;
Ok(())
})?;
s.set_playhead(r.start);
Ok(range_json(r))
}
fn rename_speaker(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("transcript.renameSpeaker", "`name` is required"))?.to_string();
let item = u64_p(p, "item").map(ItemId).and_then(|i| media_item(s, i));
// `speaker`: the current name (every transcript), or an index (needs `item`)
let (old_name, index) = match p.get("speaker") {
Some(Value::String(n)) => (Some(n.clone()), None),
Some(v) if v.is_u64() => (None, v.as_u64().map(|n| n as usize)),
_ => return Err(bad("transcript.renameSpeaker", "`speaker` (name, or index with `item`) is required")),
};
if index.is_some() && item.is_none() {
return Err(bad("transcript.renameSpeaker", "a speaker index needs `item`"));
}
let mut n = 0;
let mut next = s.project.transcripts.clone();
for (i, t) in next.iter_mut() {
if item.is_some_and(|x| x != *i) {
continue;
}
let tt = Arc::make_mut(t);
for (k, sp) in tt.speakers.iter_mut().enumerate() {
if old_name.as_ref().is_some_and(|o| *o == sp.name) || index == Some(k) {
sp.name = name.clone();
n += 1;
}
}
}
if n == 0 {
return Err(EngineError::Other("no such speaker".into()));
}
s.edit("Rename Speaker", move |pr, _| {
pr.transcripts = next;
Ok(())
})?;
Ok(json!({"renamed": n}))
}
fn remove_ranges(s: &mut Session, label: &str, ranges: Vec<TimeRange>) -> Result<Value> {
let n = ranges.len();
if n == 0 {
return Ok(json!({"removed": 0, "ticks": 0}));
}
let total = s.edit_sequence(label, |q, ctx, _| Ok(tx::ripple_delete_ranges(q, ranges, ctx)))?;
Ok(json!({"removed": n, "ticks": total.0, "seconds": total.0 as f64 / TICKS_PER_SECOND as f64}))
}
fn remove_pauses(s: &mut Session, p: &Value) -> Result<Value> {
let words = sequence_words(s);
let min = Tick::from_seconds_f64(f64_p(p, "minSeconds").unwrap_or(1.0));
let keep = Tick::from_seconds_f64(f64_p(p, "keepSeconds").unwrap_or(0.15));
let ranges = tx::find_pauses(&words, min, keep, s.sequence_rate());
remove_ranges(s, "Remove Pauses", ranges)
}
fn remove_fillers(s: &mut Session, p: &Value) -> Result<Value> {
let words = sequence_words(s);
let fillers: Vec<String> = match p.get("fillers").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => tx::DEFAULT_FILLERS.iter().map(|f| f.to_string()).collect(),
};
let hits = tx::find_fillers(&words, &fillers);
let ranges = tx::filler_ranges(&words, &hits, s.sequence_rate());
remove_ranges(s, "Remove Filler Words", ranges)
}
fn create_captions(s: &mut Session, p: &Value) -> Result<Value> {
let words = sequence_words(s);
let d = CaptionRules::default();
let rules = CaptionRules {
max_chars: u64_p(p, "maxChars").map(|n| n as usize).unwrap_or(d.max_chars),
lines: u64_p(p, "lines").map(|n| n as usize).unwrap_or(d.lines),
min_duration: f64_p(p, "minSeconds").map(Tick::from_seconds_f64).unwrap_or(d.min_duration),
max_duration: f64_p(p, "maxSeconds").map(Tick::from_seconds_f64).unwrap_or(d.max_duration),
gap_frames: p.get("gapFrames").and_then(Value::as_i64).unwrap_or(d.gap_frames),
break_pause: d.break_pause,
};
let blocks = tx::caption_blocks(&words, &rules, s.sequence_rate());
let format = str_p(p, "format").and_then(CaptionFormat::from_name).unwrap_or_default();
let name = str_p(p, "name").unwrap_or("Transcript").to_string();
let n = blocks.len();
let tid = s.edit_sequence("Create Captions", |q, ctx, st| {
let tid = TrackId(ctx.alloc());
let mut t = CaptionTrack::new(tid, name, format);
t.captions = tx::blocks_to_captions(&blocks, ctx);
q.caption_tracks.insert(0, t);
st.caption_selection.clear();
Ok(tid)
})?;
Ok(json!({"track": tid.0, "captions": n}))
}
fn models(_: &mut Session, _: &Value) -> Result<Value> {
let dir = models_dir();
Ok(json!({
"available": filmcraft_speech::available(),
"default": filmcraft_speech::models::DEFAULT_MODEL,
"dir": dir.as_ref().map(|d| d.to_string_lossy().to_string()),
"models": filmcraft_speech::models::catalogue().iter().map(|m| json!({
"id": m.id, "name": m.name, "multilingual": m.multilingual, "description": m.description,
"license": m.license, "source": m.source, "size": m.size(),
"installed": dir.as_ref().is_some_and(|d| filmcraft_speech::models::installed(d, m)),
})).collect::<Vec<_>>(),
}))
}
/// Download a catalogue model into `<data dir>/models` (feature `speech-download`). Hosts show
/// the size, source and licence (`transcript.models`) and ask before running this.
fn download_model(_: &mut Session, p: &Value) -> Result<Value> {
let id = str_p(p, "model").unwrap_or(filmcraft_speech::models::DEFAULT_MODEL);
let m = filmcraft_speech::models::find(id).ok_or_else(|| speech_err(SpeechError::UnknownModel(id.into())))?;
let dir = models_dir().ok_or_else(|| EngineError::Other("no data directory for speech models".into()))?;
#[cfg(feature = "speech-download")]
{
filmcraft_speech::models::download(&dir, m, &mut |_, _, _| true).map_err(speech_err)?;
Ok(json!({"model": m.id, "dir": filmcraft_speech::models::model_dir(&dir, m).to_string_lossy()}))
}
#[cfg(not(feature = "speech-download"))]
{
let _ = (m, dir);
Err(EngineError::Other("model downloads are not available in this build (built without the `speech-download` feature)".into()))
}
}
pub fn commands() -> Vec<CommandSpec> {
vec![
spec(
"transcript.generate",
"Transcribe…",
&["Sequence", "Transcript"],
r#"{"items":[id]?,"model":"whisper-base"?,"language":"en|auto"?,"diarize":bool?,"maxSpeakers":n?}"#,
always,
generate,
true,
),
spec(
"transcript.set",
"Import Transcript",
&[],
r#"{"item":id,"transcript":{"language":str,"speakers":[{"name":str}],"words":[{"text":str,"start":tick,"end":tick,"speaker":n?}]}}"#,
always,
set,
true,
),
spec("transcript.delete", "Delete Transcript", &["Sequence", "Transcript"], r#"{"items":[id]?}"#, has_transcripts, delete, true),
spec("transcript.inspect", "Inspect Transcript", &[], r#"{"paragraphGapSeconds":f?}"#, always, inspect, false),
spec("transcript.search", "Search Transcript", &[], r#"{"query":str}"#, always, search, false),
spec("transcript.models", "List Speech Models", &[], "{}", always, models, false),
spec("transcript.downloadModel", "Download Speech Model", &[], r#"{"model":"whisper-base"?}"#, always, download_model, true),
spec("transcript.select", "Mark Selected Text", &[], r#"{"from":word,"to":word?}"#, has_transcript, select, true),
spec("transcript.extract", "Extract Selected Text", &[], r#"{"from":word,"to":word?}"#, has_transcript, |s, p| extract_or_lift(s, p, true), true),
spec("transcript.lift", "Lift Selected Text", &[], r#"{"from":word,"to":word?}"#, has_transcript, |s, p| extract_or_lift(s, p, false), true),
spec(
"transcript.renameSpeaker",
"Rename Speaker…",
&[],
r#"{"speaker":"Speaker 1"|index,"name":str,"item":id?}"#,
has_transcripts,
rename_speaker,
true,
),
spec(
"transcript.removePauses",
"Remove Pauses",
&["Sequence", "Transcript"],
r#"{"minSeconds":f?,"keepSeconds":f?}"#,
has_transcript,
remove_pauses,
true,
),
spec("transcript.removeFillers", "Remove Filler Words", &["Sequence", "Transcript"], r#"{"fillers":[str]?}"#, has_transcript, remove_fillers, true),
spec(
"transcript.createCaptions",
"Create Captions from Transcript…",
&["Sequence", "Transcript"],
r#"{"maxChars":n?,"lines":1|2?,"minSeconds":f?,"maxSeconds":f?,"gapFrames":n?,"format":str?,"name":str?}"#,
has_transcript,
create_captions,
true,
),
]
}