diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index f8d5c271d..9d5c5ad07 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -235,3 +235,125 @@ 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; + let readyState = 1; + afterEach(() => { + readyState = 1; + }); + beforeAll(() => { + Object.defineProperty(HTMLMediaElement.prototype, "currentTime", { + configurable: true, + get: () => videoTime, + set: (v: number) => { + videoTime = v; + }, + }); + Object.defineProperty(HTMLMediaElement.prototype, "readyState", { + configurable: true, + get: () => readyState, + }); + 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("keeps a scrub made before the video's metadata loads", () => { + readyState = 0; + videoTime = 0; + renderWithVideo(); + drag(screen.getByTestId("edit-clip-trim-track"), 500, 500); + expect(videoTime).toBe(0); + + readyState = 1; + const video = document.querySelector("video"); + if (!video) throw new Error("no preview video rendered"); + act(() => { + video.dispatchEvent(new Event("loadedmetadata")); + }); + expect(videoTime).toBe(50); + }); + + 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..f2b7bb7a8 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,19 @@ export function EditClipModal({ setCropTouched(false); }, [open, clip]); + // The picture follows the playhead: every seek of the preview is this one, including the + // first, and a scrub before the metadata lands is not lost. + useEffect(() => { + const v = cropVideoRef.current; + if (!open || !v) return; + const seek = () => { + v.currentTime = playheadSec; + }; + if (v.readyState >= 1) seek(); + else v.addEventListener("loadedmetadata", seek, { once: true }); + return () => v.removeEventListener("loadedmetadata", seek); + }, [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