transcripts: disabled, not failing, in builds without speech-to-text (#89, #97, #98)

- transcript.generate is enabled only with a recogniser: one installed in
  Session::transcriber, or the build's own (feature `whisper`). Otherwise
  describe, command_list enabled_only and the menu report it disabled with
  "speech-to-text is not available in this build" as the reason (#97).
  Transcribe Sequence, which runs it, follows.
- transcript.downloadModel is enabled only with `speech-download` (#98).
- file.import with Automatically transcribe clips on no longer skips
  transcription silently when it can't run: the disabled command's reason
  lands in the import's errors as "transcription: ..." (#89).
- docs/transcripts.md describes the disabled state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
carlitosteso3andClaude Opus 5.5 committed 2026-10-07 09:14:05 -05:00
1 parent e975621bbd
commit b8fa786cad
6 files changed
+86 -22

No files matched your search

+4 -6
View File
@@ -826,13 +826,11 @@ fn build() -> Vec<CommandSpec> {
// Project Settings ▸ Ingest: copy / transcode / create proxies
// (image sequences are many files: ingest copies and transcodes single files only)
let item_ids: Vec<ItemId> = ids.iter().filter(|i| !image_sequences.iter().any(|q| q["item"].as_u64() == Some(**i))).map(|i| ItemId(*i)).collect();
// Settings ▸ Media Analysis & Transcription ▸ Automatically transcribe clips
// Settings ▸ Media Analysis & Transcription ▸ Automatically transcribe clips. In a build
// without speech-to-text `transcript.generate` is disabled and its reason lands in
// `errors`, so the enabled setting doesn't silently do nothing (#89).
let ma = &s.prefs.media_analysis;
if ma.auto_transcribe
&& ma.auto_transcribe_scope == "allImported"
&& !ids.is_empty()
&& (s.transcriber.is_some() || crate::transcript::speech_available())
{
if ma.auto_transcribe && ma.auto_transcribe_scope == "allImported" && !ids.is_empty() {
let audio: Vec<u64> = ids.iter().copied().filter(|i| s.project.item(ItemId(*i)).is_some_and(|it| it.has_audio())).collect();
if !audio.is_empty()
&& let Err(e) = s.execute("transcript.generate", json!({"items": audio}))
+8 -1
View File
@@ -57,7 +57,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
&["Sequence"],
None,
r#"{"track":"mix"|"A1"|id?,"language":"en|auto"?,"diarize":bool?,"maxSpeakers":n?,"model":str?}"#,
has_audio_clips,
can_transcribe_sequence,
transcribe,
),
spec(
@@ -105,6 +105,13 @@ fn has_audio_clips(s: &Session) -> std::result::Result<(), String> {
if q.audio_tracks.iter().any(|t| !t.items.is_empty()) { Ok(()) } else { Err("the sequence has no audio clips".into()) }
}
/// Transcribe Sequence runs `transcript.generate`: disabled like it in a build without
/// speech-to-text (#97), and when the sequence has no sound.
fn can_transcribe_sequence(s: &Session) -> std::result::Result<(), String> {
crate::transcript::can_transcribe(s)?;
has_audio_clips(s)
}
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()) }
+24
View File
@@ -481,6 +481,30 @@ fn auto_transcribe_on_import_and_transcription_defaults() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #89: with auto-transcribe on in a build without speech-to-text (and no recogniser installed),
/// importing a clip with sound reported `"errors": []` and transcribed nothing; the import now says
/// why. With the setting off the import stays error-free.
#[test]
fn auto_transcribe_without_speech_to_text_reports_it_on_import() {
if filmcraft_speech::available() {
return;
}
let dir = tmp_dir("prefs-transcribe-off");
let mov = dir.join("talk.mov");
crate::media_test_util::make_movie(&mov, filmcraft_media::DemoScene::OceanSunset, 64, 36, 24);
let mut s = Session::default();
let r = s.execute("file.import", json!({"paths": [mov.to_string_lossy()]})).unwrap();
assert_eq!(r["errors"], json!([]));
set(&mut s, "mediaAnalysis.autoTranscribe", json!(true));
set(&mut s, "mediaAnalysis.autoTranscribeScope", json!("allImported"));
let r = s.execute("file.import", json!({"paths": [mov.to_string_lossy()]})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 1, "the clip is still imported");
let errors = r["errors"].as_array().unwrap();
assert!(errors.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with("transcription:") && e.contains("whisper"))), "{errors:?}");
assert!(s.project.transcripts.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hardware_decoding_setting_drives_the_decoder_switch() {
let mut s = Session::default();
+21 -5
View File
@@ -52,6 +52,24 @@ fn has_transcript(s: &Session) -> std::result::Result<(), String> {
if sequence_words(s).is_empty() { Err("the sequence has no transcript (Transcribe first)".into()) } else { Ok(()) }
}
const NO_SPEECH: &str = "speech-to-text is not available in this build (built without the `whisper` feature); import a transcript with transcript.set instead";
/// `transcript.generate` needs a recogniser: one installed in [`Session::transcriber`], or this
/// build's own (feature `whisper`). Without either the command is disabled, so `describe`,
/// `command_list {"enabled_only": true}` and the menu don't offer it (#97).
pub(crate) fn can_transcribe(s: &Session) -> std::result::Result<(), String> {
if s.transcriber.is_some() || speech_available() { Ok(()) } else { Err(NO_SPEECH.into()) }
}
/// `transcript.downloadModel` is compiled in only with the `speech-download` feature (#98).
fn can_download(_: &Session) -> std::result::Result<(), String> {
if cfg!(feature = "speech-download") {
Ok(())
} else {
Err("model downloads are not available in this build (built without the `speech-download` feature)".into())
}
}
fn has_transcripts(s: &Session) -> std::result::Result<(), String> {
if s.project.transcripts.is_empty() { Err("there are no transcripts".into()) } else { Ok(()) }
}
@@ -123,9 +141,7 @@ fn transcriber(s: &Session, p: &Value) -> Result<Arc<dyn Transcriber>> {
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(),
));
return Err(EngineError::Other(NO_SPEECH.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)
@@ -427,7 +443,7 @@ pub fn commands() -> Vec<CommandSpec> {
"Transcribe…",
&["Sequence", "Transcript"],
r#"{"items":[id]?,"model":"whisper-base"?,"language":"en|auto"?,"diarize":bool?,"maxSpeakers":n?}"#,
always,
can_transcribe,
generate,
true,
),
@@ -444,7 +460,7 @@ pub fn commands() -> Vec<CommandSpec> {
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.downloadModel", "Download Speech Model", &[], r#"{"model":"whisper-base"?}"#, can_download, 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),
+22 -7
View File
@@ -162,16 +162,31 @@ fn set_delete_and_models() {
fn generate_without_a_transcriber() {
let (mut s, item, _) = session();
s.transcriber = None;
let e = s.execute("transcript.generate", json!({"items": [item.0], "model": "nope"})).unwrap_err().to_string();
assert!(e.contains("unknown speech model"), "{e}");
if !filmcraft_speech::available() {
if filmcraft_speech::available() {
let e = s.execute("transcript.generate", json!({"items": [item.0], "model": "nope"})).unwrap_err().to_string();
assert!(e.contains("unknown speech model"), "{e}");
} else {
// no recogniser at all: the command is disabled, with the build limitation as its reason
assert!(!s.is_enabled("transcript.generate"));
assert!(!s.is_enabled("sequence.transcribe"), "Transcribe Sequence follows transcript.generate");
let e = s.execute("transcript.generate", json!({"items": [item.0]})).unwrap_err().to_string();
assert!(e.contains("whisper") && e.contains("not available"), "{e}");
#[cfg(not(feature = "speech-download"))]
assert!(s.execute("transcript.downloadModel", json!({})).unwrap_err().to_string().contains("not available"));
assert!(e.contains("not available right now") && e.contains("whisper") && e.contains("transcript.set"), "{e}");
}
// defaults to the media of the open sequence's audio clips
#[cfg(not(feature = "speech-download"))]
{
assert!(!s.is_enabled("transcript.downloadModel"));
let e = s.execute("transcript.downloadModel", json!({})).unwrap_err().to_string();
assert!(e.contains("not available right now") && e.contains("speech-download"), "{e}");
}
#[cfg(feature = "speech-download")]
assert!(s.is_enabled("transcript.downloadModel"));
// the other transcript commands don't depend on the build
for id in ["transcript.set", "transcript.models", "transcript.inspect"] {
assert!(s.is_enabled(id), "{id}");
}
// defaults to the media of the open sequence's audio clips; an installed recogniser enables it
s.transcriber = Some(Arc::new(FixedTranscriber { transcript: Transcript::default(), id: "empty".into() }));
assert!(s.is_enabled("transcript.generate"));
let r = s.execute("transcript.generate", json!({})).unwrap();
assert!(r["items"].as_array().unwrap().len() >= 2, "{r}");
}
+7 -3
View File
@@ -50,9 +50,13 @@ Both are **optional features**, off by default and never built for the web:
- `download` (`speech-download` on the engine): HTTPS downloads with rustls + RustCrypto and the
operating system's certificate verifier.
Without `whisper`, `transcript.generate` fails with "speech-to-text is not available in this build";
transcripts can still be imported with `transcript.set` and edited with every other command. Hosts
and tests can install any recogniser in `Session::transcriber`.
Without `whisper`, and with no recogniser installed, `transcript.generate` and Transcribe Sequence
are disabled, with "speech-to-text is not available in this build" as the reason (`describe`,
`command_list {"enabled_only": true}` and the menus show it); with Automatically transcribe clips
on, `file.import` reports the same reason as a `transcription: …` entry in its `errors`.
Without `speech-download`, `transcript.downloadModel` is disabled the same way. Transcripts can
still be imported with `transcript.set` and edited with every other command. Hosts and tests can
install any recogniser in `Session::transcriber`, which enables transcription in any build.
### Models