Merge pull request #146 from storytold/fix/97-98-89-transcripts-without-speech

Transcripts: builds without speech-to-text say so (#97, #98, #89)
This commit is contained in:
Brandon Thomas 2026-10-07 10:05:02 -04:00 committed by GitHub
commit be5b34b383
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 64 additions and 15 deletions

View File

@ -828,16 +828,17 @@ fn build() -> Vec<CommandSpec> {
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
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}))
{
errors.push(format!("transcription: {e}"));
if !audio.is_empty() {
// without speech-to-text the setting can't act: say so instead of importing
// silently untranscribed (#89)
let r = crate::transcript::can_transcribe(s)
.map_err(EngineError::Other)
.and_then(|()| s.execute("transcript.generate", json!({"items": audio})));
if let Err(e) = r {
errors.push(format!("transcription: {e}"));
}
}
}
let ingest = match crate::proxies::ingest(s, &item_ids) {

View File

@ -481,6 +481,35 @@ fn auto_transcribe_on_import_and_transcription_defaults() {
let _ = std::fs::remove_dir_all(&dir);
}
/// In a build without speech-to-text (the release builds), the transcript commands that can't
/// run say so through `enabled` (#97, #98), and an import with auto-transcribe on reports why it
/// made no transcript instead of saying nothing (#89).
#[test]
fn transcription_without_speech_to_text_says_so() {
let mut s = Session::default();
assert_eq!(s.is_enabled("transcript.generate"), crate::transcript::speech_available());
assert_eq!(s.is_enabled("transcript.downloadModel"), cfg!(feature = "speech-download"));
if crate::transcript::speech_available() {
return;
}
let dir = tmp_dir("prefs-no-speech");
let mov = dir.join("talk.mov");
crate::media_test_util::make_movie(&mov, filmcraft_media::DemoScene::OceanSunset, 64, 36, 24);
// off: an import reports nothing
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();
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("not available in this build"))), "{errors:?}");
assert!(!s.project.transcripts.contains_key(&ItemId(r["items"][0].as_u64().unwrap())));
// with a transcriber installed (a host's), the command is enabled again
s.transcriber = Some(std::sync::Arc::new(filmcraft_speech::FixedTranscriber { transcript: Default::default(), id: "fixed".into() }));
assert!(s.is_enabled("transcript.generate"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hardware_decoding_setting_drives_the_decoder_switch() {
let mut s = Session::default();

View File

@ -56,6 +56,25 @@ fn has_transcripts(s: &Session) -> std::result::Result<(), String> {
if s.project.transcripts.is_empty() { Err("there are no transcripts".into()) } else { Ok(()) }
}
/// Why speech-to-text can't run in this build (no installed transcriber, built without `whisper`).
pub(crate) 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` can run: a host installed a transcriber or the build has speech-to-text
/// (#97: it reported enabled and then always failed).
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` can run: built with `speech-download` (#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())
}
}
/// 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 {
@ -123,9 +142,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 +444,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 +461,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),

View File

@ -163,7 +163,9 @@ 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}");
// a build without speech-to-text says that first: the command is disabled (#97)
let why = if filmcraft_speech::available() { "unknown speech model" } else { "not available in this build" };
assert!(e.contains(why), "{e}");
if !filmcraft_speech::available() {
let e = s.execute("transcript.generate", json!({"items": [item.0]})).unwrap_err().to_string();
assert!(e.contains("whisper") && e.contains("not available"), "{e}");