From 7e1f94fa32bb32ac8a49d2c2ce71e5aba54b0eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:48:21 +0800 Subject: [PATCH 1/2] fix(stt): distinguish initializing from transcribing on every spinner --- .../ai-edition/CaptionsPane.gating.test.tsx | 4 +- src/components/ai-edition/CaptionsPane.tsx | 13 +++++- src/components/ai-edition/NewEditorShell.tsx | 1 + src/components/ai-edition/RightPanes.tsx | 30 ++++++++++-- .../ai-edition/TranscriptPane.gating.test.tsx | 3 +- .../ai-edition/TranscriptionStatus.test.tsx | 19 ++++++-- .../ai-edition/TranscriptionStatus.tsx | 7 ++- .../ai-edition/transcriptionBusyLabel.test.ts | 23 ++++++++++ .../ai-edition/transcriptionBusyLabel.ts | 13 ++++++ src/components/ai-edition/v4/MediaStage.tsx | 2 +- src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/ar/settings.json | 1 - src/i18n/locales/en/editor.json | 1 + src/i18n/locales/en/settings.json | 1 - src/i18n/locales/es/editor.json | 1 + src/i18n/locales/es/settings.json | 1 - src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/fr/settings.json | 1 - src/i18n/locales/it/editor.json | 1 + src/i18n/locales/it/settings.json | 1 - src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ja-JP/settings.json | 1 - src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/ko-KR/settings.json | 1 - src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/pt-BR/settings.json | 1 - src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/ru/settings.json | 1 - src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/tr/settings.json | 1 - src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/vi/settings.json | 1 - src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 - src/i18n/locales/zh-TW/editor.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 - .../ai-edition/document/transcribe.test.ts | 46 +++++++++++++++++++ src/lib/ai-edition/document/transcribe.ts | 13 +++++- .../ai-edition/store/transcriptionStore.ts | 6 +++ .../ai-edition/transcription/status.test.ts | 18 ++++++++ src/lib/ai-edition/transcription/status.ts | 24 ++++++++++ src/lib/captioning/transcribe.ts | 4 ++ 42 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 src/components/ai-edition/transcriptionBusyLabel.test.ts create mode 100644 src/components/ai-edition/transcriptionBusyLabel.ts diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx index cae76b59a..097a1bf58 100644 --- a/src/components/ai-edition/CaptionsPane.gating.test.tsx +++ b/src/components/ai-edition/CaptionsPane.gating.test.tsx @@ -107,7 +107,9 @@ describe("captions pane gating", () => { , ); - 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("kills the retry on a media with no audio track and explains it", () => { diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx index 6d6a776c9..a54cb25d1 100644 --- a/src/components/ai-edition/CaptionsPane.tsx +++ b/src/components/ai-edition/CaptionsPane.tsx @@ -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 { firstBusyView } 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. */ @@ -98,7 +102,14 @@ 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"; + const busyLabel = transcriptionBusyLabel( + firstBusyView(Object.values(transcriptions)) ?? + (isTranscribing ? { assetId: "", status: "running", phase: "loading-model" } : undefined), + transcriptionLabel, + ); const silentMedia = gate.state === "blocked" && gate.reason === "no-audio"; const engineError = gate.state === "blocked" && gate.reason === "failed" ? gate.message : null; @@ -232,7 +243,7 @@ export function CaptionsPane() { onClick={() => void requestTimelineTranscripts()} > {isTranscribing ? : null} - {isTranscribing ? t("captions.transcribing") : t("captions.transcribe")} + {busyLabel ?? t("captions.transcribe")} ) : ( diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b371d100c..5f9db0166 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1148,6 +1148,7 @@ export function NewEditorShell() { assets: document?.assets ?? [], trimRanges: document?.timeline?.trimRanges ?? [], busyAssetIds, + transcriptions, onSeek: handleSeek, onAddTrimRange: handleAddTrimRange, onRemoveTrimRange: handleRemoveTrimRange, diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 67ed67aa6..41ac3aa97 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -62,7 +62,11 @@ 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, + firstBusyView, + 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"; @@ -81,6 +85,8 @@ import { getAspectRatioLabel, } from "@/utils/aspectRatioUtils"; import styles from "./NewEditorShell.module.css"; +import { useTranscriptionLabel } from "./TranscriptionStatus"; +import { transcriptionBusyLabel } from "./transcriptionBusyLabel"; interface PaneProps { title: string; @@ -641,6 +647,7 @@ export function TranscriptPane({ assets, trimRanges, busyAssetIds, + transcriptions, onSeek, onAddTrimRange, onRemoveTrimRange, @@ -659,6 +666,7 @@ 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; onSeek: (sec: number) => void; onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void; onRemoveTrimRange: (trimId: string) => void; @@ -706,6 +714,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( + firstBusyView(Object.values(transcriptions ?? {})) ?? + (isTranscribing ? { assetId: "", status: "running", phase: "loading-model" } : undefined), + transcriptionLabel, + ); if (clips.length === 0 || !hasAnyTranscript) { return ( @@ -731,7 +745,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")} @@ -749,7 +763,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")} @@ -768,6 +782,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} @@ -795,6 +813,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ index, section, busy, + busyLabel, cueWordId, onSeek, onAddTrimRange, @@ -803,6 +822,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; @@ -1095,7 +1115,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ }} > - {ts("transcript.transcribing")} + {busyLabel ?? ts("transcript.transcribing")} ) : null} @@ -1109,7 +1129,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ fontStyle: "italic", }} > - {busy ? ts("transcript.transcribing") : ts("transcript.noClipTranscript")} + {busy ? (busyLabel ?? ts("transcript.transcribing")) : ts("transcript.noClipTranscript")}

) : (
{ 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", () => { diff --git a/src/components/ai-edition/TranscriptionStatus.test.tsx b/src/components/ai-edition/TranscriptionStatus.test.tsx index 47f1303e4..4bbd09b49 100644 --- a/src/components/ai-edition/TranscriptionStatus.test.tsx +++ b/src/components/ai-edition/TranscriptionStatus.test.tsx @@ -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", () => { diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx index bae6a0976..2b405441f 100644 --- a/src/components/ai-edition/TranscriptionStatus.tsx +++ b/src/components/ai-edition/TranscriptionStatus.tsx @@ -12,6 +12,7 @@ import { useScopedT } from "@/contexts/I18nContext"; import { type AssetTranscriptionView, isCpuBackend, + isModelDownloadInFlight, progressFraction, realtimeSpeed, } from "@/lib/ai-edition/transcription/status"; @@ -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 diff --git a/src/components/ai-edition/transcriptionBusyLabel.test.ts b/src/components/ai-edition/transcriptionBusyLabel.test.ts new file mode 100644 index 000000000..391118fa3 --- /dev/null +++ b/src/components/ai-edition/transcriptionBusyLabel.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/components/ai-edition/transcriptionBusyLabel.ts b/src/components/ai-edition/transcriptionBusyLabel.ts new file mode 100644 index 000000000..03eccd68e --- /dev/null +++ b/src/components/ai-edition/transcriptionBusyLabel.ts @@ -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); +} diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index a7af81acb..e867c9c2d 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -447,7 +447,7 @@ export function MediaStage({ ) : ( {selectedBusy - ? t("mediaStage.transcribingEllipsis") + ? transcriptionLabel(selectedTranscription) : selectedTranscription.status === "failed" ? t("mediaStage.generationFailedHint") : t("mediaStage.notGeneratedHint")} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 39860c0da..e99718cc0 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "لم يُنشأ بعد — اختر لغة وانقر على إعادة الإنشاء.", "transcribing": "جارٍ النسخ", "downloadingModel": "جارٍ تنزيل نموذج الكلام", + "initializingModel": "جارٍ تهيئة نموذج الكلام", "transcribingEllipsis": "جارٍ النسخ…", "pendingTranscription": "بانتظار النسخ", "transcriptionFailed": "فشل النسخ", diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 2e025e9bd..bdfd5071a 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -275,7 +275,6 @@ "show": "إظهار الترجمة", "noTranscript": "تُقرأ الترجمة من نص الوسائط. فرّغ نص هذا الفيديو لتفعيلها.", "transcribe": "تفريغ نص الفيديو", - "transcribing": "جارٍ التفريغ…", "derivedFromTranscript": "{{count}} سطر ترجمة، مشتقة مباشرةً من النص المفرّغ.", "hiddenHint": "تأتي الترجمة من النص المفرّغ لهذه الوسائط. فعّلها لرؤيتها في المعاينة وفي التصدير.", "legacyAnnotations": "لا يزال هذا المشروع يحتوي على تعليقات ترجمة من الميزة القديمة ({{count}}). تُرسم فوق طبقة الترجمة.", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 7aa5954ce..20c372fbb 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -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", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index f6149858d..049c5fa87 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -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.", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 4d10b36a6..6f254ce84 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -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", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 4628154f6..787a6aa13 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -275,7 +275,6 @@ "show": "Mostrar subtítulos", "noTranscript": "Los subtítulos se leen de la transcripción del recurso. Transcribe este vídeo para activarlos.", "transcribe": "Transcribir vídeo", - "transcribing": "Transcribiendo…", "derivedFromTranscript": "{{count}} líneas de subtítulos, derivadas en vivo de la transcripción.", "hiddenHint": "Los subtítulos provienen de la transcripción de este recurso. Actívalos para verlos en la vista previa y en las exportaciones.", "legacyAnnotations": "Este proyecto todavía contiene anotaciones de subtítulos de la función antigua ({{count}}). Se dibujan encima de la capa de subtítulos.", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 7a719ade2..26a38a1b7 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "Pas encore générée — choisissez une langue et cliquez sur régénérer.", "transcribing": "Transcription en cours", "downloadingModel": "Téléchargement du modèle vocal", + "initializingModel": "Démarrage du modèle vocal", "transcribingEllipsis": "Transcription en cours…", "pendingTranscription": "Transcription en attente", "transcriptionFailed": "Échec de la transcription", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index e9521d9d2..705625628 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -275,7 +275,6 @@ "show": "Afficher les sous-titres", "noTranscript": "Les sous-titres sont issus de la transcription du média. Transcrivez cette vidéo pour les activer.", "transcribe": "Transcrire la vidéo", - "transcribing": "Transcription…", "derivedFromTranscript": "{{count}} lignes de sous-titres, dérivées de la transcription en direct.", "hiddenHint": "Les sous-titres proviennent de la transcription de ce média. Activez-les pour les voir dans l'aperçu et à l'export.", "legacyAnnotations": "Ce projet contient encore des annotations de sous-titres issues de l'ancienne fonctionnalité ({{count}}). Elles s'affichent par-dessus les sous-titres.", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 70a680a7a..0b3d84a46 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "Non ancora generata — scegli una lingua e clicca su rigenera.", "transcribing": "Trascrizione in corso", "downloadingModel": "Download del modello vocale", + "initializingModel": "Avvio del modello vocale", "transcribingEllipsis": "Trascrizione in corso…", "pendingTranscription": "Trascrizione in attesa", "transcriptionFailed": "Trascrizione non riuscita", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index ff13f7495..7882d0338 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -275,7 +275,6 @@ "show": "Mostra sottotitoli", "noTranscript": "I sottotitoli vengono letti dalla trascrizione del media. Trascrivi questo video per attivarli.", "transcribe": "Trascrivi video", - "transcribing": "Trascrizione…", "derivedFromTranscript": "{{count}} righe di sottotitoli, derivate dal vivo dalla trascrizione.", "hiddenHint": "I sottotitoli provengono dalla trascrizione di questo media. Attivali per vederli nell'anteprima e nelle esportazioni.", "legacyAnnotations": "Questo progetto contiene ancora annotazioni di sottotitoli della vecchia funzione ({{count}}). Vengono disegnate sopra il livello dei sottotitoli.", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index bcbc57164..69c4e1325 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "まだ生成されていません — 言語を選んで再生成をクリックしてください。", "transcribing": "文字起こし中", "downloadingModel": "音声モデルをダウンロード中", + "initializingModel": "音声モデルを起動しています", "transcribingEllipsis": "文字起こし中…", "pendingTranscription": "文字起こし待機中", "transcriptionFailed": "文字起こしに失敗しました", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 30a9d0875..0fe9dc129 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -275,7 +275,6 @@ "show": "字幕を表示", "noTranscript": "字幕はメディアの文字起こしから読み込まれます。有効にするにはこの動画を文字起こししてください。", "transcribe": "動画を文字起こし", - "transcribing": "文字起こし中…", "derivedFromTranscript": "文字起こしからリアルタイムに生成された字幕 {{count}} 行。", "hiddenHint": "字幕はこのメディアの文字起こしから生成されます。オンにするとプレビューと書き出しに表示されます。", "legacyAnnotations": "このプロジェクトには旧字幕機能の注釈が残っています({{count}})。字幕レイヤーの上に描画されます。", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 96e6c5339..82d4d72dd 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "아직 생성되지 않음 — 언어를 선택하고 재생성을 클릭하세요.", "transcribing": "받아쓰는 중", "downloadingModel": "음성 모델 다운로드 중", + "initializingModel": "음성 모델 시작 중", "transcribingEllipsis": "받아쓰는 중…", "pendingTranscription": "받아쓰기 대기 중", "transcriptionFailed": "받아쓰기 실패", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 676fe6959..e4ec8b1f1 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -275,7 +275,6 @@ "show": "자막 표시", "noTranscript": "자막은 미디어 전사에서 읽어옵니다. 켜려면 이 동영상을 전사하세요.", "transcribe": "동영상 전사하기", - "transcribing": "전사 중…", "derivedFromTranscript": "전사에서 실시간으로 생성된 자막 {{count}}줄.", "hiddenHint": "자막은 이 미디어의 전사에서 만들어집니다. 켜면 미리보기와 내보내기에서 볼 수 있습니다.", "legacyAnnotations": "이 프로젝트에는 이전 자막 기능의 주석이 남아 있습니다({{count}}개). 자막 레이어 위에 그려집니다.", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index e5f828d3c..59b89d4ca 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "Ainda não gerada — escolha um idioma e clique em regenerar.", "transcribing": "Transcrevendo", "downloadingModel": "Baixando modelo de voz", + "initializingModel": "Iniciando modelo de voz", "transcribingEllipsis": "Transcrevendo…", "pendingTranscription": "Transcrição pendente", "transcriptionFailed": "Falha na transcrição", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index b6d4a78c4..83f1f064d 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -275,7 +275,6 @@ "show": "Mostrar legendas", "noTranscript": "As legendas são lidas da transcrição da mídia. Transcreva este vídeo para ativá-las.", "transcribe": "Transcrever vídeo", - "transcribing": "Transcrevendo…", "derivedFromTranscript": "{{count}} linhas de legenda, derivadas ao vivo da transcrição.", "hiddenHint": "As legendas vêm da transcrição desta mídia. Ative-as para vê-las na prévia e nas exportações.", "legacyAnnotations": "Este projeto ainda contém anotações de legenda do recurso antigo ({{count}}). Elas são desenhadas sobre a camada de legendas.", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 4abdc63f3..03ee75a1c 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "Ещё не создан — выберите язык и нажмите «Пересоздать».", "transcribing": "Расшифровка", "downloadingModel": "Загрузка речевой модели", + "initializingModel": "Запуск речевой модели", "transcribingEllipsis": "Расшифровка…", "pendingTranscription": "Ожидает расшифровки", "transcriptionFailed": "Ошибка расшифровки", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index d9c06b71c..6bff44cd7 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -275,7 +275,6 @@ "show": "Показывать субтитры", "noTranscript": "Субтитры берутся из расшифровки медиафайла. Расшифруйте это видео, чтобы включить их.", "transcribe": "Расшифровать видео", - "transcribing": "Расшифровка…", "derivedFromTranscript": "{{count}} строк субтитров, получены из расшифровки в реальном времени.", "hiddenHint": "Субтитры берутся из расшифровки этого медиафайла. Включите их, чтобы видеть в предпросмотре и в экспорте.", "legacyAnnotations": "В проекте ещё остались аннотации субтитров от старой функции ({{count}}). Они рисуются поверх слоя субтитров.", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index d4bb46a10..e7ead7827 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "Henüz oluşturulmadı — bir dil seçin ve yeniden oluştur'a tıklayın.", "transcribing": "Metne dökülüyor", "downloadingModel": "Konuşma modeli indiriliyor", + "initializingModel": "Konuşma modeli başlatılıyor", "transcribingEllipsis": "Metne dökülüyor…", "pendingTranscription": "Metne dökme bekliyor", "transcriptionFailed": "Metne dökme başarısız oldu", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index c593e4db7..a890775f5 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -275,7 +275,6 @@ "show": "Altyazıları göster", "noTranscript": "Altyazılar medyanın dökümünden okunur. Açmak için bu videonun dökümünü çıkarın.", "transcribe": "Videonun dökümünü çıkar", - "transcribing": "Döküm çıkarılıyor…", "derivedFromTranscript": "{{count}} altyazı satırı, dökümden canlı olarak türetildi.", "hiddenHint": "Altyazılar bu medyanın dökümünden gelir. Önizlemede ve dışa aktarımlarda görmek için açın.", "legacyAnnotations": "Bu proje hâlâ eski altyazı özelliğinden kalan altyazı açıklamaları içeriyor ({{count}}). Altyazı katmanının üstünde çizilirler.", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 6f55d77a5..c3a090da2 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "Chưa được tạo — chọn ngôn ngữ và nhấp vào tạo lại.", "transcribing": "Đang phiên âm", "downloadingModel": "Đang tải mô hình giọng nói", + "initializingModel": "Đang khởi động mô hình giọng nói", "transcribingEllipsis": "Đang phiên âm…", "pendingTranscription": "Đang chờ phiên âm", "transcriptionFailed": "Phiên âm thất bại", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 176f13eb6..ce1790ba4 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -275,7 +275,6 @@ "show": "Hiện phụ đề", "noTranscript": "Phụ đề được lấy từ bản chép lời của media. Hãy chép lời video này để bật phụ đề.", "transcribe": "Chép lời video", - "transcribing": "Đang chép lời…", "derivedFromTranscript": "{{count}} dòng phụ đề, được tạo trực tiếp từ bản chép lời.", "hiddenHint": "Phụ đề đến từ bản chép lời của media này. Bật lên để thấy chúng trong bản xem trước và khi xuất.", "legacyAnnotations": "Dự án này vẫn còn chú thích phụ đề từ tính năng phụ đề cũ ({{count}}). Chúng hiển thị đè lên lớp phụ đề.", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index dcfeb282b..551f8beb9 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "尚未生成 — 选择语言并点击重新生成。", "transcribing": "正在转录", "downloadingModel": "正在下载语音模型", + "initializingModel": "正在启动语音模型", "transcribingEllipsis": "正在转录…", "pendingTranscription": "等待转录", "transcriptionFailed": "转录失败", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 19f231703..10ac0c119 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -275,7 +275,6 @@ "show": "显示字幕", "noTranscript": "字幕来自媒体的转录。请先转录此视频以启用字幕。", "transcribe": "转录视频", - "transcribing": "转录中…", "derivedFromTranscript": "{{count}} 行字幕,实时由转录生成。", "hiddenHint": "字幕来自此媒体的转录。开启后可在预览和导出中看到。", "legacyAnnotations": "此项目仍包含旧字幕功能生成的字幕批注({{count}} 条)。它们会绘制在字幕图层之上。", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 4893e4caf..2839ac57c 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -145,6 +145,7 @@ "notGeneratedHint": "尚未產生 — 選擇語言並點擊重新產生。", "transcribing": "轉錄中", "downloadingModel": "正在下載語音模型", + "initializingModel": "正在啟動語音模型", "transcribingEllipsis": "轉錄中…", "pendingTranscription": "等待轉錄", "transcriptionFailed": "轉錄失敗", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index e2bc412e9..4a9a2fc43 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -276,7 +276,6 @@ "show": "顯示字幕", "noTranscript": "字幕取自媒體的逐字稿。請先為這部影片產生逐字稿以啟用字幕。", "transcribe": "為影片產生逐字稿", - "transcribing": "轉錄中…", "derivedFromTranscript": "{{count}} 行字幕,即時由逐字稿產生。", "hiddenHint": "字幕來自這個媒體的逐字稿。開啟後即可在預覽與匯出中看到。", "legacyAnnotations": "此專案仍含有舊字幕功能留下的字幕註解({{count}} 個)。它們會繪製在字幕圖層之上。", diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts index 034abf212..648fed50b 100644 --- a/src/lib/ai-edition/document/transcribe.test.ts +++ b/src/lib/ai-edition/document/transcribe.test.ts @@ -53,6 +53,52 @@ function makeDoc(): AxcutDocument { }; } +describe("transcribeAsset status sequence", () => { + it("does not emit transcribing between extract and the worker", async () => { + const phases: string[] = []; + transcribeMock.mockImplementationOnce(async (_samples, options) => { + options?.onStatus?.({ phase: "model" }); + options?.onStatus?.({ + phase: "transcribe", + completedSec: 1, + totalSec: 10, + }); + return { segments: [], granularity: "phrase" as const, detectedLanguage: "en" }; + }); + + await transcribeAsset(makeDoc(), "asset_1", { + onStatus: (status) => phases.push(status.phase), + }); + + expect(phases[0]).toBe("extracting-audio"); + expect(phases[1]).toBe("loading-model"); + expect(phases.indexOf("transcribing")).toBeGreaterThan(1); + expect(phases.slice(0, phases.indexOf("transcribing"))).not.toContain("transcribing"); + }); + + it("forwards model download bytes onto loading-model status", async () => { + const events: Array<{ phase: string; downloadedBytes?: number; totalBytes?: number }> = []; + transcribeMock.mockImplementationOnce(async (_samples, options) => { + options?.onStatus?.({ + phase: "model", + downloadedBytes: 10, + totalBytes: 100, + }); + return { segments: [], granularity: "phrase" as const, detectedLanguage: "en" }; + }); + + await transcribeAsset(makeDoc(), "asset_1", { + onStatus: (status) => events.push(status), + }); + + expect(events).toContainEqual({ + phase: "loading-model", + downloadedBytes: 10, + totalBytes: 100, + }); + }); +}); + describe("transcribeAsset language handling", () => { it("forwards a forced language to the worker and stores it on the transcript", async () => { transcribeMock.mockResolvedValueOnce({ diff --git a/src/lib/ai-edition/document/transcribe.ts b/src/lib/ai-edition/document/transcribe.ts index 04d170718..dcdfbc650 100644 --- a/src/lib/ai-edition/document/transcribe.ts +++ b/src/lib/ai-edition/document/transcribe.ts @@ -22,6 +22,10 @@ export interface TranscribeStatus { backend?: string; /** Real-time factor for the run so far — wall-clock / audio, lower is faster. */ rtf?: number; + /** Bytes of the speech model fetched so far. Only during `"loading-model"`. */ + downloadedBytes?: number; + /** Total bytes of the in-flight model download. */ + totalBytes?: number; } export interface TranscribeAssetOptions { @@ -47,7 +51,10 @@ export async function transcribeAsset( signal: options.signal, }); - options.onStatus?.({ phase: "transcribing" }); + // Stay on loading-model until the main process reports inference chunks. + // Emitting "transcribing" here used to label the cold `server.start()` wait + // as if recognition had begun. + options.onStatus?.({ phase: "loading-model" }); // Only pass `language` to the worker when the caller forced a specific // code. `"auto"` (or any falsy value) leaves Whisper to detect from // the audio. The pipeline tags every chunk with the language it used @@ -72,6 +79,10 @@ export async function transcribeAsset( // case a user cannot otherwise diagnose. backend: status.backend, rtf: status.rtf, + ...(status.downloadedBytes !== undefined + ? { downloadedBytes: status.downloadedBytes } + : {}), + ...(status.totalBytes !== undefined ? { totalBytes: status.totalBytes } : {}), }), }); diff --git a/src/lib/ai-edition/store/transcriptionStore.ts b/src/lib/ai-edition/store/transcriptionStore.ts index 64d910b13..4e55bca3e 100644 --- a/src/lib/ai-edition/store/transcriptionStore.ts +++ b/src/lib/ai-edition/store/transcriptionStore.ts @@ -59,6 +59,8 @@ export interface TranscriptionJob { */ backend?: string; rtf?: number; + downloadedBytes?: number; + totalBytes?: number; /** `"auto"` unless the user forced a language from the media card. */ language: string; failure?: TranscriptionFailure; @@ -386,6 +388,10 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { // first chunk (audio extraction, the model download) carry neither. ...(status.backend !== undefined ? { backend: status.backend } : {}), ...(status.rtf !== undefined ? { rtf: status.rtf } : {}), + ...(status.downloadedBytes !== undefined + ? { downloadedBytes: status.downloadedBytes } + : {}), + ...(status.totalBytes !== undefined ? { totalBytes: status.totalBytes } : {}), }), }); if (controller.signal.aborted) { diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index 00261712e..181f83d96 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -4,7 +4,9 @@ import { type AssetTranscriptionView, classifyTranscriptionError, deriveAssetStatus, + firstBusyView, isCpuBackend, + isModelDownloadInFlight, isPermanentFailure, progressFraction, realtimeSpeed, @@ -325,6 +327,22 @@ describe("isCpuBackend", () => { }); }); +describe("model download bytes", () => { + it("is in-flight only when totalBytes is positive and download is incomplete", () => { + expect(isModelDownloadInFlight({ downloadedBytes: 10, totalBytes: 100 })).toBe(true); + expect(isModelDownloadInFlight({ downloadedBytes: 100, totalBytes: 100 })).toBe(false); + expect(isModelDownloadInFlight({})).toBe(false); + }); + + it("prefers a running view over a queued one for pane copy", () => { + const busy = firstBusyView([ + view("a", "queued"), + { assetId: "b", status: "running", phase: "loading-model" }, + ]); + expect(busy?.assetId).toBe("b"); + }); +}); + describe("deriveAssetStatus carries the engine's own report", () => { // Both facts come from the main process on the chunk status events, and the // view is the only thing the three status surfaces read. diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index 64fdb411c..b74f745cb 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -66,6 +66,22 @@ export function isCpuBackend(backend: string | undefined): boolean { return backend === "whispercpp-cpu"; } +/** True while a model file is still arriving — not merely "the model is loading". */ +export function isModelDownloadInFlight(view: { + downloadedBytes?: number; + totalBytes?: number; +}): boolean { + return (view.totalBytes ?? 0) > 0 && (view.downloadedBytes ?? 0) < (view.totalBytes ?? 0); +} + +/** First running job, else first queued one — the pane spinner's source of copy. */ +export function firstBusyView( + views: Iterable, +): AssetTranscriptionView | undefined { + const list = [...views]; + return list.find((v) => v.status === "running") ?? list.find((v) => v.status === "queued"); +} + /** * A media that has no audio track (or one Whisper cannot read) will fail the * same way on every attempt, so that verdict is worth remembering: it is @@ -128,6 +144,10 @@ export interface AssetTranscriptionView { backend?: string; /** Real-time factor for the run so far; pair with `realtimeSpeed()` to display. */ rtf?: number; + /** Bytes of the speech model fetched so far. Only during `"loading-model"`. */ + downloadedBytes?: number; + /** Total bytes of the in-flight model download. */ + totalBytes?: number; } /** In-flight (or last-failed) state of one asset's job. Mirrors the store entry. */ @@ -138,6 +158,8 @@ export interface TranscriptionJobLike { failure?: TranscriptionFailure; backend?: string; rtf?: number; + downloadedBytes?: number; + totalBytes?: number; } export function findAssetTranscript( @@ -187,6 +209,8 @@ export function deriveAssetStatus(input: { progress: job.progress, backend: job.backend, rtf: job.rtf, + downloadedBytes: job.downloadedBytes, + totalBytes: job.totalBytes, }; } if (transcript) { diff --git a/src/lib/captioning/transcribe.ts b/src/lib/captioning/transcribe.ts index 3ca985649..a8c6ec3e9 100644 --- a/src/lib/captioning/transcribe.ts +++ b/src/lib/captioning/transcribe.ts @@ -45,6 +45,10 @@ export interface SttRendererStatus { backend?: string; /** Real-time factor for the run so far — wall-clock / audio, lower is faster. */ rtf?: number; + /** Bytes of the speech model fetched so far. Only during `"model"`. */ + downloadedBytes?: number; + /** Total bytes of the in-flight model download. */ + totalBytes?: number; } interface RendererSttApi { From 564b3ee02c0db15742121182b63be6c9ce539509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:39:09 +0800 Subject: [PATCH 2/2] fix(stt): keep busy labels timeline-scoped and visible during regeneration --- .../ai-edition/CaptionsPane.gating.test.tsx | 27 ++++ src/components/ai-edition/CaptionsPane.tsx | 18 ++- src/components/ai-edition/NewEditorShell.tsx | 8 ++ src/components/ai-edition/RightPanes.tsx | 9 +- .../ai-edition/transcription/status.test.ts | 115 +++++++++++------- src/lib/ai-edition/transcription/status.ts | 18 +++ 6 files changed, 142 insertions(+), 53 deletions(-) diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx index 097a1bf58..91ef8dbd4 100644 --- a/src/components/ai-edition/CaptionsPane.gating.test.tsx +++ b/src/components/ai-edition/CaptionsPane.gating.test.tsx @@ -112,6 +112,33 @@ describe("captions pane gating", () => { 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( + + + , + ); + expect(screen.getByRole("button", { name: "Transcribe video" })).toBeEnabled(); + }); + it("kills the retry on a media with no audio track and explains it", () => { load( documentWith({ diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx index a54cb25d1..b425c2ff3 100644 --- a/src/components/ai-edition/CaptionsPane.tsx +++ b/src/components/ai-edition/CaptionsPane.tsx @@ -25,7 +25,7 @@ import { useTranscriptionStore, } from "@/lib/ai-edition/store/transcriptionStore"; import { useCaptions } from "@/lib/ai-edition/store/useCaptions"; -import { firstBusyView } from "@/lib/ai-edition/transcription/status"; +import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status"; import { nativeBridgeClient } from "@/native"; import { ColorField } from "./ColorField"; import styles from "./NewEditorShell.module.css"; @@ -105,8 +105,11 @@ export function CaptionsPane() { 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( - firstBusyView(Object.values(transcriptions)) ?? + firstTimelineBusyView(document, transcriptions) ?? (isTranscribing ? { assetId: "", status: "running", phase: "loading-model" } : undefined), transcriptionLabel, ); @@ -256,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"))}

)} diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 5f9db0166..b2c41aa31 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -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"; @@ -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 @@ -1149,6 +1156,7 @@ export function NewEditorShell() { trimRanges: document?.timeline?.trimRanges ?? [], busyAssetIds, transcriptions, + busyView: timelineBusyView, onSeek: handleSeek, onAddTrimRange: handleAddTrimRange, onRemoveTrimRange: handleRemoveTrimRange, diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 41ac3aa97..cbcf1b801 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -64,7 +64,6 @@ import { formatMs } from "@/lib/ai-edition/timeline/format"; import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview"; import { type AssetTranscriptionView, - firstBusyView, type TranscriptGateReason, } from "@/lib/ai-edition/transcription/status"; import { getAssetPath } from "@/lib/assetPath"; @@ -648,6 +647,7 @@ export function TranscriptPane({ trimRanges, busyAssetIds, transcriptions, + busyView, onSeek, onAddTrimRange, onRemoveTrimRange, @@ -667,6 +667,11 @@ export function TranscriptPane({ * background pass, with nothing on screen to say why. */ busyAssetIds: readonly string[]; transcriptions?: Record; + /** 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; @@ -716,7 +721,7 @@ export function TranscriptPane({ const silentMedia = blocked?.reason === "no-audio"; const transcriptionLabel = useTranscriptionLabel(); const paneBusyLabel = transcriptionBusyLabel( - firstBusyView(Object.values(transcriptions ?? {})) ?? + busyView ?? (isTranscribing ? { assetId: "", status: "running", phase: "loading-model" } : undefined), transcriptionLabel, ); diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index 181f83d96..e40636dee 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -5,6 +5,7 @@ import { classifyTranscriptionError, deriveAssetStatus, firstBusyView, + firstTimelineBusyView, isCpuBackend, isModelDownloadInFlight, isPermanentFailure, @@ -232,53 +233,53 @@ describe("resolveTranscriptGate", () => { }); }); -describe("transcriptRelevantAssetIds", () => { - const base = { - schemaVersion: 7 as const, - project: { - id: "proj_1", - title: "T", - createdAt: "2026-06-25T10:00:00.000Z", - updatedAt: "2026-06-25T10:00:00.000Z", - }, - transcript: null, - transcripts: [], - annotations: [], - zoomRanges: [], - legacyEditor: null, - }; - - function doc(assetIds: string[], clipAssetIds: string[]): AxcutDocument { - return { - ...base, - assets: assetIds.map((id) => ({ - id, - kind: "video" as const, - label: id, - originalPath: `/tmp/${id}.mp4`, - cameraTrack: null, +const base = { + schemaVersion: 7 as const, + project: { + id: "proj_1", + title: "T", + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T10:00:00.000Z", + }, + transcript: null, + transcripts: [], + annotations: [], + zoomRanges: [], + legacyEditor: null, +}; + +function doc(assetIds: string[], clipAssetIds: string[]): AxcutDocument { + return { + ...base, + assets: assetIds.map((id) => ({ + id, + kind: "video" as const, + label: id, + originalPath: `/tmp/${id}.mp4`, + cameraTrack: null, + })), + timeline: { + clips: clipAssetIds.map((assetId, i) => ({ + id: `clip_${i}`, + assetId, + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: i * 10, + timelineEndSec: i * 10 + 10, + wordRefs: [], + origin: "system" as const, + reason: "", })), - timeline: { - clips: clipAssetIds.map((assetId, i) => ({ - id: `clip_${i}`, - assetId, - sourceStartSec: 0, - sourceEndSec: 10, - timelineStartSec: i * 10, - timelineEndSec: i * 10 + 10, - wordRefs: [], - origin: "system" as const, - reason: "", - })), - gaps: [], - trimRanges: [], - muteRanges: [], - speedRanges: [], - captionRanges: [], - }, - } as AxcutDocument; - } + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + } as AxcutDocument; +} +describe("transcriptRelevantAssetIds", () => { it("only counts the assets the timeline plays", () => { expect(transcriptRelevantAssetIds(doc(["a", "b"], ["a", "a"]))).toEqual(["a"]); }); @@ -296,6 +297,30 @@ describe("transcriptRelevantAssetIds", () => { }); }); +describe("firstTimelineBusyView", () => { + it("ignores a busy job on an asset the timeline does not play", () => { + // "b" is in the bin and mid-transcription, but the timeline only plays + // "a" — the timeline-scoped label must stay idle, like the gate does. + const views = { b: view("b", "running") }; + expect(firstTimelineBusyView(doc(["a", "b"], ["a"]), views)).toBeUndefined(); + }); + + it("reports a busy job on a timeline asset", () => { + const views = { a: view("a", "running"), b: view("b", "running") }; + expect(firstTimelineBusyView(doc(["a", "b"], ["a"]), views)?.assetId).toBe("a"); + }); + + it("keeps the empty-timeline fallback: whole-bin jobs count", () => { + const views = { b: view("b", "queued") }; + expect(firstTimelineBusyView(doc(["a", "b"], []), views)?.assetId).toBe("b"); + }); + + it("is quiet when nothing relevant is busy", () => { + const views = { a: view("a", "ready") }; + expect(firstTimelineBusyView(doc(["a"], ["a"]), views)).toBeUndefined(); + }); +}); + describe("realtimeSpeed", () => { // The engine reports RTF (wall-clock / audio, lower is faster); the UI shows // its reciprocal, which is the figure the POC report headlines. diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index b74f745cb..8bc98b489 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -82,6 +82,24 @@ export function firstBusyView( return list.find((v) => v.status === "running") ?? list.find((v) => v.status === "queued"); } +/** + * First busy view among the assets the timeline actually plays — the same + * scope every transcript-dependent gate uses (`transcriptRelevantAssetIds`). + * A job on an off-timeline asset must not relabel a timeline-scoped button + * that stays enabled: label and gate have to answer about the same assets. + */ +export function firstTimelineBusyView( + document: AxcutDocument | null, + views: Record, +): AssetTranscriptionView | undefined { + const relevant: AssetTranscriptionView[] = []; + for (const id of transcriptRelevantAssetIds(document)) { + const view = views[id]; + if (view) relevant.push(view); + } + return firstBusyView(relevant); +} + /** * A media that has no audio track (or one Whisper cannot read) will fail the * same way on every attempt, so that verdict is worth remembering: it is