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

Filter by extension

Filter by extension

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

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

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

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

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

Expand Down
136 changes: 88 additions & 48 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<label
Expand All @@ -615,10 +630,17 @@ function CropField({
</label>
<input
type="number"
value={value}
value={draft ?? displayPct(value)}
min={0}
max={100}
onChange={(e) => onChange(Number(e.target.value))}
step={step}
onFocus={() => setDraft(String(value))}
onBlur={() => setDraft(null)}
onChange={(e) => {
setDraft(e.target.value);
const parsed = Number(e.target.value);
if (e.target.value !== "" && Number.isFinite(parsed)) onChange(parsed);
}}
style={{
width: "100%",
padding: "8px 10px",
Expand Down Expand Up @@ -687,6 +709,7 @@ export function EditClipModal({
// fraction-of-frame width/height. 16/9 is just a placeholder until the
// crop <video>'s real metadata loads (see the effect below).
const [videoAspectRatio, setVideoAspectRatio] = useState(16 / 9);
const [frameSizePx, setFrameSizePx] = useState({ width: 0, height: 0 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const cropFrameRef = useRef<HTMLDivElement | null>(null);
const cropVideoRef = useRef<HTMLVideoElement | null>(null);

Expand All @@ -698,10 +721,11 @@ export function EditClipModal({
setDraftEnd(clip.sourceEndSec ?? clip.sourceStartSec);
setActiveEdge(null);
const region = clip.cropRegion ?? IDENTITY_CROP;
setCropXPct(Math.round(region.x * 100));
setCropYPct(Math.round(region.y * 100));
setCropWPct(Math.round(region.width * 100));
setCropHPct(Math.round(region.height * 100));
const pct = cropDraftToPct(cropDraftFromRegion(region));
setCropXPct(pct.x);
setCropYPct(pct.y);
setCropWPct(pct.w);
setCropHPct(pct.h);
setCropTouched(false);
}, [open, clip]);

Expand All @@ -722,13 +746,22 @@ export function EditClipModal({
// clip's original in-point, not on every trim drag.
useEffect(() => {
if (!open || !clip) return;
// A clip switch must not leave the previous clip's dimensions live: an
// immediate preset or typed edit would quantize against the wrong
// resolution and `cropTouched` would lock the wrong aspect in. Reset to
// the same defaults a fresh dialog starts with; the metadata handler
// below refills them (immediately, when this clip's metadata is already
// loaded).
setVideoAspectRatio(16 / 9);
setFrameSizePx({ width: 0, height: 0 });
const v = cropVideoRef.current;
if (!v) return;
const seek = () => {
v.pause();
if (Number.isFinite(clip.sourceStartSec)) v.currentTime = clip.sourceStartSec;
if (v.videoWidth > 0 && v.videoHeight > 0) {
setVideoAspectRatio(v.videoWidth / v.videoHeight);
setFrameSizePx({ width: v.videoWidth, height: v.videoHeight });
}
};
if (v.readyState >= 1) seek();
Expand Down Expand Up @@ -785,10 +818,10 @@ export function EditClipModal({
// field/handle logic below, keeps every later edit at that ratio).
if (!candidate?.ratio) return;
const fit = centeredFitPct(candidate.ratio / videoAspectRatio);
setCropXPct(Math.round(fit.x));
setCropYPct(Math.round(fit.y));
setCropWPct(Math.round(fit.w));
setCropHPct(Math.round(fit.h));
setCropXPct(fit.x);
setCropYPct(fit.y);
setCropWPct(fit.w);
setCropHPct(fit.h);
};

// Fraction-space width/height ratio the crop is locked to while a preset is
Expand All @@ -802,11 +835,11 @@ export function EditClipModal({
// independently. All keep the rectangle inside the frame.
const applyCropX = (v: number) => {
setCropTouched(true);
setCropXPct(Math.round(clampPct(v, 0, 100 - cropWPct)));
setCropXPct(clampPct(v, 0, 100 - cropWPct));
};
const applyCropY = (v: number) => {
setCropTouched(true);
setCropYPct(Math.round(clampPct(v, 0, 100 - cropHPct)));
setCropYPct(clampPct(v, 0, 100 - cropHPct));
};
const applyCropW = (v: number) => {
setCropTouched(true);
Expand All @@ -817,10 +850,10 @@ export function EditClipModal({
h = 100 - cropYPct;
w = h * lockedFractionRatio;
}
setCropWPct(Math.round(w));
setCropHPct(Math.round(h));
setCropWPct(w);
setCropHPct(h);
} else {
setCropWPct(Math.round(clampPct(v, MIN_PCT, 100 - cropXPct)));
setCropWPct(clampPct(v, MIN_PCT, 100 - cropXPct));
}
};
const applyCropH = (v: number) => {
Expand All @@ -832,10 +865,10 @@ export function EditClipModal({
w = 100 - cropXPct;
h = w / lockedFractionRatio;
}
setCropWPct(Math.round(w));
setCropHPct(Math.round(h));
setCropWPct(w);
setCropHPct(h);
} else {
setCropHPct(Math.round(clampPct(v, MIN_PCT, 100 - cropYPct)));
setCropHPct(clampPct(v, MIN_PCT, 100 - cropYPct));
}
};

Expand All @@ -853,8 +886,8 @@ export function EditClipModal({
const move = (ev: PointerEvent) => {
const dxPct = ((ev.clientX - startX) / r.width) * 100;
const dyPct = ((ev.clientY - startY) / r.height) * 100;
setCropXPct(Math.round(clampPct(start.x + dxPct, 0, 100 - start.w)));
setCropYPct(Math.round(clampPct(start.y + dyPct, 0, 100 - start.h)));
setCropXPct(clampPct(start.x + dxPct, 0, 100 - start.w));
setCropYPct(clampPct(start.y + dyPct, 0, 100 - start.h));
};
const up = () => {
window.removeEventListener("pointermove", move);
Expand Down Expand Up @@ -924,10 +957,10 @@ export function EditClipModal({
x = fixedLeft ? anchorX : anchorX - w;
y = fixedTop ? anchorY : anchorY - h;
}
setCropXPct(Math.round(x));
setCropYPct(Math.round(y));
setCropWPct(Math.round(w));
setCropHPct(Math.round(h));
setCropXPct(x);
setCropYPct(y);
setCropWPct(w);
setCropHPct(h);
};
const up = () => {
window.removeEventListener("pointermove", move);
Expand All @@ -951,10 +984,11 @@ export function EditClipModal({
setDraftStart(clip.sourceStartSec);
setDraftEnd(clip.sourceEndSec ?? clip.sourceStartSec);
const region = clip.cropRegion ?? IDENTITY_CROP;
setCropXPct(Math.round(region.x * 100));
setCropYPct(Math.round(region.y * 100));
setCropWPct(Math.round(region.width * 100));
setCropHPct(Math.round(region.height * 100));
const pct = cropDraftToPct(cropDraftFromRegion(region));
setCropXPct(pct.x);
setCropYPct(pct.y);
setCropWPct(pct.w);
setCropHPct(pct.h);
setCropRatio(detectRatio(region, videoAspectRatio));
setCropTouched(false);
};
Expand All @@ -979,21 +1013,7 @@ export function EditClipModal({
subtitle={assetMeta?.label ?? undefined}
wide
>
<div
ref={cropFrameRef}
style={{
position: "relative",
width: "100%",
height: 230,
maxWidth: 409,
margin: "0 auto 14px",
flexShrink: 0,
background: "#0a0b0e",
borderRadius: "var(--r-md)",
border: "1px solid var(--border)",
overflow: "hidden",
}}
>
<div ref={cropFrameRef} style={previewBoxStyle(videoAspectRatio)}>
{cropPreviewSource ? (
<video
ref={cropVideoRef}
Expand Down Expand Up @@ -1200,10 +1220,30 @@ export function EditClipModal({
alignItems: "end",
}}
>
<CropField label={t("cropDialog.fieldX")} value={cropXPct} onChange={applyCropX} />
<CropField label={t("cropDialog.fieldY")} value={cropYPct} onChange={applyCropY} />
<CropField label={t("cropDialog.fieldW")} value={cropWPct} onChange={applyCropW} />
<CropField label={t("cropDialog.fieldH")} value={cropHPct} onChange={applyCropH} />
<CropField
label={t("cropDialog.fieldX")}
value={cropXPct}
step={stepPct(frameSizePx.width)}
onChange={applyCropX}
/>
<CropField
label={t("cropDialog.fieldY")}
value={cropYPct}
step={stepPct(frameSizePx.height)}
onChange={applyCropY}
/>
<CropField
label={t("cropDialog.fieldW")}
value={cropWPct}
step={stepPct(frameSizePx.width)}
onChange={applyCropW}
/>
<CropField
label={t("cropDialog.fieldH")}
value={cropHPct}
step={stepPct(frameSizePx.height)}
onChange={applyCropH}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 110 }}>
<label
style={{
Expand Down Expand Up @@ -1244,7 +1284,7 @@ export function EditClipModal({
whiteSpace: "nowrap",
}}
>
{cropWPct}% × {cropHPct}%
{displayPct(cropWPct)}% × {displayPct(cropHPct)}%
</span>
</div>
</div>
Expand Down
9 changes: 9 additions & 0 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo";
import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
import { matchesShortcut } from "@/lib/shortcuts";
import { nativeBridgeClient } from "@/native";
import type { AiEditionProjectSummary } from "@/native/contracts";
Expand Down Expand Up @@ -159,6 +160,12 @@ export function NewEditorShell() {
.map((v) => v.assetId),
[transcriptions],
);
// For the pane-level busy label: timeline-scoped like the gate, so an
// off-timeline job cannot relabel controls the gate keeps enabled.
const timelineBusyView = useMemo(
() => firstTimelineBusyView(document, transcriptions),
[document, transcriptions],
);
const tl = useTimeline();
// An undo only puts the restored document back in the store and marks it dirty,
// so without this the reverted state never reached disk: close the window and the
Expand Down Expand Up @@ -1148,6 +1155,8 @@ export function NewEditorShell() {
assets: document?.assets ?? [],
trimRanges: document?.timeline?.trimRanges ?? [],
busyAssetIds,
transcriptions,
busyView: timelineBusyView,
onSeek: handleSeek,
onAddTrimRange: handleAddTrimRange,
onRemoveTrimRange: handleRemoveTrimRange,
Expand Down
Loading
Loading