Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/components/ai-edition/v4/FloatingInspector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ vi.mock("@/contexts/I18nContext", () => ({

vi.mock("../RightPanes", () => ({
AudioPane: () => <div data-testid="audio-pane">AudioPane</div>,
ChoiceRow: () => <div data-testid="choice-row">ChoiceRow</div>,
AudioTrackPane: ({ onClose }: { onClose?: () => void }) => (
<div data-testid="audio-track-pane">
AudioTrackPane
Expand Down
163 changes: 144 additions & 19 deletions src/components/ai-edition/v4/FloatingInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { parseCustomPlaybackSpeedInput } from "@/components/video-editor/customPlaybackSpeed";
import {
effectiveZoomScale,
FIXED_ROTATION_3D_PRESETS,
isRotation3DPreset,
MAX_PLAYBACK_SPEED,
MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE,
MOVING_ROTATION_3D_PRESETS,
type Rotation3DPreset,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
} from "@/components/video-editor/types";
import { useScopedT } from "@/contexts/I18nContext";
import {
Expand Down Expand Up @@ -428,7 +432,145 @@ function convertAnnotationKind(
return { ...parked, type: next, content: restored };
}

const ZOOM_DEPTHS = [1, 2, 3, 4, 5, 6] as const;
const ZOOM_DEPTHS: readonly ZoomDepth[] = [1, 2, 3, 4, 5, 6];

// The row: the default and one step either side, plus a strong close-up. The two ends of the
// table (1.25×, 5×) and every level between are one entry in the free field below, which is
// why the row stays short. Labels read the table, not a formula: a formula once announced
// "2.0×" where the timeline pill showed "1.80×" and the render applied 1.8.
const ZOOM_PRESETS = ([2, 3, 4, 5] as const).map((depth) => ({
value: ZOOM_DEPTH_SCALES[depth],
label: `${ZOOM_DEPTH_SCALES[depth]}×`,
}));

/**
* The zoom level as a row of presets plus a free field, the same pair as the speed control
* below. A level is one click away instead of two (open the select, then pick): that is
* My-Denia's change (#694), and so is everything that keeps rapid clicks and arrow steps in
* order, below.
*
* The control speaks in scales, not depths: a preset and a typed level are the same kind of
* value, and a typed level the table has (1.8, or 1.25 which is not in the row) is written as
* its depth, so a document keeps naming its presets. Anything else is a `customScale`.
*/
export function ZoomLevelControl({
region,
tl,
}: {
region: { id: string; depth: ZoomDepth; customScale?: number };
tl: Pick<TimelineApi, "updateZoomDepth" | "updateZoomCustomScale">;
}) {
const ts = useScopedT("settings");
const current = effectiveZoomScale(region);
// Last level this instance asked for, and the generation of that request. Levels repeat,
// so a set of levels cannot tell "our older 2.2 landed" from "the latest request is 2.2" or
// from an undo that happens to land on 2.2. Each click/key gets a new gen. Every gen
// belonging to this region epoch is removed from `pending` when it settles: a superseded
// request must still drain, or `pending` stays non-empty and undo/redo can never overwrite
// the request. Only the latest gen may change it.
//
// The row shows the request, not the document: its no-op guard compares against the value
// it is given, and against a document still saying 1.8 while a 2.2 is in flight, stepping
// back to 1.8 would be dropped as a re-press.
const requestedRef = useRef(current);
const [requested, setRequested] = useState(current);
const genRef = useRef(0);
const pendingRef = useRef(new Set<number>());
const currentRef = useRef(current);
currentRef.current = current;
// "" means the field is idle and shows the live level as its placeholder.
const [draft, setDraft] = useState("");

// biome-ignore lint/correctness/useExhaustiveDependencies: region.id is the trigger, not a read — the body resets request state; the level is taken from the render's ref so a same-level other pill still clears the previous pill's pending gen.
useEffect(() => {
genRef.current += 1;
pendingRef.current.clear();
requestedRef.current = currentRef.current;
setRequested(currentRef.current);
}, [region.id]);

useEffect(() => {
if (pendingRef.current.size > 0) return;
requestedRef.current = current;
setRequested(current);
}, [current]);

const setScale = (scale: number) => {
// Re-choosing the current level is not an edit: no save, no undo entry.
if (scale === requestedRef.current) return;
requestedRef.current = scale;
setRequested(scale);
const gen = ++genRef.current;
pendingRef.current.add(gen);
const depth = ZOOM_DEPTHS.find((d) => ZOOM_DEPTH_SCALES[d] === scale);
const write =
depth === undefined
? tl.updateZoomCustomScale(region.id, scale)
: tl.updateZoomDepth(region.id, depth);
// A refused write hands the level back to the document, so the same one can be retried.
const settle = (ok: boolean) => {
pendingRef.current.delete(gen);
if (ok || gen !== genRef.current) return;
requestedRef.current = currentRef.current;
setRequested(currentRef.current);
};
void Promise.resolve(write).then(
(ok) => settle(ok !== false),
() => settle(false),
);
};

const commitDraft = () => {
const text = draft
.trim()
.replace(",", ".")
.replace(/\s*[×x]$/i, "");
setDraft("");
// Empty or unparseable reverts to the live level rather than guessing at an intent.
if (text === "" || !Number.isFinite(Number(text))) return;
const scale = Math.round(Number(text) * 100) / 100;
if (scale < MIN_ZOOM_SCALE || scale > MAX_ZOOM_SCALE) {
toast.error(ts("zoom.customScaleRange", { min: MIN_ZOOM_SCALE, max: MAX_ZOOM_SCALE }));
return;
}
setScale(scale);
};

return (
<>
{/* A level outside the row presses no button; the field below shows it. */}
{paneStack(
ts("zoom.level"),
<ChoiceRow<number>
label={ts("zoom.level")}
options={ZOOM_PRESETS}
value={requested}
onChange={setScale}
/>,
)}
{paneRow(
ts("zoom.customScale"),
<input
type="text"
inputMode="decimal"
aria-label={ts("zoom.customScale")}
placeholder={`${requested}×`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
// Enter blurs, and the blur handler commits: one path, so a keyboard commit
// can't apply the same draft twice.
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
className={shell.control}
style={{ width: 84, textAlign: "right" }}
/>,
)}
</>
);
}

// The speeds people actually reach for, one row of buttons: slow down, back to normal, and three
// steps up. Every other speed (the shared ladder's 0.25×, 3×, 5×, anything up to
// `MAX_PLAYBACK_SPEED`) is one entry in the free field below, which is why the row stays short.
Expand Down Expand Up @@ -569,24 +711,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
<div style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
{paneHeader(<ZoomIn size={16} />, tt("labels.zoom"), onClose, tc("actions.close"))}
<div style={bodyStyle}>
{paneRow(
ts("zoom.level"),
<select
value={region.depth}
onChange={(e) =>
void tl.updateZoomDepth(region.id, Number(e.target.value) as 1 | 2 | 3 | 4 | 5 | 6)
}
className={shell.control}
>
{ZOOM_DEPTHS.map((d) => (
<option key={d} value={d}>
{/* La table, pas une formule : ce libellé annonçait « 2.0× » là où la pastille de la
timeline affiche « 1.80× » et où le rendu applique 1.8. */}
{ZOOM_DEPTH_SCALES[d]}×
</option>
))}
</select>,
)}
<ZoomLevelControl key={region.id} region={region} tl={tl} />
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{paneRow(
ts("zoom.camera.title"),
Expand Down
Loading
Loading