mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 11:17:01 -04:00
- 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:
1 parent
e975621bbd
commit
b8fa786cad
6 files changed
+86
-22
No files matched your search
@@ -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}))
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user