- {clips.length === 0 ? ts("transcript.noClips") : ts("transcript.noTranscript")}
+ {clips.length === 0
+ ? ts("transcript.noClips")
+ : isTranscribing
+ ? ts("transcript.transcribing")
+ : silentMedia
+ ? ts("transcript.noAudio")
+ : ts("transcript.noTranscript")}
- {ts("transcript.whisperHint")}
+ {blocked?.reason === "failed" && blocked.message
+ ? blocked.message
+ : ts("transcript.whisperHint")}
- {ts("transcript.noClipTranscript")}
+ {busy ? ts("transcript.transcribing") : ts("transcript.noClipTranscript")}
) : (
({ nativeBridgeClient: { aiEdition: {} } }));
+vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
+
+const ASSET: AxcutAsset = {
+ id: "asset_1",
+ kind: "video",
+ label: "recording.mp4",
+ originalPath: "/rec.mp4",
+ durationSec: 12,
+ cameraTrack: null,
+};
+
+const CLIPS: AxcutClip[] = [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 12,
+ timelineStartSec: 0,
+ timelineEndSec: 12,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+];
+
+function renderPane(
+ overrides: {
+ isTranscribing?: boolean;
+ blocked?: { reason: TranscriptGateReason; message?: string };
+ } = {},
+) {
+ return render(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("transcript pane gating", () => {
+ it("offers the button while nothing has been attempted", () => {
+ renderPane();
+ expect(screen.getByRole("button", { name: "Transcribe now" })).toBeEnabled();
+ });
+
+ it("shows the background run in progress instead of an idle button", () => {
+ renderPane({ isTranscribing: true });
+ const button = screen.getByRole("button", { name: "Transcribing…" });
+ expect(button).toBeDisabled();
+ });
+
+ it("disables the button when the timeline's media have no audio track, and says why", () => {
+ renderPane({ blocked: { reason: "no-audio" } });
+ expect(screen.getByRole("button", { name: "Transcribe now" })).toBeDisabled();
+ expect(screen.getByText("This media has no audio track")).toBeInTheDocument();
+ });
+
+ it("keeps the retry available after a transient failure, and surfaces the engine message", () => {
+ renderPane({ blocked: { reason: "failed", message: "whisper-server exited" } });
+ expect(screen.getByRole("button", { name: "Transcribe now" })).toBeEnabled();
+ expect(screen.getByText("whisper-server exited")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
index af75267ad..c55ae8233 100644
--- a/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.keyboardCut.test.tsx
@@ -67,7 +67,11 @@ const W2_TRIMMED: AxcutTrimRange = {
reason: "",
};
-function renderPane(trimRanges: AxcutTrimRange[], onAddTrimRange = vi.fn()) {
+function renderPane(
+ trimRanges: AxcutTrimRange[],
+ onAddTrimRange = vi.fn(),
+ busyAssetIds: string[] = [],
+) {
const view = render(
{
expect(cutRange(onAddTrimRange)).toEqual([2, 3]); // "trois"
});
+ it("keeps cutting while ANOTHER asset is being transcribed", () => {
+ // The background pass runs on its own now, so a run on some other media must
+ // not quietly turn this block into an editor that ignores Backspace — the
+ // read-only state is scoped to the asset whose transcript is being rewritten.
+ const { editor, onAddTrimRange } = renderPane([], vi.fn(), ["asset_other"]);
+ caretBeforeWordAt(editor, 3);
+ fireEvent.keyDown(editor, { key: "Backspace" });
+ expect(cutRange(onAddTrimRange)).toEqual([2, 3]);
+ });
+
+ it("stops cutting, visibly, while THIS asset is being transcribed", () => {
+ // Its transcript is about to be replaced, so the block is read-only — and it
+ // says so, instead of swallowing the keystroke in silence.
+ const { editor, onAddTrimRange, getByText } = renderPane([], vi.fn(), ["asset_1"]);
+ caretBeforeWordAt(editor, 3);
+ fireEvent.keyDown(editor, { key: "Backspace" });
+ expect(cutRange(onAddTrimRange)).toBeNull();
+ expect(editor).toHaveAttribute("aria-busy", "true");
+ expect(getByText("Transcribing…")).toBeInTheDocument();
+ });
+
it("Backspace skips over an already-trimmed word instead of doing nothing", () => {
// Hold Backspace and you land here: "deux" is already struck through, so the word
// immediately before the caret has nothing left to cut. The keystroke used to
@@ -184,7 +209,7 @@ describe("keyboard cut with the caret between words", () => {
transcripts={[TRANSCRIPT]}
assets={[ASSET]}
trimRanges={trims}
- busy={false}
+ busyAssetIds={[]}
onSeek={vi.fn()}
onAddTrimRange={(_target, startSec, endSec) =>
setTrims((prev) => [
diff --git a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
index 36004c5c3..9ce354646 100644
--- a/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
+++ b/src/components/ai-edition/TranscriptPane.sharedMedia.test.tsx
@@ -73,7 +73,7 @@ function renderPane(onSeek: (sec: number) => void = vi.fn()) {
transcripts={[TRANSCRIPT]}
assets={[ASSET]}
trimRanges={[]}
- busy={false}
+ busyAssetIds={[]}
onSeek={onSeek}
onAddTrimRange={vi.fn()}
onRemoveTrimRange={vi.fn()}
diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx
new file mode 100644
index 000000000..5e17bd276
--- /dev/null
+++ b/src/components/ai-edition/TranscriptionStatus.tsx
@@ -0,0 +1,81 @@
+// One place that turns an `AssetTranscriptionView` into words and a colour.
+//
+// The media list (left panel), the media stage and the source-transcript modal
+// all report the same six states; before auto-transcription each of them
+// spelled its own dot colours and labels out inline, and they had already
+// drifted (the left panel knew about "pending", the stage only ever showed a
+// spinner). Keeping the vocabulary here means a new state shows up everywhere
+// at once.
+
+import { Loader2 } from "lucide-react";
+import { useScopedT } from "@/contexts/I18nContext";
+import type { AssetTranscriptionView } from "@/lib/ai-edition/transcription/status";
+
+/** Human-readable state of one asset's transcript, in the user's language. */
+export function useTranscriptionLabel(): (view: AssetTranscriptionView) => string {
+ const t = useScopedT("editor");
+ return (view) => {
+ switch (view.status) {
+ case "ready":
+ return t("mediaStage.transcriptReady");
+ case "queued":
+ return t("mediaStage.pendingTranscription");
+ case "running":
+ return t("mediaStage.transcribing");
+ case "empty":
+ return t("mediaStage.noSpeechDetected");
+ case "failed":
+ return view.failure?.kind === "error"
+ ? t("mediaStage.transcriptionFailed")
+ : t("mediaStage.noAudioTrack");
+ default:
+ return t("mediaStage.noTranscript");
+ }
+ };
+}
+
+const DOT_COLOR: Record = {
+ ready: { fill: "var(--success)", halo: "0 0 0 3px var(--success-soft)" },
+ queued: { fill: "#f59e0b", halo: "0 0 0 3px rgba(245, 158, 11, 0.2)" },
+ running: { fill: "var(--accent)", halo: "0 0 0 3px rgba(16, 185, 129, 0.2)" },
+ // A silent media is not a bug — it just has nothing to say. Amber, not red.
+ empty: { fill: "#f59e0b", halo: "0 0 0 3px rgba(245, 158, 11, 0.2)" },
+ failed: { fill: "var(--danger)", halo: "0 0 0 3px rgba(239, 68, 68, 0.2)" },
+ idle: { fill: "var(--dim)", halo: "none" },
+};
+
+/** Compact status marker: a spinner while a run is in flight, a dot otherwise. */
+export function TranscriptionStatusDot({
+ view,
+ size = 8,
+}: {
+ view: AssetTranscriptionView;
+ size?: number;
+}) {
+ const label = useTranscriptionLabel()(view);
+ if (view.status === "running" || view.status === "queued") {
+ return (
+
+ );
+ }
+ const { fill, halo } = DOT_COLOR[view.status];
+ return (
+
+ );
+}
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index e3698c75a..79c89f7e0 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -873,9 +873,14 @@
cursor: pointer;
text-align: left;
}
-.recMenuRow:hover {
+.recMenuRow:hover:not(:disabled) {
background: var(--surface-2);
}
+/* A gated row (e.g. Smart cuts before its transcript exists) still shows its
+ reason line, so it must not also light up as if it were clickable. */
+.recMenuRow:disabled {
+ cursor: not-allowed;
+}
.recMenuRow.active {
background: var(--accent-soft);
color: var(--accent);
diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx
index 306d3574a..6f53c4dad 100644
--- a/src/components/ai-edition/v4/FloatingInspector.tsx
+++ b/src/components/ai-edition/v4/FloatingInspector.tsx
@@ -78,10 +78,6 @@ interface FloatingInspectorProps {
* through a facet body. */
clips: AxcutClip[];
onEditClip: (clip: AxcutClip) => void;
- /** Runs the transcription the caption layer reads from. Captions are derived
- * from the transcript, so this is the only caption action the shell owns. */
- onTranscribe: () => void;
- isTranscribing: boolean;
transcriptProps: TranscriptProps;
/** Drives the selected-element settings pane (zoom/speed/annotation/trim) —
* takes over the inspector, forcing it open, whenever a timeline region is
@@ -97,8 +93,6 @@ export function FloatingInspector({
onToggleOpen,
clips,
onEditClip,
- onTranscribe,
- isTranscribing,
transcriptProps,
tl,
}: FloatingInspectorProps) {
@@ -114,13 +108,7 @@ export function FloatingInspector({
{selection ? (
tl.clearSelection()} />
) : (
-
+
)}
) : null}
@@ -1029,14 +1017,10 @@ const secondaryBtnStyle: React.CSSProperties = {
function FacetBody({
facet,
- onTranscribe,
- isTranscribing,
onCollapse,
transcriptProps,
}: {
facet: Facet;
- onTranscribe: () => void;
- isTranscribing: boolean;
onCollapse: () => void;
transcriptProps: TranscriptProps;
}) {
@@ -1073,10 +1057,7 @@ function FacetBody({
if (facet === "layout") return wrap(collapse,