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/Modals.tsx b/src/components/ai-edition/Modals.tsx index aeab7b1fa..f74c68a78 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -10,6 +10,13 @@ import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { formatSeconds } from "@/lib/ai-edition/timeline/format"; +import { + cropDraftFromRegion, + cropDraftToPct, + displayPct, + previewBoxStyle, + stepPct, +} from "./cropDraft"; import styles from "./NewEditorShell.module.css"; import type { VideoSource } from "./VirtualPreview"; @@ -596,11 +603,19 @@ function CropField({ label, value, onChange, + step, }: { label: string; value: number; onChange: (n: number) => void; + step: number; }) { + // While the field is focused the user's raw text is the value: rendering + // `displayPct(value)` on a controlled input would rewrite "25." to "25" on + // every keystroke, making decimals untypable. The buffer seeds from the + // UNROUNDED stored value so native stepper arrows step from the exact + // state, not the rounded display; two-decimal formatting happens on blur. + const [draft, setDraft] = useState(null); return (