diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index b8a42069e..fe14aee33 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -2,7 +2,7 @@ import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { EditClipModal } from "./Modals"; @@ -162,3 +162,121 @@ describe("EditClipModal crop from the keyboard", () => { ); }); }); + +describe("EditClipModal preview transport", () => { + // jsdom has no media pipeline: stand in a clock the modal can seek, and a rAF the test + // steps by hand. + let videoTime = 0; + let frame: FrameRequestCallback | null = null; + 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.play = () => Promise.resolve(); + HTMLMediaElement.prototype.pause = vi.fn(); + }); + beforeEach(() => { + videoTime = 0; + frame = null; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + frame = cb; + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + afterEach(() => vi.unstubAllGlobals()); + + const renderWithVideo = () => + renderWithI18n( + , + ); + // 1550px track over 155s: 10px a second. + const playheadPct = () => screen.getByTestId("edit-clip-playhead").style.left; + const pct = (sec: number) => `${(sec / 155) * 100}%`; + + it("scrubs on a click or drag of the track, held inside the kept range", () => { + renderWithVideo(); + 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: 1500 })); + }); + expect(playheadPct()).toBe(pct(105)); + act(() => { + window.dispatchEvent(new MouseEvent("pointerup")); + }); + fireEvent.pointerDown(track, { clientX: 50 }); + expect(playheadPct()).toBe(pct(20)); + }); + + it("plays and pauses on Space, and steps on the arrows", () => { + renderWithVideo(); + const play = screen.getByTestId("edit-clip-play"); + + fireEvent.keyDown(document.body, { key: " " }); + expect(play).toHaveAttribute("aria-pressed", "true"); + videoTime = 30; + act(() => frame?.(0)); + expect(playheadPct()).toBe(pct(30)); + fireEvent.keyDown(document.body, { key: " " }); + expect(play).toHaveAttribute("aria-pressed", "false"); + + fireEvent.keyDown(document.body, { key: "ArrowRight", shiftKey: true }); + expect(playheadPct()).toBe(pct(31)); + expect(videoTime).toBe(31); + }); + + it("stops at the out-point, and plays again from the in-point", () => { + renderWithVideo(); + const play = screen.getByTestId("edit-clip-play"); + + fireEvent.click(play); + videoTime = 106; + act(() => frame?.(0)); + expect(play).toHaveAttribute("aria-pressed", "false"); + expect(playheadPct()).toBe(pct(105)); + + fireEvent.click(play); + expect(videoTime).toBe(20); + }); + + it("shows the held trim handle's frame, then goes back to the playhead", () => { + renderWithVideo(); + fireEvent.pointerDown(screen.getByTestId("edit-clip-trim-track"), { clientX: 500 }); + act(() => { + window.dispatchEvent(new MouseEvent("pointerup")); + }); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip end" }), { + clientX: 0, + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: -100 })); + }); + expect(videoTime).toBe(95); + + act(() => { + window.dispatchEvent(new MouseEvent("pointerup")); + }); + expect(videoTime).toBe(50); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index dfe2f20a7..15e9e0178 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -1,4 +1,15 @@ -import { AlertTriangle, Crop, FolderOpen, FolderPlus, Pencil, Plus, Trash2, X } from "lucide-react"; +import { + AlertTriangle, + Crop, + FolderOpen, + FolderPlus, + Pause, + Pencil, + Play, + Plus, + Trash2, + X, +} from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, @@ -679,6 +690,13 @@ export function EditClipModal({ const [videoAspectRatio, setVideoAspectRatio] = useState(16 / 9); const cropFrameRef = useRef(null); const cropVideoRef = useRef(null); + // Preview transport, on the source clock like the trim. The playhead stays inside the + // kept range: this plays what the clip will keep, not the whole recording. + const [playheadSec, setPlayheadSec] = useState(0); + const [playing, setPlaying] = useState(false); + // Space and the arrows are handled at document level; the listener reads the latest + // render's handler through this ref instead of re-registering on every playback tick. + const onTransportKeyRef = useRef<((e: KeyboardEvent) => void) | null>(null); // 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. @@ -687,6 +705,8 @@ export function EditClipModal({ setDraftStart(clip.sourceStartSec); setDraftEnd(clip.sourceEndSec ?? clip.sourceStartSec); setActiveEdge(null); + setPlayheadSec(clip.sourceStartSec); + setPlaying(false); const region = clip.cropRegion ?? IDENTITY_CROP; const pct = cropDraftToPct(cropDraftFromRegion(region)); setCropXPct(pct.x); @@ -696,6 +716,51 @@ export function EditClipModal({ setCropTouched(false); }, [open, clip]); + // Playback: the video's own clock drives the playhead until the out-point, where it + // stops. Dragging a trim handle, scrubbing or stepping all pause first, so the out-point + // can't move under a running loop. + useEffect(() => { + const v = cropVideoRef.current; + if (!open || !playing || !v) return; + let raf = 0; + const tick = () => { + if (v.currentTime >= draftEnd) { + v.pause(); + setPlayheadSec(draftEnd); + setPlaying(false); + return; + } + setPlayheadSec(v.currentTime); + raf = requestAnimationFrame(tick); + }; + v.play().catch(() => setPlaying(false)); + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + v.pause(); + }; + }, [open, playing, draftEnd]); + + // While a trim handle is held the picture shows that handle's frame; let go and it + // returns to the playhead. + // A trim that moves past the playhead takes the playhead with it. + const keptPlayheadSec = Math.min(Math.max(playheadSec, draftStart), draftEnd); + const previewSec = + activeEdge === "start" ? draftStart : activeEdge === "end" ? draftEnd : keptPlayheadSec; + useEffect(() => { + const v = cropVideoRef.current; + // Before metadata, the effect below does the first seek. + if (!open || playing || !v || v.readyState < 1) return; + v.currentTime = previewSec; + }, [open, playing, previewSec]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => onTransportKeyRef.current?.(e); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open]); + // 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