diff --git a/electron/stt/extractAudio.test.ts b/electron/stt/extractAudio.test.ts index 24e9694c8..c437b1ae9 100644 --- a/electron/stt/extractAudio.test.ts +++ b/electron/stt/extractAudio.test.ts @@ -8,10 +8,11 @@ const resolveFfmpegMock = vi.fn<() => string | null>(); vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawnMock(...args) })); vi.mock("../media/audioPeaks", () => ({ resolveFfmpeg: () => resolveFfmpegMock() })); -const { extractMono16kPcm, FfmpegUnavailableError, NoAudioTrackError } = await import( - "./extractAudio" +const { extractMono16kPcm, FfmpegUnavailableError, MediaUnreadableError, NoAudioTrackError } = + await import("./extractAudio"); +const { STT_MEDIA_UNREADABLE, STT_NATIVE_EXTRACTION_UNAVAILABLE } = await import( + "./transcriptionContract" ); -const { STT_NATIVE_EXTRACTION_UNAVAILABLE } = await import("./transcriptionContract"); /** A stand-in for the ffmpeg child: two pipes and a close event, nothing more. */ function fakeChild() { @@ -95,6 +96,60 @@ describe("extractMono16kPcm", () => { await expect(promise).rejects.toBeInstanceOf(NoAudioTrackError); }); + // Issue #968: the renderer explains these as "check folder access" and retries + // them, so both must carry the marker, which is all that survives the IPC. + it("reports a read the OS refused as unreadable media, not as no audio", async () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const promise = extractMono16kPcm("/Users/me/Downloads/a.mp4"); + child.stderr.end("/Users/me/Downloads/a.mp4: Operation not permitted"); + child.stdout.end(); + child.emit("close", 1); + + await expect(promise).rejects.toBeInstanceOf(MediaUnreadableError); + await expect(promise).rejects.toThrow(STT_MEDIA_UNREADABLE); + }); + + it("reports an ffmpeg that hangs opening the file as unreadable media", async () => { + vi.useFakeTimers(); + try { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const promise = extractMono16kPcm("/Users/me/Downloads/a.mp4"); + const settled = expect(promise).rejects.toThrow(STT_MEDIA_UNREADABLE); + await vi.advanceTimersByTimeAsync(60_000); + await settled; + expect(child.kill).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("reports a timeout AFTER audio came out as a plain stall, not unreadable media", async () => { + // The file was readable; pointing the user at folder access would be wrong. + vi.useFakeTimers(); + try { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const promise = extractMono16kPcm("/Users/me/Downloads/a.mp4"); + const settled = promise.then( + () => { + throw new Error("expected a rejection"); + }, + (error: Error) => error, + ); + child.stdout.write(f32le([0.5])); + await vi.advanceTimersByTimeAsync(60_000); + const error = await settled; + expect(error).not.toBeInstanceOf(MediaUnreadableError); + expect(error.message).not.toContain(STT_MEDIA_UNREADABLE); + expect(error.message).toBe("ffmpeg timed out after 60000ms on /Users/me/Downloads/a.mp4"); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the samples when ffmpeg exits non-zero AFTER writing audio", async () => { // A truncated file still yields usable audio; throwing it away would lose a // transcript over a trailing byte. diff --git a/electron/stt/extractAudio.ts b/electron/stt/extractAudio.ts index 08168d21e..28dd9f177 100644 --- a/electron/stt/extractAudio.ts +++ b/electron/stt/extractAudio.ts @@ -21,7 +21,7 @@ import { spawn } from "node:child_process"; import { resolveFfmpeg } from "../media/audioPeaks"; -import { STT_NATIVE_EXTRACTION_UNAVAILABLE } from "./transcriptionContract"; +import { STT_MEDIA_UNREADABLE, STT_NATIVE_EXTRACTION_UNAVAILABLE } from "./transcriptionContract"; /** What whisper.cpp wants, and what `decodePeaks` already asks ffmpeg for. */ const SAMPLE_RATE = 16_000; @@ -50,6 +50,22 @@ export class NoAudioTrackError extends Error { } } +/** + * The media file could not be read at all: the OS refused it, or ffmpeg hung + * opening it. Unlike `NoAudioTrackError` this is NOT permanent: it clears once + * the user grants access, so it carries `STT_MEDIA_UNREADABLE` for the renderer + * to explain rather than remember. + */ +export class MediaUnreadableError extends Error { + constructor(filePath: string, detail: string) { + super(`${STT_MEDIA_UNREADABLE}: cannot read ${filePath}: ${detail}`); + this.name = "MediaUnreadableError"; + } +} + +/** What ffmpeg's stderr says when the OS refused to open the input. */ +const ACCESS_DENIED = /operation not permitted|permission denied/i; + /** * Decode `filePath` to mono 16 kHz float samples. * @@ -105,8 +121,16 @@ export async function extractMono16kPcm( const timer = setTimeout(() => { child.kill("SIGKILL"); + // Only a hang before the first byte says the file could not be opened (a + // pending macOS folder-access prompt produces exactly that, issue #968). Once + // audio has come out the media was readable, and the stall is ffmpeg's. + const nothingRead = total === 0 && carry === null; finish(() => - reject(new Error(`ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms on ${filePath}`)), + reject( + nothingRead + ? new MediaUnreadableError(filePath, `ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms`) + : new Error(`ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms on ${filePath}`), + ), ); }, EXTRACT_TIMEOUT_MS); @@ -137,7 +161,13 @@ export async function extractMono16kPcm( // caller treats both the same way — this asset will not transcribe — so they // share an error type; `stderr` carries which it was. if (code !== 0 && total === 0) { - finish(() => reject(new NoAudioTrackError(filePath, stderr.trim()))); + // Except a read the OS refused: that also exits non-zero with nothing + // decoded, but remembering it as "no audio" would never retry the file + // once access is granted. + const error = ACCESS_DENIED.test(stderr) + ? new MediaUnreadableError(filePath, stderr.trim()) + : new NoAudioTrackError(filePath, stderr.trim()); + finish(() => reject(error)); return; } const out = new Float32Array(total); diff --git a/electron/stt/transcriptionContract.ts b/electron/stt/transcriptionContract.ts index 5433698ac..4a2563a78 100644 --- a/electron/stt/transcriptionContract.ts +++ b/electron/stt/transcriptionContract.ts @@ -142,6 +142,17 @@ export interface SttTranscribeRequest { */ export const STT_NATIVE_EXTRACTION_UNAVAILABLE = "stt:native-extraction-unavailable"; +/** + * Marker carried in the error message when audio extraction could not read the + * `sourcePath` itself: the OS refused the read, or ffmpeg hung opening the file + * (macOS holds the read behind a pending folder-access prompt, issue #968). + * + * Only `extractAudio.ts` sets it, so the renderer can tell "this file's folder is + * not readable" from an engine failure that happens to mention a permission (model + * loading, the whisper helper). Same IPC reason as above for being a string. + */ +export const STT_MEDIA_UNREADABLE = "stt:media-unreadable"; + /** IPC response: main → renderer. */ export interface SttTranscribeResponse { segments: SttPhraseSegment[]; diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index bb30e2115..7b0558084 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -19,6 +19,7 @@ import { type AssetTranscriptionView, isSilentFailure, transcriptHasSpeech, + transcriptionFailureHintKey, } from "@/lib/ai-edition/transcription/status"; import { formatBytes } from "@/utils/formatBytes"; import { @@ -105,6 +106,9 @@ export function MediaStage({ : { assetId: "", status: "idle" }; const selectedBusy = selectedTranscription.status === "running" || selectedTranscription.status === "queued"; + const failureHintKey = selectedTranscription.failure + ? transcriptionFailureHintKey(selectedTranscription.failure) + : null; const handleImport = async () => { if (!projectId) { @@ -380,9 +384,7 @@ export function MediaStage({ color: "var(--muted)", }} > - {selectedTranscription.failure.kind === "error" - ? selectedTranscription.failure.message - : t("mediaStage.noAudioTrackHint")} + {failureHintKey ? t(failureHintKey) : selectedTranscription.failure.message}

) : null} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index e93f6914b..0de8bfa88 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "اللغة المكتشفة: {{language}}", "noAudioTrack": "لا يوجد مسار صوتي", "noAudioTrackHint": "لا يحتوي هذا الملف على مسار صوتي — لا يوجد ما يمكن نسخه.", + "mediaUnreadableHint": "تعذّرت قراءة صوت هذا الملف. تأكد من أن OpenScreen يمكنه الوصول إلى المجلد الذي يوجد فيه، ثم أعد الإنشاء.", "noSpeechDetected": "لم يتم اكتشاف كلام" }, "exportDialog": { diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index ac242ff8d..78ed90f4f 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Zjištěný jazyk: {{language}}", "noAudioTrack": "Žádná zvuková stopa", "noAudioTrackHint": "Toto médium nemá zvukovou stopu, takže není co přepisovat.", + "mediaUnreadableHint": "Zvuk tohoto média se nepodařilo přečíst. Ověřte, že OpenScreen má přístup ke složce, ve které je uloženo, a pak jej vygenerujte znovu.", "noSpeechDetected": "Žádná řeč nezjištěna" }, "exportDialog": { diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index a292f3270..c5bc7453f 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Erkannte Sprache: {{language}}", "noAudioTrack": "Keine Audiospur", "noAudioTrackHint": "Dieses Medium hat keine Audiospur — es gibt nichts zu transkribieren.", + "mediaUnreadableHint": "Der Ton dieses Mediums konnte nicht gelesen werden. Stelle sicher, dass OpenScreen auf den Ordner zugreifen darf, in dem es liegt, und generiere dann neu.", "noSpeechDetected": "Keine Sprache erkannt" }, "exportDialog": { diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 52dbd2d35..8a2073355 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Detected language: {{language}}", "noAudioTrack": "No audio track", "noAudioTrackHint": "This media has no audio track — there is nothing to transcribe.", + "mediaUnreadableHint": "Couldn't read this media's audio. Make sure OpenScreen can access the folder it's in, then regenerate.", "noSpeechDetected": "No speech detected" }, "exportDialog": { diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index d3e01791c..5640049e7 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Idioma detectado: {{language}}", "noAudioTrack": "Sin pista de audio", "noAudioTrackHint": "Este medio no tiene pista de audio: no hay nada que transcribir.", + "mediaUnreadableHint": "No se pudo leer el audio de este medio. Asegúrate de que OpenScreen puede acceder a la carpeta que lo contiene y vuelve a generar.", "noSpeechDetected": "No se detectó voz" }, "exportDialog": { diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 60bede225..e7e0c5bf2 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Langue détectée : {{language}}", "noAudioTrack": "Aucune piste audio", "noAudioTrackHint": "Ce média n'a pas de piste audio — il n'y a rien à transcrire.", + "mediaUnreadableHint": "Impossible de lire l'audio de ce média. Vérifiez qu'OpenScreen a accès au dossier qui le contient, puis régénérez.", "noSpeechDetected": "Aucune parole détectée" }, "exportDialog": { diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index bef5e48d6..cc2116ea3 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Lingua rilevata: {{language}}", "noAudioTrack": "Nessuna traccia audio", "noAudioTrackHint": "Questo contenuto non ha una traccia audio: non c'è nulla da trascrivere.", + "mediaUnreadableHint": "Impossibile leggere l'audio di questo contenuto. Verifica che OpenScreen possa accedere alla cartella che lo contiene, poi rigenera.", "noSpeechDetected": "Nessun parlato rilevato" }, "exportDialog": { diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index b8322677a..2313587b6 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "検出された言語: {{language}}", "noAudioTrack": "音声トラックがありません", "noAudioTrackHint": "このメディアには音声トラックがないため、文字起こしできません。", + "mediaUnreadableHint": "このメディアの音声を読み取れませんでした。OpenScreen が保存先のフォルダーにアクセスできることを確認してから、再生成してください。", "noSpeechDetected": "音声が検出されませんでした" }, "exportDialog": { diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 3dfd3a431..b75059aa3 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "감지된 언어: {{language}}", "noAudioTrack": "오디오 트랙 없음", "noAudioTrackHint": "이 미디어에는 오디오 트랙이 없어 받아쓸 내용이 없습니다.", + "mediaUnreadableHint": "이 미디어의 오디오를 읽을 수 없습니다. OpenScreen이 파일이 있는 폴더에 접근할 수 있는지 확인한 후 다시 생성하세요.", "noSpeechDetected": "음성이 감지되지 않음" }, "exportDialog": { diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index d4c344ad9..003ea563a 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Idioma detectado: {{language}}", "noAudioTrack": "Sem faixa de áudio", "noAudioTrackHint": "Esta mídia não tem faixa de áudio — não há nada para transcrever.", + "mediaUnreadableHint": "Não foi possível ler o áudio desta mídia. Verifique se o OpenScreen tem acesso à pasta onde ela está e gere novamente.", "noSpeechDetected": "Nenhuma fala detectada" }, "exportDialog": { diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 35f0d0233..096daf5e7 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Обнаруженный язык: {{language}}", "noAudioTrack": "Нет аудиодорожки", "noAudioTrackHint": "В этом медиафайле нет аудиодорожки — расшифровывать нечего.", + "mediaUnreadableHint": "Не удалось прочитать звук этого медиафайла. Убедитесь, что у OpenScreen есть доступ к папке, в которой он находится, и создайте расшифровку заново.", "noSpeechDetected": "Речь не обнаружена" }, "exportDialog": { diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index df1eb203f..f4a76714e 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Algılanan dil: {{language}}", "noAudioTrack": "Ses parçası yok", "noAudioTrackHint": "Bu medyada ses parçası yok — metne dökülecek bir şey bulunmuyor.", + "mediaUnreadableHint": "Bu medyanın sesi okunamadı. OpenScreen'in bulunduğu klasöre erişebildiğinden emin olun, ardından yeniden oluşturun.", "noSpeechDetected": "Konuşma algılanmadı" }, "exportDialog": { diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 97e000f27..1ab9da48c 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Ngôn ngữ phát hiện: {{language}}", "noAudioTrack": "Không có bản âm thanh", "noAudioTrackHint": "Media này không có bản âm thanh — không có gì để phiên âm.", + "mediaUnreadableHint": "Không thể đọc âm thanh của media này. Hãy đảm bảo OpenScreen có quyền truy cập thư mục chứa nó, rồi tạo lại.", "noSpeechDetected": "Không phát hiện giọng nói" }, "exportDialog": { diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index e2be1ddc2..98a12b084 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "检测到的语言:{{language}}", "noAudioTrack": "无音频轨道", "noAudioTrackHint": "此媒体没有音频轨道,没有可转录的内容。", + "mediaUnreadableHint": "无法读取此媒体的音频。请确认 OpenScreen 可以访问它所在的文件夹,然后重新生成。", "noSpeechDetected": "未检测到语音" }, "exportDialog": { diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 991a5d04f..8862c279a 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "偵測到的語言:{{language}}", "noAudioTrack": "無音訊軌道", "noAudioTrackHint": "此媒體沒有音訊軌道,沒有可轉錄的內容。", + "mediaUnreadableHint": "無法讀取此媒體的音訊。請確認 OpenScreen 可以存取它所在的資料夾,然後重新產生。", "noSpeechDetected": "未偵測到語音" }, "exportDialog": { diff --git a/src/lib/ai-edition/store/transcriptionStore.ts b/src/lib/ai-edition/store/transcriptionStore.ts index 0eb950a56..316f30791 100644 --- a/src/lib/ai-edition/store/transcriptionStore.ts +++ b/src/lib/ai-edition/store/transcriptionStore.ts @@ -42,6 +42,7 @@ import { type TranscriptionFailure, type TranscriptionPhase, type TranscriptionProgress, + transcriptionFailureHintKey, transcriptRelevantAssetIds, } from "../transcription/status"; import { useProjectStore } from "./projectStore"; @@ -451,6 +452,9 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { } if (!isCurrentRun(assetId, runId)) return; // superseded by a newer request const failure = classifyTranscriptionError(error); + // The UI shows a translated hint or a trimmed message; the full detail + // (channel, path, ffmpeg stderr) belongs here. + console.warn(`[transcription] asset ${assetId} failed:`, error); patchJob(assetId, runId, { status: "failed", phase: undefined, progress: undefined, failure }); flushSettleWaiters(assetId); await persistPermanentFailure(projectId, assetId, failure); @@ -459,8 +463,13 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { // queue into the same wall would spend a full retry budget per asset and // stack one identical toast per asset. Fail them with the same verdict // instead — the gate then reads "failed" (not "queued forever"), and one - // manual retry re-runs them all once the engine is back. - if (failure.kind === "error") failRemainingQueue(projectId, failure); + // manual retry re-runs them all once the engine is back. A file the OS would + // not let ffmpeg read is the exception: that verdict is about this file's + // folder, and stamping it on media elsewhere would send the user looking at + // the wrong permission. + if (failure.kind === "error" && !failure.unreadableMedia) { + failRemainingQueue(projectId, failure); + } // A silent recording is an expected outcome, not an incident: the media card // and every gated button already say so, so the background pass stays quiet. // A run the user asked for by hand still gets an answer — but an @@ -469,7 +478,10 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { if (isPermanentFailure(failure.kind)) { if (job.manual) toast.info(toastText("mediaStage.noAudioTrackHint")); } else { - toast.error(toastText("mediaStage.transcriptionFailed"), { description: failure.message }); + const hintKey = transcriptionFailureHintKey(failure); + toast.error(toastText("mediaStage.transcriptionFailed"), { + description: hintKey ? toastText(hintKey) : failure.message, + }); } } finally { if (activeRun?.controller === controller) activeRun = null; diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index 442322d8a..c94cdf705 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { STT_MEDIA_UNREADABLE } from "../../../../electron/stt/transcriptionContract"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { type AssetTranscriptionView, @@ -14,7 +15,9 @@ import { progressFraction, realtimeSpeed, resolveTranscriptGate, + stripIpcErrorWrapper, transcriptHasSpeech, + transcriptionFailureHintKey, transcriptRelevantAssetIds, } from "./status"; @@ -100,7 +103,91 @@ describe("classifyTranscriptionError", () => { const failure = classifyTranscriptionError(new Error("whisper-server exited")); expect(failure.kind).toBe("error"); expect(failure.message).toBe("whisper-server exited"); + expect(failure.unreadableMedia).toBeUndefined(); expect(isPermanentFailure(failure.kind)).toBe(false); + expect(transcriptionFailureHintKey(failure)).toBeNull(); + }); + + // Issue #968: macOS held ffmpeg's read of a file in ~/Downloads behind a + // pending TCC prompt until the extraction timeout fired, and the media card + // printed the whole IPC rejection. + // The shape `MediaUnreadableError` takes once `ipcRenderer.invoke` has wrapped it. + it("explains an extraction timeout as unreadable media, without the IPC wrapper", () => { + const failure = classifyTranscriptionError( + new Error( + `Error invoking remote method 'stt:transcribe': Error: ${STT_MEDIA_UNREADABLE}: cannot read /Users/me/Downloads/clip.mp4: ffmpeg timed out after 60000ms`, + ), + ); + expect(failure.kind).toBe("error"); + expect(failure.unreadableMedia).toBe(true); + expect(failure.message).toBe( + "cannot read /Users/me/Downloads/clip.mp4: ffmpeg timed out after 60000ms", + ); + // Transient: once the prompt is answered, the next run must try again. + expect(isPermanentFailure(failure.kind)).toBe(false); + expect(isSilentFailure(view("a", "failed", failure.kind))).toBe(false); + expect(transcriptionFailureHintKey(failure)).toBe("mediaStage.mediaUnreadableHint"); + }); + + // Wording is not evidence: only the extraction step sets the marker, and a + // permission error out of model loading is an engine failure, which must keep + // the engine path (raw message, rest of the queue failed with it). + it("leaves a permission error from model loading on the engine-failure path", () => { + const failure = classifyTranscriptionError( + new Error( + "Error invoking remote method 'stt:transcribe': Error: EACCES: permission denied, open '/Users/me/Library/Application Support/openscreen/models/ggml-small.bin'", + ), + ); + expect(failure.kind).toBe("error"); + expect(failure.unreadableMedia).toBeUndefined(); + expect(transcriptionFailureHintKey(failure)).toBeNull(); + }); + + it("does not take an ffmpeg timeout without the marker for unreadable media", () => { + // e.g. the peaks decoder's own timeout, which says the same words. + const failure = classifyTranscriptionError( + new Error("ffmpeg timed out after 60000ms on /Users/me/Downloads/clip.mp4"), + ); + expect(failure.unreadableMedia).toBeUndefined(); + }); + + it("strips the IPC wrapper from an engine message it has no hint for", () => { + const failure = classifyTranscriptionError( + new Error("Error invoking remote method 'stt:transcribe': Error: whisper-server exited"), + ); + expect(failure.kind).toBe("error"); + expect(failure.message).toBe("whisper-server exited"); + expect(transcriptionFailureHintKey(failure)).toBeNull(); + }); + + it("gives the media verdicts the no-audio hint", () => { + expect(transcriptionFailureHintKey({ kind: "no-audio", message: "x" })).toBe( + "mediaStage.noAudioTrackHint", + ); + expect(transcriptionFailureHintKey({ kind: "unsupported-audio", message: "x" })).toBe( + "mediaStage.noAudioTrackHint", + ); + }); +}); + +describe("stripIpcErrorWrapper", () => { + it("removes the channel prefix and the rebuilt error name", () => { + expect(stripIpcErrorWrapper("Error invoking remote method 'stt:transcribe': Error: boom")).toBe( + "boom", + ); + expect( + stripIpcErrorWrapper("Error invoking remote method 'stt:transcribe': TypeError: bad arg"), + ).toBe("bad arg"); + }); + + it("keeps the message when the rejection carries no error name", () => { + expect(stripIpcErrorWrapper("Error invoking remote method 'x:y': plain reason")).toBe( + "plain reason", + ); + }); + + it("leaves a message that never crossed IPC untouched", () => { + expect(stripIpcErrorWrapper("Error: not from IPC")).toBe("Error: not from IPC"); }); }); diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index 825d4e771..fe28e88ed 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -6,6 +6,7 @@ // plain data — `store/transcriptionStore.ts` owns the queue and the side // effects, this module owns the vocabulary. +import { STT_MEDIA_UNREADABLE } from "../../../../electron/stt/transcriptionContract"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { voiceoverPlacements } from "../timeline/aggregated-transcript"; @@ -14,8 +15,18 @@ export type TranscriptionFailureKind = "no-audio" | "unsupported-audio" | "error export interface TranscriptionFailure { kind: TranscriptionFailureKind; - /** Raw engine/exception message — surfaced as a tooltip / toast description. */ + /** Raw engine/exception message, IPC wrapper stripped. Surfaced as a tooltip. */ message: string; + /** + * Audio extraction could not read the media file itself: the OS refused access, + * or ffmpeg hung opening it (macOS parks the read behind a pending "access files + * in your Downloads folder" prompt until the extraction timeout fires, issue + * #968). Set only from the `STT_MEDIA_UNREADABLE` marker `extractAudio.ts` puts + * on its rejection, never inferred from wording. + * Transient, so `kind` stays `"error"`, but the user can fix it: the UI shows a + * hint pointing at file access instead of the raw `message`. + */ + unreadableMedia?: boolean; } /** Which part of the pipeline a running job is in (mirrors `TranscribeAssetOptions.onStatus`). */ @@ -129,7 +140,17 @@ export type PersistableFailureKind = Exclude; * open, and pops a toast carrying raw ffmpeg stderr (issue #628). */ export function classifyTranscriptionError(error: unknown): TranscriptionFailure { - const message = error instanceof Error ? error.message : String(error); + const message = stripIpcErrorWrapper(error instanceof Error ? error.message : String(error)); + // Keyed on the marker only the audio-extraction step sets, not on wording: a + // "permission denied" out of model loading is an engine failure, and must keep + // the engine-failure handling (raw message, fail the rest of the queue). + if (message.includes(STT_MEDIA_UNREADABLE)) { + return { + kind: "error", + message: message.replace(`${STT_MEDIA_UNREADABLE}: `, ""), + unreadableMedia: true, + }; + } if (/no audio track|zero audio frames|no decodable audio/i.test(message)) { return { kind: "no-audio", message }; } @@ -139,6 +160,24 @@ export function classifyTranscriptionError(error: unknown): TranscriptionFailure return { kind: "error", message }; } +/** + * `ipcRenderer.invoke` rejects with `Error invoking remote method '': + * : `. Only `` means anything to the user. + */ +export function stripIpcErrorWrapper(message: string): string { + return message.replace(/^Error invoking remote method '[^']*': (?:[A-Za-z]*Error: )?/, ""); +} + +/** + * The `editor` i18n key of the sentence that explains this failure to the user, + * or null when the engine message is the best explanation there is. + */ +export function transcriptionFailureHintKey(failure: TranscriptionFailure): string | null { + if (failure.kind !== "error") return "mediaStage.noAudioTrackHint"; + if (failure.unreadableMedia) return "mediaStage.mediaUnreadableHint"; + return null; +} + /** * True when this asset's job ended on a verdict about the MEDIA rather than on * something that went wrong: no audio track, or an audio codec nothing here can