From f5452ed1dbbab9204e8dee6d3e6a1e164a930eda Mon Sep 17 00:00:00 2001 From: Prayas Lashkari Date: Tue, 29 Sep 2026 00:27:17 -0400 Subject: [PATCH 1/2] feat(edit-clip): scrub the clip to frame the crop - A playhead on the trim bar; a click or drag on the bar puts it on a frame, and the preview shows that frame to frame the crop on. - It stays inside the kept range: a scrub is held there, and a trim that crosses it pushes it along. - It is local to the dialog and is not an edit: Apply stays off. --- .../ai-edition/EditClipModal.test.tsx | 102 ++++++++++++++++++ src/components/ai-edition/Modals.tsx | 68 ++++++++++-- .../ai-edition/NewEditorShell.module.css | 14 +++ 3 files changed, 177 insertions(+), 7 deletions(-) diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index f8d5c271d..b59463c52 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -235,3 +235,105 @@ describe("EditClipModal crop from the keyboard", () => { ); }); }); + +describe("EditClipModal playhead for framing the crop", () => { + // jsdom has no media pipeline: stand in a clock the modal can seek. + let videoTime = 0; + beforeAll(() => { + Object.defineProperty(HTMLMediaElement.prototype, "currentTime", { + configurable: true, + get: () => videoTime, + set: (v: number) => { + videoTime = v; + }, + }); + Object.defineProperty(HTMLMediaElement.prototype, "readyState", { + configurable: true, + get: () => 1, + }); + HTMLMediaElement.prototype.pause = vi.fn(); + }); + + function renderWithVideo() { + const onClose = vi.fn(); + renderWithI18n( + , + ); + return { onClose }; + } + // 1550px track over 155s: 10px a second. + const playheadPct = () => screen.getByTestId("edit-clip-playhead").style.left; + const pct = (sec: number) => `${(sec / 155) * 100}%`; + const drag = (target: HTMLElement, fromX: number, toX: number) => { + fireEvent.pointerDown(target, { clientX: fromX }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: toX })); + window.dispatchEvent(new MouseEvent("pointerup")); + }); + }; + + it("opens on the in-point and shows the frame under a click or drag of the track", () => { + renderWithVideo(); + expect(playheadPct()).toBe(pct(20)); + expect(videoTime).toBe(20); + const track = screen.getByTestId("edit-clip-trim-track"); + + fireEvent.pointerDown(track, { clientX: 500 }); + expect(playheadPct()).toBe(pct(50)); + expect(videoTime).toBe(50); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 700 })); + }); + expect(playheadPct()).toBe(pct(70)); + expect(videoTime).toBe(70); + }); + + it("holds the playhead inside the kept range", () => { + renderWithVideo(); + const track = screen.getByTestId("edit-clip-trim-track"); + + drag(track, 500, 1500); + expect(playheadPct()).toBe(pct(105)); + expect(videoTime).toBe(105); + drag(track, 50, 50); + expect(playheadPct()).toBe(pct(20)); + expect(videoTime).toBe(20); + }); + + it("is pushed along by a trim that crosses it, and stays there", () => { + renderWithVideo(); + drag(screen.getByTestId("edit-clip-trim-track"), 500, 500); + const end = screen.getByRole("button", { name: "Adjust clip end" }); + + drag(end, 0, -600); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–0:45.0"); + expect(playheadPct()).toBe(pct(45)); + expect(videoTime).toBe(45); + + drag(end, 0, 300); + expect(playheadPct()).toBe(pct(45)); + expect(videoTime).toBe(45); + + drag(screen.getByRole("button", { name: "Adjust clip start" }), 0, 400); + expect(playheadPct()).toBe(pct(60)); + expect(videoTime).toBe(60); + }); + + it("is not an edit: Apply stays off and the backdrop still closes", () => { + const { onClose } = renderWithVideo(); + drag(screen.getByTestId("edit-clip-trim-track"), 500, 800); + + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + const backdrop = document.querySelector('[class*="modalBackdrop"]'); + if (!backdrop) throw new Error("no modal backdrop rendered"); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index bc5833e53..a765b1432 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -692,6 +692,9 @@ export function EditClipModal({ const [videoAspectRatio, setVideoAspectRatio] = useState(16 / 9); const cropFrameRef = useRef(null); const cropVideoRef = useRef(null); + // The frame the crop is framed on, on the source clock like the trim. Local to this + // dialog, and always inside the kept range. + const [playheadSec, setPlayheadSec] = useState(0); // ponytail: sync local drag state to the clip every time the modal opens. // `open` is the trigger so external clip changes don't fight the user mid-edit. @@ -700,6 +703,7 @@ export function EditClipModal({ setDraftStart(clip.sourceStartSec); setDraftEnd(clip.sourceEndSec ?? clip.sourceStartSec); setActiveEdge(null); + setPlayheadSec(clip.sourceStartSec); const region = clip.cropRegion ?? IDENTITY_CROP; const pct = cropDraftToPct(cropDraftFromRegion(region)); setCropXPct(pct.x); @@ -709,6 +713,14 @@ export function EditClipModal({ setCropTouched(false); }, [open, clip]); + // The picture follows the playhead. + useEffect(() => { + const v = cropVideoRef.current; + // Before metadata, the crop preview effect below does the first seek. + if (!open || !v || v.readyState < 1) return; + v.currentTime = playheadSec; + }, [open, playheadSec]); + // Re-detect the active ratio preset whenever the stored region or the // video's real aspect ratio changes — the latter only becomes accurate // once the crop