Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/components/ai-edition/CaptionsPane.gating.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,36 @@ describe("captions pane gating", () => {
<CaptionsPane />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Transcribing…" })).toBeDisabled();
// A phase-less running job renders the shared busy label ("Transcribing",
// mediaStage.transcribing) rather than the pane's old private copy.
expect(screen.getByRole("button", { name: "Transcribing" })).toBeDisabled();
});

it("keeps the idle button when only an off-timeline asset is busy", () => {
// The gate answers for the timeline's assets; the label must not answer
// for the whole bin. A bin asset mid-transcription used to relabel the
// still-enabled button with its busy copy.
const offTimeline: AxcutAsset = {
id: "asset_2",
kind: "video",
label: "bin-only.mp4",
originalPath: "/bin.mp4",
durationSec: 8,
cameraTrack: null,
};
const document = documentWith(ASSET);
document.assets.push(offTimeline);
load(document);
useTranscriptionStore.setState({
projectId: "proj_1",
jobs: { asset_2: { status: "running", language: "auto", manual: false } },
});
render(
<I18nProvider>
<CaptionsPane />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Transcribe video" })).toBeEnabled();
});

it("kills the retry on a media with no audio track and explains it", () => {
Expand Down
27 changes: 22 additions & 5 deletions src/components/ai-edition/CaptionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ import {
} from "@/lib/ai-edition/captions";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
useTimelineTranscriptGate,
useTranscriptionStore,
} from "@/lib/ai-edition/store/transcriptionStore";
import { useCaptions } from "@/lib/ai-edition/store/useCaptions";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import { nativeBridgeClient } from "@/native";
import { ColorField } from "./ColorField";
import styles from "./NewEditorShell.module.css";
import { SliderCell, Toggle } from "./RightPanes";
import { useTranscriptionLabel } from "./TranscriptionStatus";
import { transcriptionBusyLabel } from "./transcriptionBusyLabel";

/** The families `src/index.css` already loads for on-canvas text — anything else
* would render in the preview but fall back to a default in the export canvas. */
Expand Down Expand Up @@ -98,7 +102,17 @@ export function CaptionsPane() {
// actual footage had speech.
const gate = useTimelineTranscriptGate();
const requestTimelineTranscripts = useTranscriptionStore((s) => s.requestTimelineTranscripts);
const transcriptions = useAssetTranscriptions();
const transcriptionLabel = useTranscriptionLabel();
const isTranscribing = gate.state === "pending";
// Timeline-scoped on purpose: the gate below answers for the timeline's
// assets, so the label must too — an off-timeline job must not relabel an
// enabled button.
const busyLabel = transcriptionBusyLabel(
firstTimelineBusyView(document, transcriptions) ??
(isTranscribing ? { assetId: "", status: "running", phase: "loading-model" } : undefined),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
transcriptionLabel,
);
const silentMedia = gate.state === "blocked" && gate.reason === "no-audio";
const engineError = gate.state === "blocked" && gate.reason === "failed" ? gate.message : null;

Expand Down Expand Up @@ -232,7 +246,7 @@ export function CaptionsPane() {
onClick={() => void requestTimelineTranscripts()}
>
{isTranscribing ? <Loader2 size={14} className="animate-spin" /> : null}
{isTranscribing ? t("captions.transcribing") : t("captions.transcribe")}
{busyLabel ?? t("captions.transcribe")}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</button>
</div>
) : (
Expand All @@ -245,10 +259,13 @@ export function CaptionsPane() {
>
{/* The cue count is only meaningful while the layer is on — deriving
cues short-circuits when it's off, so a "0 lines" reading there
would say the transcript is empty when it isn't. */}
{settings.enabled
? t("captions.derivedFromTranscript", { count: cues.length })
: t("captions.hiddenHint")}
would say the transcript is empty when it isn't. While a
regeneration is in flight the phase label matters more than the
count of cues about to be replaced. */}
{busyLabel ??
(settings.enabled
? t("captions.derivedFromTranscript", { count: cues.length })
: t("captions.hiddenHint"))}
</p>
)}

Expand Down
9 changes: 9 additions & 0 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import { matchesShortcut } from "@/lib/shortcuts";
import { nativeBridgeClient } from "@/native";
import type { AiEditionProjectSummary } from "@/native/contracts";
Expand Down Expand Up @@ -159,6 +160,12 @@ export function NewEditorShell() {
.map((v) => v.assetId),
[transcriptions],
);
// For the pane-level busy label: timeline-scoped like the gate, so an
// off-timeline job cannot relabel controls the gate keeps enabled.
const timelineBusyView = useMemo(
() => firstTimelineBusyView(document, transcriptions),
[document, transcriptions],
);
const tl = useTimeline();
// An undo only puts the restored document back in the store and marks it dirty,
// so without this the reverted state never reached disk: close the window and the
Expand Down Expand Up @@ -1148,6 +1155,8 @@ export function NewEditorShell() {
assets: document?.assets ?? [],
trimRanges: document?.timeline?.trimRanges ?? [],
busyAssetIds,
transcriptions,
busyView: timelineBusyView,
onSeek: handleSeek,
onAddTrimRange: handleAddTrimRange,
onRemoveTrimRange: handleRemoveTrimRange,
Expand Down
35 changes: 30 additions & 5 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ import {
import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera";
import { formatMs } from "@/lib/ai-edition/timeline/format";
import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview";
import type { TranscriptGateReason } from "@/lib/ai-edition/transcription/status";
import {
type AssetTranscriptionView,
type TranscriptGateReason,
} from "@/lib/ai-edition/transcription/status";
import { getAssetPath } from "@/lib/assetPath";
import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/compositeLayout";
import { supportsCursorClickEffects } from "@/lib/cursor/cursorCapabilities";
Expand All @@ -81,6 +84,8 @@ import {
getAspectRatioLabel,
} from "@/utils/aspectRatioUtils";
import styles from "./NewEditorShell.module.css";
import { useTranscriptionLabel } from "./TranscriptionStatus";
import { transcriptionBusyLabel } from "./transcriptionBusyLabel";

interface PaneProps {
title: string;
Expand Down Expand Up @@ -641,6 +646,8 @@ export function TranscriptPane({
assets,
trimRanges,
busyAssetIds,
transcriptions,
busyView,
onSeek,
onAddTrimRange,
onRemoveTrimRange,
Expand All @@ -659,6 +666,12 @@ export function TranscriptPane({
* stream silently swallow Backspace and hover-bin clicks for the whole
* background pass, with nothing on screen to say why. */
busyAssetIds: readonly string[];
transcriptions?: Record<string, AssetTranscriptionView>;
/** First busy view over the TIMELINE's assets (same scope as `blocked` and
* `isTranscribing`) — the pane-level label reads this, never the whole
* `transcriptions` record, so an off-timeline job cannot relabel controls
* the gate keeps enabled. */
busyView?: AssetTranscriptionView;
onSeek: (sec: number) => void;
onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
onRemoveTrimRange: (trimId: string) => void;
Expand Down Expand Up @@ -706,6 +719,12 @@ export function TranscriptPane({
// Only silence is a dead end: every other reason (a retryable failure, no
// engine, nothing attempted) leaves the button worth pressing.
const silentMedia = blocked?.reason === "no-audio";
const transcriptionLabel = useTranscriptionLabel();
const paneBusyLabel = transcriptionBusyLabel(
busyView ??
(isTranscribing ? { assetId: "", status: "running", phase: "loading-model" } : undefined),
transcriptionLabel,
);

if (clips.length === 0 || !hasAnyTranscript) {
return (
Expand All @@ -731,7 +750,7 @@ export function TranscriptPane({
{clips.length === 0
? ts("transcript.noClips")
: isTranscribing
? ts("transcript.transcribing")
? (paneBusyLabel ?? ts("transcript.transcribing"))
: silentMedia
? ts("transcript.noAudio")
: ts("transcript.noTranscript")}
Expand All @@ -749,7 +768,7 @@ export function TranscriptPane({
// fail on the same missing track every time.
disabled={!canTranscribe || isTranscribing || silentMedia}
>
{isTranscribing ? ts("transcript.transcribing") : ts("transcript.transcribeNow")}
{paneBusyLabel ?? ts("transcript.transcribeNow")}
</button>
</div>
</Pane>
Expand All @@ -768,6 +787,10 @@ export function TranscriptPane({
index={idx}
section={section}
busy={busyAssetIds.includes(section.clip.assetId)}
busyLabel={
transcriptionBusyLabel(transcriptions?.[section.clip.assetId], transcriptionLabel) ??
undefined
}
cueWordId={cueWordId}
onSeek={onSeek}
onAddTrimRange={onAddTrimRange}
Expand Down Expand Up @@ -795,6 +818,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
busy,
busyLabel,
cueWordId,
onSeek,
onAddTrimRange,
Expand All @@ -803,6 +827,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
index: number;
section: ClipSection;
busy: boolean;
busyLabel?: string;
cueWordId: string | null;
onSeek: (sec: number) => void;
onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void;
Expand Down Expand Up @@ -1095,7 +1120,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}}
>
<Loader2 size={12} className="animate-spin" />
{ts("transcript.transcribing")}
{busyLabel ?? ts("transcript.transcribing")}
</span>
) : null}
</span>
Expand All @@ -1109,7 +1134,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
fontStyle: "italic",
}}
>
{busy ? ts("transcript.transcribing") : ts("transcript.noClipTranscript")}
{busy ? (busyLabel ?? ts("transcript.transcribing")) : ts("transcript.noClipTranscript")}
</p>
) : (
<div
Expand Down
3 changes: 2 additions & 1 deletion src/components/ai-edition/TranscriptPane.gating.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ describe("transcript pane gating", () => {

it("shows the background run in progress instead of an idle button", () => {
renderPane({ isTranscribing: true });
const button = screen.getByRole("button", { name: "Transcribing…" });
const button = screen.getByRole("button", { name: "Starting speech model" });
expect(button).toBeDisabled();
expect(screen.queryByRole("button", { name: "Transcribing…" })).toBeNull();
});

it("disables the button when the timeline's media have no audio track, and says why", () => {
Expand Down
19 changes: 16 additions & 3 deletions src/components/ai-edition/TranscriptionStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,25 @@ describe("useTranscriptionLabel telemetry suffix", () => {
);
});

// The model download has its own words and no device to report yet.
it("leaves the model-download phase alone", () => {
// Cold start / already-cached model: loading-model with no byte counters is
// the helper booting, not a download, so the copy must not say "Downloading".
it("names initializing when loading-model has no in-flight bytes", () => {
expect(labelOf(running({ phase: "loading-model", backend: "whispercpp-cpu", rtf: 1.1 }))).toBe(
"mediaStage.downloadingModel",
"mediaStage.initializingModel",
);
});

it("names downloading when loading-model reports an in-flight byte count", () => {
expect(
labelOf(
running({
phase: "loading-model",
downloadedBytes: 40_000_000,
totalBytes: 253_000_000,
}),
),
).toBe("mediaStage.downloadingModel");
});
});

describe("TranscriptionStatusDot", () => {
Expand Down
7 changes: 6 additions & 1 deletion src/components/ai-edition/TranscriptionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useScopedT } from "@/contexts/I18nContext";
import {
type AssetTranscriptionView,
isCpuBackend,
isModelDownloadInFlight,
progressFraction,
realtimeSpeed,
} from "@/lib/ai-edition/transcription/status";
Expand All @@ -30,7 +31,11 @@ export function useTranscriptionLabel(): (view: AssetTranscriptionView) => strin
// screen to explain it, so it gets its own words rather than being
// labelled "Transcribing" — this is the phase most often mistaken for
// a hang, and the one `phase` was carried through the store for.
if (view.phase === "loading-model") return t("mediaStage.downloadingModel");
if (view.phase === "loading-model") {
return isModelDownloadInFlight(view)
? t("mediaStage.downloadingModel")
: t("mediaStage.initializingModel");
}
// Transcribing a long recording runs for minutes. A bare
// "Transcribing…" for that whole time is indistinguishable from a
// hang, so append the percentage as soon as the main process reports
Expand Down
23 changes: 23 additions & 0 deletions src/components/ai-edition/transcriptionBusyLabel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import type { AssetTranscriptionView } from "@/lib/ai-edition/transcription/status";
import { transcriptionBusyLabel } from "./transcriptionBusyLabel";

function view(extra: Partial<AssetTranscriptionView> = {}): AssetTranscriptionView {
return { assetId: "a", status: "running", phase: "loading-model", ...extra };
}

describe("Captions/Transcript busy copy", () => {
it("does not use captions.transcribing while pending on loading-model", () => {
const label = transcriptionBusyLabel(view(), (v) =>
v.phase === "loading-model" ? "mediaStage.initializingModel" : "captions.transcribing",
);
expect(label).toBe("mediaStage.initializingModel");
expect(label).not.toContain("captions.transcribing");
});

it("returns null when idle so the pane can keep its transcribe verb", () => {
expect(
transcriptionBusyLabel(view({ status: "idle", phase: undefined }), () => "x"),
).toBeNull();
});
});
13 changes: 13 additions & 0 deletions src/components/ai-edition/transcriptionBusyLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { AssetTranscriptionView } from "@/lib/ai-edition/transcription/status";

/**
* Copy for a boolean spinner (Captions / Transcript / Source transcript).
* Returns null when nothing is in flight so the caller can keep its idle verb.
*/
export function transcriptionBusyLabel(
view: AssetTranscriptionView | undefined,
labelOf: (view: AssetTranscriptionView) => string,
): string | null {
if (!view || (view.status !== "running" && view.status !== "queued")) return null;
return labelOf(view);
}
2 changes: 1 addition & 1 deletion src/components/ai-edition/v4/MediaStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ export function MediaStage({
) : (
<span style={{ color: "var(--muted)" }}>
{selectedBusy
? t("mediaStage.transcribingEllipsis")
? transcriptionLabel(selectedTranscription)
: selectedTranscription.status === "failed"
? t("mediaStage.generationFailedHint")
: t("mediaStage.notGeneratedHint")}
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ar/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"notGeneratedHint": "لم يُنشأ بعد — اختر لغة وانقر على إعادة الإنشاء.",
"transcribing": "جارٍ النسخ",
"downloadingModel": "جارٍ تنزيل نموذج الكلام",
"initializingModel": "جارٍ تهيئة نموذج الكلام",
"transcribingEllipsis": "جارٍ النسخ…",
"pendingTranscription": "بانتظار النسخ",
"transcriptionFailed": "فشل النسخ",
Expand Down
1 change: 0 additions & 1 deletion src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@
"show": "إظهار الترجمة",
"noTranscript": "تُقرأ الترجمة من نص الوسائط. فرّغ نص هذا الفيديو لتفعيلها.",
"transcribe": "تفريغ نص الفيديو",
"transcribing": "جارٍ التفريغ…",
"derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.",
"hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.",
"legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
"transcriptReady": "Transcript ready",
"transcribing": "Transcribing",
"downloadingModel": "Downloading speech model",
"initializingModel": "Starting speech model",
"transcribingEllipsis": "Transcribing…",
"pendingTranscription": "Pending transcription",
"transcriptionFailed": "Transcription failed",
Expand Down
1 change: 0 additions & 1 deletion src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,6 @@
"show": "Show captions",
"noTranscript": "Captions are read from the media transcript. Transcribe this video to turn them on.",
"transcribe": "Transcribe video",
"transcribing": "Transcribing…",
"derivedFromTranscript": "{{count}} caption lines, derived live from the transcript.",
"hiddenHint": "Captions come from this media's transcript. Turn them on to see them in the preview and in exports.",
"legacyAnnotations": "This project still contains caption annotations from the old captions feature ({{count}}). They render on top of the caption layer.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"notGeneratedHint": "Aún no generada — elige un idioma y haz clic en regenerar.",
"transcribing": "Transcribiendo",
"downloadingModel": "Descargando modelo de voz",
"initializingModel": "Iniciando el modelo de voz",
"transcribingEllipsis": "Transcribiendo…",
"pendingTranscription": "Transcripción pendiente",
"transcriptionFailed": "Error de transcripción",
Expand Down
Loading
Loading