diff --git a/src/components/ai-edition/TranscriptionStatus.test.tsx b/src/components/ai-edition/TranscriptionStatus.test.tsx
index 4bbd09b49..6b9163eb1 100644
--- a/src/components/ai-edition/TranscriptionStatus.test.tsx
+++ b/src/components/ai-edition/TranscriptionStatus.test.tsx
@@ -114,4 +114,44 @@ describe("TranscriptionStatusDot", () => {
expect(container.querySelector("svg")).toBeNull();
expect(container.querySelector("span")).toHaveAttribute("title", "mediaStage.transcriptReady");
});
+
+ it("renders amber dot and clean title for silent media (no-audio)", () => {
+ const { container } = render(
+ ,
+ );
+ const span = container.querySelector("span");
+ expect(span).toHaveStyle({ background: "#f59e0b" });
+ expect(span).toHaveAttribute("title", "mediaStage.noAudioTrack");
+ });
+
+ it("renders danger dot and detail title for actual error failure", () => {
+ const { container } = render(
+ ,
+ );
+ const span = container.querySelector("span");
+ expect(span).toHaveStyle({ background: "var(--danger)" });
+ expect(span).toHaveAttribute(
+ "title",
+ "mediaStage.transcriptionFailed — whisper-server exited unexpectedly",
+ );
+ });
});
diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx
index 2b405441f..cf59cb412 100644
--- a/src/components/ai-edition/TranscriptionStatus.tsx
+++ b/src/components/ai-edition/TranscriptionStatus.tsx
@@ -105,7 +105,10 @@ export function TranscriptionStatusDot({
);
}
- const { fill, halo } = DOT_COLOR[view.status];
+ const isSilence = view.failure?.kind === "no-audio" || view.failure?.kind === "unsupported-audio";
+ const { fill, halo } = isSilence
+ ? { fill: "#f59e0b", halo: "0 0 0 3px rgba(245, 158, 11, 0.2)" }
+ : DOT_COLOR[view.status];
return (
);
}
diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx
index a69c615fe..07faa241a 100644
--- a/src/components/ai-edition/v4/MediaStage.tsx
+++ b/src/components/ai-edition/v4/MediaStage.tsx
@@ -94,6 +94,9 @@ export function MediaStage({
: { assetId: "", status: "idle" };
const selectedBusy =
selectedTranscription.status === "running" || selectedTranscription.status === "queued";
+ const selectedSilence =
+ selectedTranscription.failure?.kind === "no-audio" ||
+ selectedTranscription.failure?.kind === "unsupported-audio";
const handleImport = async () => {
if (!projectId) {
@@ -308,13 +311,17 @@ export function MediaStage({
borderRadius: 9999,
background:
selectedTranscription.status === "failed"
- ? "var(--danger-soft)"
+ ? selectedSilence
+ ? "var(--warn-soft)"
+ : "var(--danger-soft)"
: selectedTranscription.status === "ready"
? "var(--success-soft)"
: "var(--accent-soft)",
color:
selectedTranscription.status === "failed"
- ? "var(--danger)"
+ ? selectedSilence
+ ? "var(--warn)"
+ : "var(--danger)"
: selectedTranscription.status === "ready"
? "var(--success)"
: "var(--accent)",
@@ -452,7 +459,9 @@ export function MediaStage({
{selectedBusy
? transcriptionLabel(selectedTranscription)
: selectedTranscription.status === "failed"
- ? t("mediaStage.generationFailedHint")
+ ? selectedSilence
+ ? t("mediaStage.noAudioTrackHint")
+ : t("mediaStage.generationFailedHint")
: t("mediaStage.notGeneratedHint")}
)}
diff --git a/src/lib/ai-edition/store/transcriptionStore.test.ts b/src/lib/ai-edition/store/transcriptionStore.test.ts
index 03276bd25..dd33ddf98 100644
--- a/src/lib/ai-edition/store/transcriptionStore.test.ts
+++ b/src/lib/ai-edition/store/transcriptionStore.test.ts
@@ -195,6 +195,29 @@ describe("useTranscriptionStore", () => {
expect(transcribeMocks.transcribeAsset).toHaveBeenCalledTimes(1);
});
+ it("remembers NoAudioTrackError as a no-audio verdict and does not toast an error", async () => {
+ const err = Object.assign(
+ new Error(
+ "Error invoking remote method 'stt:transcribe': NoAudioTrackError: No decodable audio in /path/to/rec.mp4: Output file #0 does not contain any stream",
+ ),
+ { name: "NoAudioTrackError" },
+ );
+ transcribeMocks.transcribeAsset.mockRejectedValue(err);
+ loadDocument(makeDoc(["asset_1"]));
+
+ const { sync } = useTranscriptionStore.getState();
+ sync(useProjectStore.getState().document);
+ await whenTranscriptionIdle();
+
+ const job = useTranscriptionStore.getState().jobs.asset_1;
+ expect(job?.status).toBe("failed");
+ expect(job?.failure?.kind).toBe("no-audio");
+ expect(useProjectStore.getState().document?.assets[0].transcriptionFailure?.kind).toBe(
+ "no-audio",
+ );
+ expect(toastMocks.error).not.toHaveBeenCalled();
+ });
+
it("skips an asset that already carries a persisted failure on a fresh load", async () => {
const doc = makeDoc(["asset_1"]);
loadDocument({
diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts
index d453936bf..88d289e44 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -64,6 +64,28 @@ describe("classifyTranscriptionError", () => {
).toBe("no-audio");
});
+ it("recognises native extraction NoAudioTrackError", () => {
+ const err = Object.assign(
+ new Error("No decodable audio in /tmp/rec.mp4: Output file #0 does not contain any stream"),
+ {
+ name: "NoAudioTrackError",
+ },
+ );
+ const failure = classifyTranscriptionError(err);
+ expect(failure.kind).toBe("no-audio");
+ expect(isPermanentFailure(failure.kind)).toBe(true);
+ });
+
+ it("recognises remote IPC wrapped NoAudioTrackError", () => {
+ const failure = classifyTranscriptionError(
+ new Error(
+ "Error invoking remote method 'stt:transcribe': NoAudioTrackError: No decodable audio in C:\\test\\rec.mp4: Output file #0 does not contain any stream",
+ ),
+ );
+ expect(failure.kind).toBe("no-audio");
+ expect(isPermanentFailure(failure.kind)).toBe(true);
+ });
+
it("recognises an audio codec the caption path cannot read", () => {
const failure = classifyTranscriptionError(
new Error("Audio codec not supported for captions: ac-3"),
diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts
index f9df6a89f..07f0217d4 100644
--- a/src/lib/ai-edition/transcription/status.ts
+++ b/src/lib/ai-edition/transcription/status.ts
@@ -117,12 +117,19 @@ export type PersistableFailureKind = Exclude;
/**
* Map an exception out of `transcribeAsset` onto a failure the UI can explain.
- * The two deterministic cases come from `extractMono16kWebDemuxer` — it is the
- * only layer that knows whether the container actually holds audio.
+ * The deterministic silence cases come from `electron/stt/extractAudio` (`NoAudioTrackError`,
+ * "No decodable audio") or renderer extraction (`extractMono16kWebDemuxer`).
*/
export function classifyTranscriptionError(error: unknown): TranscriptionFailure {
const message = error instanceof Error ? error.message : String(error);
- if (/no audio track/i.test(message) || /zero audio frames/i.test(message)) {
+ const name = (error as { name?: string })?.name ?? "";
+ if (
+ name === "NoAudioTrackError" ||
+ /noaudiotrackerror/i.test(message) ||
+ /no decodable audio/i.test(message) ||
+ /no audio track/i.test(message) ||
+ /zero audio frames/i.test(message)
+ ) {
return { kind: "no-audio", message };
}
if (/audio codec not supported/i.test(message)) {