AudioTrackPane
diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx
index 949e307d0..6e1c952f8 100644
--- a/src/components/ai-edition/v4/FloatingInspector.tsx
+++ b/src/components/ai-edition/v4/FloatingInspector.tsx
@@ -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 {
@@ -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;
+}) {
+ 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());
+ 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"),
+
+ label={ts("zoom.level")}
+ options={ZOOM_PRESETS}
+ value={requested}
+ onChange={setScale}
+ />,
+ )}
+ {paneRow(
+ ts("zoom.customScale"),
+ 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.
@@ -569,24 +711,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
{paneRow(
ts("zoom.camera.title"),
diff --git a/src/components/ai-edition/v4/ZoomLevelControl.test.tsx b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx
new file mode 100644
index 000000000..7b76a663b
--- /dev/null
+++ b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx
@@ -0,0 +1,489 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ZOOM_DEPTH_SCALES, type ZoomDepth } from "@/components/video-editor/types";
+
+// The pane is only reachable with a project open and a zoom region selected, so drive the
+// control directly. The translator echoes keys, as in `SpeedControl.test.tsx`.
+vi.mock("@/contexts/I18nContext", () => ({
+ useScopedT: () => (key: string, vars?: Record) =>
+ vars ? `${key}:${Object.values(vars).join(",")}` : key,
+}));
+
+const toastError = vi.fn();
+vi.mock("sonner", () => ({ toast: { error: (message: string) => toastError(message) } }));
+
+import { ZoomLevelControl } from "./FloatingInspector";
+
+// Never reached by the preset tests: a scale the table has is written as its depth.
+const updateZoomCustomScale = vi.fn(async (_id: string, _scale: number) => true);
+
+function renderControl(depth: ZoomDepth, customScale?: number) {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ render(
+ ,
+ );
+ const group = screen.getByRole("group", { name: "zoom.level" });
+ const buttons = screen.getAllByRole("button");
+ const field = screen.getByRole("textbox", { name: "zoom.customScale" });
+ return { updateZoomDepth, group, buttons, field };
+}
+
+/**
+ * The same control with the pane's half of the loop in place: in the editor `updateZoomDepth`
+ * writes the region and the pane re-renders with the new `depth`, so the pressed button moves
+ * under the keyboard. Stepping is only coherent if that feedback exists — with a frozen prop
+ * every arrow would keep counting from the level the control opened on.
+ */
+function renderControlled(initial: ZoomDepth) {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ function Harness() {
+ const [depth, setDepth] = useState(initial);
+ return (
+ {
+ // Synchronously, so the re-render lands inside the `fireEvent` that caused it:
+ // the next keystroke in a test then sees the same DOM a user's would.
+ updateZoomDepth(id, next);
+ setDepth(next);
+ return Promise.resolve(true);
+ },
+ }}
+ />
+ );
+ }
+ render();
+ const group = screen.getByRole("group", { name: "zoom.level" });
+ const buttons = screen.getAllByRole("button");
+ const focusLevel = (depth: ZoomDepth) => (buttons[depth - 2] as HTMLButtonElement).focus();
+ return { updateZoomDepth, group, buttons, focusLevel };
+}
+
+describe("ZoomLevelControl", () => {
+ beforeEach(() => {
+ toastError.mockClear();
+ updateZoomCustomScale.mockClear();
+ });
+
+ it("renders four presets, labelled with the table value, current one pressed", () => {
+ const { buttons, field } = renderControl(3);
+ expect(buttons.map((b) => b.textContent)).toEqual(
+ ([2, 3, 4, 5] as const).map((d) => `${ZOOM_DEPTH_SCALES[d]}×`),
+ );
+ expect(buttons.map((b) => b.getAttribute("aria-pressed"))).toEqual([
+ "false",
+ "true",
+ "false",
+ "false",
+ ]);
+ expect(field).toHaveAttribute("placeholder", "1.8×");
+ });
+
+ // The ends of the table and every custom level live in the field, not the row.
+ it("presses no preset for a level outside the row, and shows it in the field", () => {
+ const { buttons, field } = renderControl(3, 2.75);
+ expect(buttons.every((b) => b.getAttribute("aria-pressed") === "false")).toBe(true);
+ expect(field).toHaveAttribute("placeholder", "2.75×");
+ });
+
+ it("commits a typed level as a custom scale, on Enter or blur and only once", () => {
+ const { updateZoomDepth, field } = renderControl(3);
+ fireEvent.change(field, { target: { value: "2,5×" } });
+ fireEvent.keyDown(field, { key: "Enter" });
+ fireEvent.blur(field);
+ expect(updateZoomCustomScale).toHaveBeenCalledTimes(1);
+ expect(updateZoomCustomScale).toHaveBeenCalledWith("z1", 2.5);
+ expect(updateZoomDepth).not.toHaveBeenCalled();
+ expect(field).toHaveValue("");
+ });
+
+ // A document keeps naming its presets, whether they were clicked or typed.
+ it("writes a typed level the table has as its depth", () => {
+ const { updateZoomDepth, field } = renderControl(3);
+ fireEvent.change(field, { target: { value: "1.25" } });
+ fireEvent.blur(field);
+ expect(updateZoomDepth).toHaveBeenCalledWith("z1", 1);
+ expect(updateZoomCustomScale).not.toHaveBeenCalled();
+ });
+
+ it("refuses a level outside the renderer's range and says so", () => {
+ const { updateZoomDepth, field } = renderControl(3);
+ fireEvent.change(field, { target: { value: "8" } });
+ fireEvent.blur(field);
+ expect(toastError).toHaveBeenCalledWith("zoom.customScaleRange:1,5");
+ expect(updateZoomCustomScale).not.toHaveBeenCalled();
+ expect(updateZoomDepth).not.toHaveBeenCalled();
+ });
+
+ it("ignores an unparseable draft without touching the region", () => {
+ const { updateZoomDepth, field } = renderControl(3);
+ fireEvent.change(field, { target: { value: "abc" } });
+ fireEvent.blur(field);
+ expect(toastError).not.toHaveBeenCalled();
+ expect(updateZoomCustomScale).not.toHaveBeenCalled();
+ expect(updateZoomDepth).not.toHaveBeenCalled();
+ });
+
+ it("commits a level in one click", () => {
+ const { updateZoomDepth, buttons } = renderControl(3);
+ fireEvent.click(buttons[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledTimes(1);
+ expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5);
+ });
+
+ it("does not write when the current level is clicked again", () => {
+ // A no-op edit would still land a save and an undo entry.
+ const { updateZoomDepth, buttons } = renderControl(3);
+ fireEvent.click(buttons[1] as HTMLButtonElement);
+ expect(updateZoomDepth).not.toHaveBeenCalled();
+ });
+
+ // The other half of that guard: undo/redo and the agent write the region without going
+ // through this control, so a request of ours must never outlive the prop. The moment the
+ // region says something else, that is the level to compare against.
+ it("follows the region when the level is changed from elsewhere", async () => {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5);
+ await act(async () => {
+ // Let the request settle so the follow-effect is allowed to copy the prop.
+ });
+
+ // An undo lands on 2 instead of the 5 this control asked for.
+ rerender(
+ ,
+ );
+ fireEvent.click(screen.getAllByRole("button")[0] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledTimes(2);
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5);
+ });
+
+ it("steps to the neighbouring level with the arrow keys and moves focus with it", () => {
+ const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(3);
+ focusLevel(3);
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 4);
+ expect(buttons[2]).toHaveFocus();
+ fireEvent.keyDown(group, { key: "ArrowDown" });
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5);
+ expect(buttons[3]).toHaveFocus();
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 4);
+ fireEvent.keyDown(group, { key: "ArrowUp" });
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 3);
+ expect(buttons[1]).toHaveFocus();
+ expect(updateZoomDepth).toHaveBeenCalledTimes(4);
+ expect(buttons[1]).toHaveAttribute("aria-pressed", "true");
+ });
+
+ // Every level is a Tab stop, so focus can sit on a level that is not the selected one.
+ // Counting from the selection there moved focus the wrong way across the row.
+ it("steps from the button that has focus, not from the selected level", () => {
+ const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(2);
+ focusLevel(4);
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ expect(buttons[3]).toHaveFocus();
+ expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5);
+ });
+
+ // `updateZoomDepth` writes the document, so the pressed state only catches up a tick later.
+ // Focus moves in the keystroke itself, which is why holding an arrow down keeps advancing
+ // instead of re-applying the same step against a `depth` prop that has not landed yet.
+ it("keeps stepping while the write is still in flight", () => {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ function Harness() {
+ const [depth, setDepth] = useState(3);
+ return (
+ {
+ await updateZoomDepth(id, next);
+ setDepth(next);
+ return true;
+ },
+ }}
+ />
+ );
+ }
+ render();
+ const group = screen.getByRole("group", { name: "zoom.level" });
+ const buttons = screen.getAllByRole("button");
+ (buttons[1] as HTMLButtonElement).focus();
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]);
+ expect(buttons[3]).toHaveFocus();
+ });
+
+ // The mirror image of the test above, and the one that catches a stale read: stepping
+ // BACK to where the region started. The guard that makes re-pressing the current level a
+ // no-op has to compare against what was last asked for, not against a `depth` prop that
+ // still says 3 because the first write has not landed -- or the user's second keystroke
+ // is dropped and the level stays on 4.
+ it("does not drop a step back while the first write is still in flight", () => {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ function Harness() {
+ const [depth, setDepth] = useState(3);
+ return (
+ {
+ await updateZoomDepth(id, next);
+ setDepth(next);
+ return true;
+ },
+ }}
+ />
+ );
+ }
+ render();
+ const group = screen.getByRole("group", { name: "zoom.level" });
+ const buttons = screen.getAllByRole("button");
+ (buttons[1] as HTMLButtonElement).focus();
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 3]);
+ expect(buttons[1]).toHaveFocus();
+ });
+
+ // `saveDocument` writes the returned document into the store and only then
+ // resolves, so an earlier request can echo back while a later one is still in
+ // flight. Copying that echo into the no-op guard made ArrowLeft look like a
+ // re-press of the current level: 3 → 4 → 5, 4 lands, ArrowLeft dropped, and
+ // the level stayed on 5 with focus on 4.
+ it("does not treat an earlier in-flight write as the latest request", async () => {
+ const resolvers: Array<() => void> = [];
+ const updateZoomDepth = vi.fn((_id: string, _depth: ZoomDepth) => {
+ return new Promise((resolve) => {
+ resolvers.push(() => resolve(true));
+ });
+ });
+ function Harness() {
+ const [depth, setDepth] = useState(3);
+ return (
+ {
+ const pending = updateZoomDepth(id, next);
+ void pending.then(() => setDepth(next));
+ return pending;
+ },
+ }}
+ />
+ );
+ }
+ render();
+ const group = screen.getByRole("group", { name: "zoom.level" });
+ const buttons = screen.getAllByRole("button");
+ (buttons[1] as HTMLButtonElement).focus();
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]);
+ expect(resolvers).toHaveLength(2);
+
+ await act(async () => {
+ resolvers[0]!();
+ });
+ // The row shows the latest request, not the echo of the older one.
+ expect(buttons[3]).toHaveAttribute("aria-pressed", "true");
+ expect(buttons[3]).toHaveFocus();
+
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4]);
+ expect(buttons[2]).toHaveFocus();
+ });
+
+ // Depth values repeat, so an older request landing on 4 must not look like
+ // the later request for 4 has confirmed, or the 5 in between is treated as
+ // an external write and the latest 4 is lost.
+ it("does not treat an older request for the same depth as the latest one", async () => {
+ const resolvers: Array<() => void> = [];
+ const updateZoomDepth = vi.fn((_id: string, _depth: ZoomDepth) => {
+ return new Promise((resolve) => {
+ resolvers.push(() => resolve(true));
+ });
+ });
+ function Harness() {
+ const [depth, setDepth] = useState(3);
+ return (
+ {
+ const pending = updateZoomDepth(id, next);
+ void pending.then(() => setDepth(next));
+ return pending;
+ },
+ }}
+ />
+ );
+ }
+ render();
+ const group = screen.getByRole("group", { name: "zoom.level" });
+ const buttons = screen.getAllByRole("button");
+ (buttons[1] as HTMLButtonElement).focus();
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4]);
+
+ await act(async () => {
+ resolvers[0]!();
+ });
+ await act(async () => {
+ resolvers[1]!();
+ });
+ fireEvent.click(buttons[3] as HTMLButtonElement);
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4, 5]);
+ });
+
+ it("does not leak a pending request onto a different zoom region", async () => {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledWith("A", 5);
+
+ rerender(
+ ,
+ );
+ fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("B", 5);
+ expect(updateZoomDepth).toHaveBeenCalledTimes(2);
+ });
+
+ it("follows an undo after rapid steps have all settled", async () => {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true);
+ const { rerender } = render(
+ ,
+ );
+ const buttons = screen.getAllByRole("button");
+ fireEvent.click(buttons[2] as HTMLButtonElement);
+ fireEvent.click(buttons[3] as HTMLButtonElement);
+ expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]);
+ await act(async () => {
+ // both generations must drain, or the follow-effect stays blocked
+ });
+
+ rerender(
+ ,
+ );
+ fireEvent.click(buttons[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledTimes(3);
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5);
+ });
+
+ it("retries the same level after a failed save", async () => {
+ const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => false);
+ render(
+ ,
+ );
+ const buttons = screen.getAllByRole("button");
+ fireEvent.click(buttons[3] as HTMLButtonElement);
+ await act(async () => {
+ // settle the failed write so the same target is not stuck as current
+ });
+ // The row hands the level back to the document while nothing is pending.
+ expect(buttons[1]).toHaveAttribute("aria-pressed", "true");
+ fireEvent.click(buttons[3] as HTMLButtonElement);
+ expect(updateZoomDepth).toHaveBeenCalledTimes(2);
+ expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5);
+ });
+
+ it("clamps at the lowest level instead of wrapping", () => {
+ const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(2);
+ focusLevel(2);
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
+ expect(updateZoomDepth).not.toHaveBeenCalled();
+ expect(buttons[0]).toHaveFocus();
+ });
+
+ it("clamps at the highest level instead of wrapping", () => {
+ const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(5);
+ focusLevel(5);
+ fireEvent.keyDown(group, { key: "ArrowRight" });
+ expect(updateZoomDepth).not.toHaveBeenCalled();
+ expect(buttons[3]).toHaveFocus();
+ });
+
+ it("keeps its own keys off the window listener, and lets every other key through", () => {
+ // The editor shell listens on WINDOW, above React's root container: ArrowLeft/ArrowRight
+ // seek the playhead there and Space is play/pause. Space matters most — the shell
+ // `preventDefault()`s it, which cancels the button's own activation, so an unstopped
+ // Space changed no level and started playback instead.
+ //
+ // What this pins is the propagation rule, which is where the bug was. jsdom does not
+ // dispatch a button's native activation for Space at all, so "Space commits the focused
+ // level" is only provable in a browser, where it was checked against the running editor.
+ const onWindowKey = vi.fn();
+ window.addEventListener("keydown", onWindowKey);
+ try {
+ const { group } = renderControlled(3);
+ for (const key of ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " ", "Enter"]) {
+ fireEvent.keyDown(group, { key });
+ }
+ expect(onWindowKey).not.toHaveBeenCalled();
+
+ // Keys the group ignores still get there, or the editor shortcuts would be dead.
+ fireEvent.keyDown(group, { key: "z" });
+ fireEvent.keyDown(group, { key: "Tab" });
+ expect(onWindowKey).toHaveBeenCalledTimes(2);
+ } finally {
+ window.removeEventListener("keydown", onWindowKey);
+ }
+ });
+
+ // `preventDefault()` on the activation keys would cancel the button's own click, which is
+ // exactly how the shell broke Space in the first place.
+ it("does not cancel the default action of the activation keys", () => {
+ const { group } = renderControlled(3);
+ for (const key of ["Enter", " "]) {
+ const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
+ group.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ }
+ });
+});
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 100be30b1..88a9a32b9 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -384,6 +384,7 @@
"level": "مستوى التكبير",
"previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير",
"customScale": "تكبير مخصص",
+ "customScaleRange": "يتراوح التكبير بين {{min}}× و{{max}}×",
"selectRegion": "حدد منطقة التكبير للتعديل",
"cursor": {
"title": "المؤشر",
diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json
index e5e1be7f6..701c74a11 100644
--- a/src/i18n/locales/cs/settings.json
+++ b/src/i18n/locales/cs/settings.json
@@ -384,6 +384,7 @@
"level": "Úroveň přiblížení",
"previewHold": "Podržte pro náhled efektu přiblížení",
"customScale": "Vlastní přiblížení",
+ "customScaleRange": "Přiblížení může být od {{min}}× do {{max}}×",
"selectRegion": "Vyberte oblast přiblížení pro úpravu",
"cursor": {
"title": "Kurzor",
diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json
index a2479ad90..4b7e19ad9 100644
--- a/src/i18n/locales/de/settings.json
+++ b/src/i18n/locales/de/settings.json
@@ -384,6 +384,7 @@
"level": "Zoomstufe",
"previewHold": "Gedrückt halten für die Zoom-Vorschau",
"customScale": "Eigener Zoom",
+ "customScaleRange": "Der Zoom reicht von {{min}}× bis {{max}}×",
"selectRegion": "Wähle einen Zoom-Bereich zum Anpassen",
"cursor": {
"title": "Cursor",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index e4957152a..297b712ed 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -383,7 +383,8 @@
},
"level": "Zoom level",
"previewHold": "Hold to preview zoom effect",
- "customScale": "Custom Zoom",
+ "customScale": "Custom zoom",
+ "customScaleRange": "Zoom goes from {{min}}× to {{max}}×",
"selectRegion": "Select a zoom region to adjust",
"cursor": {
"title": "Cursor",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 16f5c7a79..890b41440 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -384,6 +384,7 @@
"level": "Nivel de zoom",
"previewHold": "Mantener para previsualizar el efecto de zoom",
"customScale": "Zoom personalizado",
+ "customScaleRange": "El zoom va de {{min}}× a {{max}}×",
"selectRegion": "Selecciona una región de zoom para ajustar",
"cursor": {
"title": "Cursor",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index b8cb772a2..68e0e2233 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -384,6 +384,7 @@
"level": "Niveau de zoom",
"previewHold": "Maintenir pour prévisualiser l'effet de zoom",
"customScale": "Zoom personnalisé",
+ "customScaleRange": "Le zoom va de {{min}}× à {{max}}×",
"selectRegion": "Sélectionnez une région de zoom à ajuster",
"cursor": {
"title": "Curseur",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 23ad7a651..a0c13e5aa 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -384,6 +384,7 @@
"level": "Livello zoom",
"previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
"customScale": "Zoom personalizzato",
+ "customScaleRange": "Lo zoom va da {{min}}× a {{max}}×",
"selectRegion": "Seleziona una regione zoom da regolare",
"cursor": {
"title": "Cursore",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index 8e3a4dfdb..af5e3d177 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -384,6 +384,7 @@
"level": "ズーム倍率",
"previewHold": "押している間ズーム効果をプレビュー",
"customScale": "カスタムズーム",
+ "customScaleRange": "ズーム倍率は{{min}}×から{{max}}×までです",
"selectRegion": "ズーム範囲を選択して調整",
"cursor": {
"title": "カーソル",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 6a46098fc..7c556eb97 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -384,6 +384,7 @@
"level": "줌 레벨",
"previewHold": "누르고 있으면 줌 효과 미리보기",
"customScale": "커스텀 줌",
+ "customScaleRange": "줌은 {{min}}×에서 {{max}}×까지 가능합니다",
"selectRegion": "조정할 줌 구간을 선택하세요",
"cursor": {
"title": "커서",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 9cf5dc48e..398fa2839 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -384,6 +384,7 @@
"level": "Nível de Zoom",
"previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom",
"customScale": "Zoom Personalizado",
+ "customScaleRange": "O zoom vai de {{min}}× a {{max}}×",
"selectRegion": "Selecione uma região de zoom para ajustar",
"cursor": {
"title": "Cursor",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index 89359ce7b..625a53605 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -384,6 +384,7 @@
"level": "Уровень масштабирования",
"previewHold": "Удерживайте для предпросмотра эффекта зума",
"customScale": "Пользовательский масштаб",
+ "customScaleRange": "Масштаб может быть от {{min}}× до {{max}}×",
"selectRegion": "Выберите область масштабирования для настройки",
"cursor": {
"title": "Курсор",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 46d43abec..206b2b7ca 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -384,6 +384,7 @@
"level": "Yakınlaştırma Seviyesi",
"previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun",
"customScale": "Özel Yakınlaştırma",
+ "customScaleRange": "Yakınlaştırma {{min}}× ile {{max}}× arasında olabilir",
"selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
"cursor": {
"title": "İmleç",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 11c4ac50e..1aa15e103 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -384,6 +384,7 @@
"level": "Mức độ thu phóng",
"previewHold": "Giữ để xem trước hiệu ứng phóng to",
"customScale": "Thu phóng tùy chỉnh",
+ "customScaleRange": "Mức thu phóng từ {{min}}× đến {{max}}×",
"selectRegion": "Chọn vùng thu phóng để điều chỉnh",
"cursor": {
"title": "Con trỏ",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index d18fd8d46..b34912793 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -384,6 +384,7 @@
"level": "缩放级别",
"previewHold": "按住预览放大效果",
"customScale": "自定义缩放",
+ "customScaleRange": "缩放范围为 {{min}}× 到 {{max}}×",
"selectRegion": "选择要调整的缩放区域",
"cursor": {
"title": "光标",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 66b592ed5..d8b84fafc 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -384,6 +384,7 @@
"level": "縮放級別",
"previewHold": "按住預覽放大效果",
"customScale": "自訂縮放",
+ "customScaleRange": "縮放範圍為 {{min}}× 到 {{max}}×",
"selectRegion": "選擇要調整的縮放區域",
"cursor": {
"title": "游標",
diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts
index 0d054eb83..263c1ab6a 100644
--- a/src/lib/ai-edition/store/documentWriteAudit.test.ts
+++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts
@@ -285,6 +285,7 @@ const DECLARED: WritePath[] = [
w("src/lib/ai-edition/store/useTimeline.ts", "removeClip", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "removeRegion", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "removeRegions", "save", "gesture"),
+ w("src/lib/ai-edition/store/useTimeline.ts", "saveZoomPatch", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "setTrimEntries", "save", "gesture"),
// The live halves of the two drags.
w("src/lib/ai-edition/store/useTimeline.ts", "updateAnnotationLive", "set", "automatic"),
@@ -293,12 +294,7 @@ const DECLARED: WritePath[] = [
w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedSpan", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedValue", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "updateTrim", "save", "gesture"),
- w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomClickImpact", "save", "gesture"),
- w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomDepth", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusLive", "set", "automatic"),
- w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusMode", "save", "gesture"),
- w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomHideCursor", "save", "gesture"),
- w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomRotation", "save", "gesture"),
w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomSpan", "save", "gesture"),
// Source-dimension backfill for assets a migration left unprobed. On load, for
// every project, whether or not the user touches anything.
diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts
index 797f39eda..3848b5ff1 100644
--- a/src/lib/ai-edition/store/useTimeline.test.ts
+++ b/src/lib/ai-edition/store/useTimeline.test.ts
@@ -1197,6 +1197,323 @@ describe("useTimeline undo history", () => {
expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(2);
});
+ // Holds the first document save until the test releases it; every later one lands at once.
+ const gateFirstSave = () => {
+ const gate: { release?: () => void } = {};
+ let calls = 0;
+ bridgeMocks.save.mockImplementation(async (doc: AxcutDocument) => {
+ calls += 1;
+ if (calls === 1) {
+ await new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ }
+ return { success: true, document: doc };
+ });
+ return gate;
+ };
+
+ it("lands rapid zoom-level steps in order, one undo step each", async () => {
+ seed(docWithZoom);
+ const gate = gateFirstSave();
+ const { result } = renderTimeline();
+
+ const p4 = result.current.updateZoomDepth("zoom_a", 4);
+ const p5 = result.current.updateZoomDepth("zoom_a", 5);
+ await waitFor(() => expect(gate.release).toEqual(expect.any(Function)));
+ await act(async () => {
+ gate.release?.();
+ await Promise.all([p4, p5]);
+ });
+
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(5);
+ act(() => {
+ expect(undo()).toBe(true);
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(4);
+ });
+
+ // A custom scale overrides the depth, so a preset that left it in place would change a
+ // field the render never reads.
+ it("clamps a custom zoom level to the renderer's range, and a preset clears it", async () => {
+ seed(docWithZoom);
+ const { result } = renderTimeline();
+
+ await act(async () => {
+ await result.current.updateZoomCustomScale("zoom_a", 9);
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.customScale).toBe(5);
+
+ await act(async () => {
+ await result.current.updateZoomDepth("zoom_a", 4);
+ });
+ const zoom = useProjectStore.getState().document?.zoomRanges[0];
+ expect(zoom?.depth).toBe(4);
+ expect(zoom?.customScale).toBeUndefined();
+ });
+
+ it("keeps a pending zoom level when the 3D tilt is changed before it lands", async () => {
+ seed(docWithZoom);
+ const gate = gateFirstSave();
+ const { result } = renderTimeline();
+
+ const pDepth = result.current.updateZoomDepth("zoom_a", 4);
+ const pRotation = result.current.updateZoomRotation("zoom_a", "iso");
+ await waitFor(() => expect(gate.release).toEqual(expect.any(Function)));
+ await act(async () => {
+ gate.release?.();
+ await Promise.all([pDepth, pRotation]);
+ });
+
+ expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({
+ depth: 4,
+ rotationPreset: "iso",
+ });
+ });
+
+ // Rebase compatibility (#694 × current main): `updateZoomClickImpact` joined the zoom pane
+ // after this PR was authored, as one more one-field whole-document writer. While a level
+ // write is still pending, a click-impact toggle built from the render's document would
+ // rebuild the pill from the stale pre-level document — and whichever save landed last won,
+ // so the pending level could come back off. Click impact must share the zoom chain so both
+ // values survive.
+ it("keeps a pending zoom level when click impact toggles before it lands", async () => {
+ seed(docWithZoom);
+ const gate = gateFirstSave();
+ const { result } = renderTimeline();
+
+ const pDepth = result.current.updateZoomDepth("zoom_a", 4);
+ const pImpact = result.current.updateZoomClickImpact("zoom_a", true);
+ await waitFor(() => expect(gate.release).toEqual(expect.any(Function)));
+ await act(async () => {
+ gate.release?.();
+ await Promise.all([pDepth, pImpact]);
+ });
+
+ expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({
+ depth: 4,
+ clickImpact: true,
+ });
+ });
+
+ // Rebase-review finding (queued zoom writes vs. document replacement): a zoom write
+ // queued behind a still-pending one starts AFTER an undo has restored the document,
+ // and must not apply its stale patch to the replacement. The in-flight write itself
+ // is dropped by `saveDocument`'s epoch check; the queued one is the hole.
+ it("drops a queued zoom write that starts after an undo replaces the document", async () => {
+ seed(docWithZoom);
+ const { result } = renderTimeline();
+ // One settled write so the undo has a recorded state to restore.
+ await act(async () => {
+ await result.current.updateZoomDepth("zoom_a", 4);
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(4);
+
+ const gate = gateFirstSave();
+ const pRotation = result.current.updateZoomRotation("zoom_a", "iso");
+ const pCursor = result.current.updateZoomHideCursor("zoom_a", true);
+ await waitFor(() => expect(gate.release).toEqual(expect.any(Function)));
+ let undid = false;
+ act(() => {
+ undid = undo();
+ });
+ expect(undid).toBe(true);
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3);
+
+ await act(async () => {
+ gate.release?.();
+ await Promise.allSettled([pRotation, pCursor]);
+ });
+
+ // The undo's result stands; neither queued write landed on the restored document.
+ expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ depth: 3 });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.rotationPreset).toBeUndefined();
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.hideCursor).toBeUndefined();
+ });
+
+ // Same finding through the project-switch path: `loadProject` replaces projectId and
+ // document without superseding the queue, and project B deliberately contains the same
+ // region id, so a stale patch must not escape detection by id coincidence.
+ it("drops queued zoom writes when a project switch replaces the document", async () => {
+ seed(docWithZoom);
+ const { result } = renderTimeline();
+ const projectB: AxcutDocument = {
+ ...docWithZoom,
+ project: { ...docWithZoom.project, id: "proj_b", title: "Project B" },
+ zoomRanges: [{ ...docWithZoom.zoomRanges[0]!, depth: 2 }],
+ };
+
+ const gate = gateFirstSave();
+ const pRotation = result.current.updateZoomRotation("zoom_a", "iso");
+ const pCursor = result.current.updateZoomHideCursor("zoom_a", true);
+ await waitFor(() => expect(gate.release).toEqual(expect.any(Function)));
+ bridgeMocks.get.mockResolvedValue({ success: true, document: projectB });
+ await act(async () => {
+ await useProjectStore.getState().loadProject("proj_b");
+ });
+ expect(useProjectStore.getState().projectId).toBe("proj_b");
+
+ await act(async () => {
+ gate.release?.();
+ await Promise.allSettled([pRotation, pCursor]);
+ });
+
+ // Project B's own zoom_a is untouched by the stale project-A queue.
+ expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({
+ id: "zoom_a",
+ depth: 2,
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.rotationPreset).toBeUndefined();
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.hideCursor).toBeUndefined();
+ });
+
+ // Rebase-review finding (stalled save blocks the zoom queue): while one zoom save's
+ // answer is unknown (bridge never settles), later zoom-pane writes must not queue
+ // behind it forever — they are refused until the unknown save settles, then work
+ // again. The refusal is the safe half: the unknown save may still land, so racing
+ // it would recreate the stale-document overwrite this chain exists to prevent.
+ it("refuses zoom writes while a save is unknown and recovers when it settles", async () => {
+ seed(docWithZoom);
+ vi.useFakeTimers();
+ try {
+ let hungDoc: AxcutDocument | undefined;
+ let releaseHungSave: (result: { success: boolean; document: AxcutDocument }) => void;
+ bridgeMocks.save.mockImplementation((doc: AxcutDocument) => {
+ hungDoc = doc;
+ return new Promise((resolve) => {
+ releaseHungSave = (result) => {
+ // Settle this one call only; later saves answer immediately.
+ bridgeMocks.save.mockImplementation(async (next: AxcutDocument) => ({
+ success: true,
+ document: next,
+ }));
+ resolve(result);
+ };
+ });
+ });
+ const { result } = renderTimeline();
+
+ let depthOk: boolean | undefined;
+ act(() => {
+ void result.current.updateZoomDepth("zoom_a", 4).then((ok) => {
+ depthOk = ok;
+ });
+ });
+ // Deadline passes with the bridge still silent: the write's result is unknown,
+ // reported to the caller as not-taken, and the document is left alone.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10_000);
+ });
+ expect(depthOk).toBe(false);
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3);
+
+ // A later zoom write is refused while that save is still unknown.
+ let rotationOk: boolean | undefined;
+ await act(async () => {
+ rotationOk = await result.current.updateZoomRotation("zoom_a", "iso");
+ });
+ expect(rotationOk).toBe(false);
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.rotationPreset).toBeUndefined();
+
+ // The unknown save settles late — it may land — and the refusal clears.
+ await act(async () => {
+ releaseHungSave({ success: true, document: hungDoc! });
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+ });
+ await act(async () => {
+ await result.current.updateZoomDepth("zoom_a", 5);
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(5);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ // Rebase-review follow-up (unknown save × replacement): the refusal must not outlive
+ // its reason. Once an undo bumps the epoch, the stuck save can no longer install
+ // anything (`saveDocument` drops it), so it must stop blocking zoom writes — even if
+ // the bridge never answers. A project switch does NOT bump the epoch, so there the
+ // stuck save can still land and the refusal correctly stays.
+ it("stops refusing zoom writes once a replacement makes the unknown save unable to land", async () => {
+ seed(docWithZoom);
+ vi.useFakeTimers();
+ try {
+ let hungDoc: AxcutDocument | undefined;
+ let releaseHungSave: (result: { success: boolean; document: AxcutDocument }) => void = () => {
+ // replaced once the hung save registers
+ };
+ let saveCalls = 0;
+ bridgeMocks.save.mockImplementation((doc: AxcutDocument) => {
+ saveCalls += 1;
+ if (saveCalls === 2) {
+ // The write whose answer never comes; the test never releases it until
+ // the very end, and then only to prove the epoch guard drops it.
+ hungDoc = doc;
+ return new Promise((resolve) => {
+ releaseHungSave = resolve;
+ });
+ }
+ return Promise.resolve({ success: true, document: doc });
+ });
+ const { result } = renderTimeline();
+
+ // One settled write so the undo has a recorded state to restore.
+ await act(async () => {
+ await result.current.updateZoomDepth("zoom_a", 4);
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(4);
+
+ let depthOk: boolean | undefined;
+ act(() => {
+ void result.current.updateZoomDepth("zoom_a", 5).then((ok) => {
+ depthOk = ok;
+ });
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10_000);
+ });
+ expect(depthOk).toBe(false);
+
+ // The undo replaces the document; the stuck save can no longer install it.
+ let undid = false;
+ act(() => {
+ undid = undo();
+ });
+ expect(undid).toBe(true);
+
+ // Zoom writes must work again on the restored document.
+ await act(async () => {
+ const ok = await result.current.updateZoomDepth("zoom_a", 5);
+ expect(ok).toBe(true);
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(5);
+
+ // When the stuck save finally settles, the epoch guard drops it — the restored
+ // (and since re-edited) document stands.
+ await act(async () => {
+ releaseHungSave({ success: true, document: hungDoc! });
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+ });
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(5);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("resolves a zoom-level write with whether the save took effect", async () => {
+ seed(docWithZoom);
+ const { result } = renderTimeline();
+ bridgeMocks.save.mockResolvedValueOnce({ success: false, error: "read-only" });
+
+ let ok: boolean | undefined;
+ await act(async () => {
+ ok = await result.current.updateZoomDepth("zoom_a", 4);
+ });
+
+ expect(ok).toBe(false);
+ expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3);
+ });
+
it("leaves no undo step behind a focus drag whose commit failed", async () => {
// The drag used to push its pre-drag document from the FIRST `setDocument`. When
// the commit then failed, `commitZoomFocus` restored that same document through
diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts
index 72a074f53..d47d1a4a4 100644
--- a/src/lib/ai-edition/store/useTimeline.ts
+++ b/src/lib/ai-edition/store/useTimeline.ts
@@ -42,8 +42,11 @@ import {
resolvePillIds,
} from "../timeline/timelineMap";
import { dropTrimPillsByIds, resolveTimelineSpanToTrim } from "../timeline/trim-mapping";
+import { MAX_ZOOM_SCALE, MIN_ZOOM_SCALE } from "../timeline/zoom-scale";
import type { AutoZoomSuggestion } from "../timeline/zoom-suggestions";
-import { useProjectStore, waitForDocumentSaves } from "./projectStore";
+import { saveWithDeadline, useProjectStore, waitForDocumentSaves } from "./projectStore";
+import { currentWriteEpoch } from "./undoStack";
+import { useSequentialTimelineOps } from "./useSequentialTimelineOps";
// How long a region lasts when the caller doesn't say. The timeline's toolbar
// passes its own duration instead, derived from the current zoom so the new pill
@@ -705,21 +708,88 @@ export function useTimeline() {
}
}, [saveDocument]);
- // Zoom-level control for the region-settings panel (1-6, matches
- // zoomRegionSchema's depth literal union — 1.0x..3.5x in 0.5x steps per
- // the `depth/2 + 0.5` label formula used throughout the timeline UI).
- const updateZoomDepth = useCallback(
- async (id: string, depth: 1 | 2 | 3 | 4 | 5 | 6) => {
- if (!document) return;
- const next: AxcutDocument = {
- ...document,
- zoomRanges: patchPillById(document.zoomRanges, id, {
- depth,
- }) as AxcutDocument["zoomRanges"],
- };
- await saveDocument(next, { history: true });
+ // The zoom pane's own write chain -- see `saveZoomPatch`. Only `enqueue` is used, so
+ // there is no fallback document to hand it.
+ const { enqueue: enqueueZoomWrite } = useSequentialTimelineOps({
+ fallbackDocument: null,
+ saveDocument,
+ });
+
+ // The zoom pane's one-field writes: level, 3D tilt, focus mode, cursor, click impact. Each
+ // is a whole-document save, so they share one chain and read the document INSIDE it. The
+ // level buttons step while the previous save is still out, and 3 -> 4 -> 5 built both saves
+ // from the render's depth-3 document: the main process does not order them, so the 4 could
+ // land last, and even in order one Ctrl+Z skipped a level. A neighbouring select changed
+ // while a level was pending rebuilt from that same document and put 3 back. Resolves
+ // `saveDocument`'s answer, so the level buttons can retry a failed write.
+ //
+ // Each request is bound to the project and write epoch it was asked against — the same
+ // pair `addAsset` samples: an undo bumps the epoch, a project switch swaps both, and a
+ // queued patch that only STARTS after such a replacement must not apply to the document
+ // that replaced its target. A save whose answer is unknown (`saveWithDeadline` timed out
+ // with the bridge still silent) may still land, so later zoom writes are refused until it
+ // settles instead of racing it — the same "a queued write racing a stuck one" the
+ // `waitForDocumentSaves` header calls out. The block is keyed to the save's own epoch:
+ // once a replacement moves the epoch, that save can no longer install anything
+ // (`saveDocument` drops it) and must stop blocking; a project switch does not move the
+ // epoch, so there the stuck save can still land and the block correctly stays.
+ const unknownZoomSavesRef = useRef>([]);
+ const saveZoomPatch = useCallback(
+ (id: string, patch: Partial) => {
+ const epoch = currentWriteEpoch();
+ const projectId = useProjectStore.getState().projectId;
+ return enqueueZoomWrite(async () => {
+ if (useProjectStore.getState().projectId !== projectId || currentWriteEpoch() !== epoch) {
+ return false;
+ }
+ if (unknownZoomSavesRef.current.filter((stuck) => stuck === epoch).length > 0) {
+ return false;
+ }
+ const doc = useProjectStore.getState().document;
+ if (!doc) return false;
+ const save = saveDocument(
+ {
+ ...doc,
+ zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"],
+ },
+ { history: true },
+ );
+ const outcome = await saveWithDeadline(save);
+ if (outcome !== "timeout") return outcome === true;
+ // Unknown, not failed: the write may still land. Report not-taken (the
+ // buttons retry) and refuse later writes into this same document generation
+ // until the save settles or the epoch moves past it.
+ unknownZoomSavesRef.current.push(epoch);
+ void save
+ .then(
+ () => undefined,
+ () => undefined,
+ )
+ .finally(() => {
+ unknownZoomSavesRef.current = unknownZoomSavesRef.current.filter(
+ (stuck) => stuck !== epoch,
+ );
+ });
+ return false;
+ });
},
- [document, saveDocument],
+ [enqueueZoomWrite, saveDocument],
+ );
+
+ // A preset level (`ZOOM_DEPTH_SCALES`, 1.25×–5×). It clears any custom scale, which
+ // would otherwise keep overriding the depth: the level picked is the level rendered.
+ const updateZoomDepth = useCallback(
+ (id: string, depth: 1 | 2 | 3 | 4 | 5 | 6) =>
+ saveZoomPatch(id, { depth, customScale: undefined }),
+ [saveZoomPatch],
+ );
+
+ // Any other level, from the pane's free field. Clamped to the renderer's range here, so no
+ // caller can store a scale that `effectiveZoomScale` would then read differently.
+ const updateZoomCustomScale = useCallback(
+ (id: string, scale: number) =>
+ saveZoomPatch(id, { customScale: Math.min(MAX_ZOOM_SCALE, Math.max(MIN_ZOOM_SCALE, scale)) }),
+ [saveZoomPatch],
);
// Same story as `focusMode` below: the 3D tilt was implemented end to end — schema
@@ -728,17 +798,9 @@ export function useTimeline() {
// `undefined` clears the preset back to a flat frame; `migrate.ts` already drops the field
// when it is falsy, so absent and "no rotation" are the same state.
const updateZoomRotation = useCallback(
- async (id: string, rotationPreset: Rotation3DPreset | undefined) => {
- if (!document) return;
- const next: AxcutDocument = {
- ...document,
- zoomRanges: patchPillById(document.zoomRanges, id, {
- rotationPreset,
- }) as AxcutDocument["zoomRanges"],
- };
- await saveDocument(next, { history: true });
- },
- [document, saveDocument],
+ (id: string, rotationPreset: Rotation3DPreset | undefined) =>
+ saveZoomPatch(id, { rotationPreset }),
+ [saveZoomPatch],
);
// Nothing could set `focusMode`: "auto" only ever arrived from the automatic suggestion pass
@@ -750,47 +812,24 @@ export function useTimeline() {
// Writing "manual" explicitly is safe even though `migrate.ts` only persists "auto": an absent
// field MEANS manual, so both forms resolve identically.
const updateZoomFocusMode = useCallback(
- async (id: string, focusMode: "manual" | "auto") => {
- if (!document) return;
- const next: AxcutDocument = {
- ...document,
- zoomRanges: patchPillById(document.zoomRanges, id, {
- focusMode,
- }) as AxcutDocument["zoomRanges"],
- };
- await saveDocument(next, { history: true });
- },
- [document, saveDocument],
+ (id: string, focusMode: "manual" | "auto") => saveZoomPatch(id, { focusMode }),
+ [saveZoomPatch],
);
const updateZoomHideCursor = useCallback(
- async (id: string, hideCursor: boolean | undefined) => {
- if (!document) return;
- const next: AxcutDocument = {
- ...document,
- zoomRanges: patchPillById(document.zoomRanges, id, {
- hideCursor: hideCursor ? true : undefined,
- }) as AxcutDocument["zoomRanges"],
- };
- await saveDocument(next, { history: true });
- },
- [document, saveDocument],
+ (id: string, hideCursor: boolean | undefined) =>
+ saveZoomPatch(id, { hideCursor: hideCursor ? true : undefined }),
+ [saveZoomPatch],
);
// Per-region, like the preset it animates. `undefined` rather than `false` so the document
- // keeps omitting the key when the option is off.
+ // keeps omitting the key when the option is off. Shares `saveZoomPatch` with the pane's
+ // other one-field writes: a toggle arriving while a level write is still pending must not
+ // rebuild the pill from the stale pre-level document and drop the level on the floor.
const updateZoomClickImpact = useCallback(
- async (id: string, clickImpact: boolean) => {
- if (!document) return;
- const next: AxcutDocument = {
- ...document,
- zoomRanges: patchPillById(document.zoomRanges, id, {
- clickImpact: clickImpact ? true : undefined,
- }) as AxcutDocument["zoomRanges"],
- };
- await saveDocument(next, { history: true });
- },
- [document, saveDocument],
+ (id: string, clickImpact: boolean) =>
+ saveZoomPatch(id, { clickImpact: clickImpact ? true : undefined }),
+ [saveZoomPatch],
);
const updateAnnotationSpan = useCallback(
@@ -1539,6 +1578,7 @@ export function useTimeline() {
updateZoomFocusLive,
commitZoomFocus,
updateZoomDepth,
+ updateZoomCustomScale,
updateZoomRotation,
updateZoomFocusMode,
updateZoomHideCursor,
diff --git a/tests/e2e/v4-shell.spec.ts b/tests/e2e/v4-shell.spec.ts
index 1df64a83d..5b060112d 100644
--- a/tests/e2e/v4-shell.spec.ts
+++ b/tests/e2e/v4-shell.spec.ts
@@ -14,6 +14,18 @@ const EDITOR_URL = `${BASE_URL}/?windowType=editor`;
// 300 MB exactly, so MediaStage's formatSize renders "300 MB".
const SIZED_BYTES = 314_572_800;
+// Only what a zoom region needs to survive `documentSchema` and reach the timeline.
+interface ZoomFixture {
+ id: string;
+ startMs: number;
+ endMs: number;
+ clipId: string;
+ sourceStartSec: number;
+ sourceEndSec: number;
+ depth: 1 | 2 | 3 | 4 | 5 | 6;
+ focus: { cx: number; cy: number };
+}
+
function makeAsset(id: string, label: string, sizeBytes?: number) {
return {
id,
@@ -64,7 +76,7 @@ function makeDoc() {
captionRanges: [],
},
annotations: [],
- zoomRanges: [],
+ zoomRanges: [] as ZoomFixture[],
legacyEditor: null,
agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] },
preview: { strategy: "seek" as const, revision: 0 },
@@ -73,6 +85,26 @@ function makeDoc() {
};
}
+// Same fixture with one zoom region on the only clip, so the inspector's zoom pane —
+// and the level row inside it — has something to select. Depth 3 is the editor's default,
+// and `ZOOM_DEPTH_SCALES` renders it as the "1.80×" the pill is addressed by below.
+function makeZoomDoc(): ReturnType {
+ const doc = makeDoc();
+ doc.zoomRanges = [
+ {
+ id: "zoom_e2e",
+ startMs: 60_000,
+ endMs: 180_000,
+ clipId: "clip_e2e",
+ sourceStartSec: 60,
+ sourceEndSec: 180,
+ depth: 3,
+ focus: { cx: 0.5, cy: 0.5 },
+ },
+ ];
+ return doc;
+}
+
// Same fixture, split into two clips: FloatingInspector's "Edit clip" button
// only renders its picker popover past one clip (clips.length === 1 jumps
// straight to onEditClip instead), so testing the popover needs a second clip.
@@ -243,6 +275,67 @@ test.describe("v4 editor shell", () => {
expect(await storeTimeSec()).toBeGreaterThan(400);
});
+ // The zoom levels are buttons rather than a `