diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx
index cae76b59a..91ef8dbd4 100644
--- a/src/components/ai-edition/CaptionsPane.gating.test.tsx
+++ b/src/components/ai-edition/CaptionsPane.gating.test.tsx
@@ -107,7 +107,36 @@ 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("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", () => {
diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx
index 6d6a776c9..b425c2ff3 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 { 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. */
@@ -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),
+ transcriptionLabel,
+ );
const silentMedia = gate.state === "blocked" && gate.reason === "no-audio";
const engineError = gate.state === "blocked" && gate.reason === "failed" ? gate.message : null;
@@ -232,7 +246,7 @@ export function CaptionsPane() {
onClick={() => void requestTimelineTranscripts()}
>
{isTranscribing ? : null}
- {isTranscribing ? t("captions.transcribing") : t("captions.transcribe")}
+ {busyLabel ?? t("captions.transcribe")}
) : (
@@ -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"))}
)}
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index b371d100c..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
@@ -1148,6 +1155,8 @@ export function NewEditorShell() {
assets: document?.assets ?? [],
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 67ed67aa6..cbcf1b801 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -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";
@@ -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;
@@ -641,6 +646,8 @@ export function TranscriptPane({
assets,
trimRanges,
busyAssetIds,
+ transcriptions,
+ busyView,
onSeek,
onAddTrimRange,
onRemoveTrimRange,
@@ -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;
+ /** 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;
@@ -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 (
@@ -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")}
@@ -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")}
@@ -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}
@@ -795,6 +818,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
index,
section,
busy,
+ busyLabel,
cueWordId,
onSeek,
onAddTrimRange,
@@ -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;
@@ -1095,7 +1120,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
}}
>
- {ts("transcript.transcribing")}
+ {busyLabel ?? ts("transcript.transcribing")}
) : null}
@@ -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")}
) : (
{
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..e40636dee 100644
--- a/src/lib/ai-edition/transcription/status.test.ts
+++ b/src/lib/ai-edition/transcription/status.test.ts
@@ -4,7 +4,10 @@ import {
type AssetTranscriptionView,
classifyTranscriptionError,
deriveAssetStatus,
+ firstBusyView,
+ firstTimelineBusyView,
isCpuBackend,
+ isModelDownloadInFlight,
isPermanentFailure,
progressFraction,
realtimeSpeed,
@@ -230,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"]);
});
@@ -294,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.
@@ -325,6 +352,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..8bc98b489 100644
--- a/src/lib/ai-edition/transcription/status.ts
+++ b/src/lib/ai-edition/transcription/status.ts
@@ -66,6 +66,40 @@ 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");
+}
+
+/**
+ * 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
@@ -128,6 +162,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 +176,8 @@ export interface TranscriptionJobLike {
failure?: TranscriptionFailure;
backend?: string;
rtf?: number;
+ downloadedBytes?: number;
+ totalBytes?: number;
}
export function findAssetTranscript(
@@ -187,6 +227,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 {