Skip to content
Draft
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
120 changes: 119 additions & 1 deletion src/components/ai-edition/EditClipModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
<EditClipModal
open
onClose={vi.fn()}
clip={CLIP}
assetMeta={ASSET}
videoSources={[{ id: "asset_1", src: "file:///rec.mp4", label: "rec" }]}
onApply={vi.fn()}
/>,
);
// 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);
});
});
189 changes: 166 additions & 23 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -679,6 +690,13 @@ export function EditClipModal({
const [videoAspectRatio, setVideoAspectRatio] = useState(16 / 9);
const cropFrameRef = useRef<HTMLDivElement | null>(null);
const cropVideoRef = useRef<HTMLVideoElement | null>(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.
Expand All @@ -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);
Expand All @@ -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 <video>'s metadata loads (see the effect below), so this
Expand Down Expand Up @@ -769,6 +834,7 @@ export function EditClipModal({
const startClientX = event.clientX;
const startDraftStart = draftStart;
const startDraftEnd = draftEnd;
setPlaying(false);
setActiveEdge(edge);
const move = (moveEvent: PointerEvent) => {
const deltaSec = ((moveEvent.clientX - startClientX) / widthPx) * sourceDurationSec;
Expand All @@ -789,6 +855,62 @@ export function EditClipModal({
window.addEventListener("pointerup", end, { once: true });
};

const seekPlayhead = (sec: number) => {
setPlaying(false);
setPlayheadSec(Math.min(Math.max(sec, draftStart), draftEnd));
};

// Click or drag anywhere on the track to scrub. The grips stop their own pointerdown,
// so grabbing one trims instead.
const startScrub = (event: ReactPointerEvent<HTMLDivElement>) => {
const track = trackRef.current;
if (!track) return;
event.preventDefault();
const left = track.getBoundingClientRect().left;
const widthPx = Math.max(1, track.clientWidth);
const seekAt = (clientX: number) =>
seekPlayhead(((clientX - left) / widthPx) * sourceDurationSec);
seekAt(event.clientX);
const move = (moveEvent: PointerEvent) => seekAt(moveEvent.clientX);
const end = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end, { once: true });
};

const togglePlay = () => {
if (playing) {
setPlaying(false);
return;
}
// At the out-point, play again from the in-point.
const from = keptPlayheadSec >= draftEnd - 0.001 ? draftStart : keptPlayheadSec;
setPlayheadSec(from);
const v = cropVideoRef.current;
if (v) v.currentTime = from;
setPlaying(true);
};

// Space plays and pauses, the arrows step a frame (a second with Shift) — the editor's
// own keys, kept in here: the shell ignores its shortcuts while a modal is open. The crop
// region stops its own arrows before they get this far.
onTransportKeyRef.current = (e: KeyboardEvent) => {
if (e.target instanceof HTMLElement && e.target.closest("input, textarea, select")) return;
if (e.key === " ") {
// Also keeps a focused button from taking the Space as a click.
e.preventDefault();
togglePlay();
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const stepSec = e.shiftKey ? 1 : 1 / 60;
seekPlayhead(keptPlayheadSec + (e.key === "ArrowLeft" ? -stepSec : stepSec));
}
};

const handleCropRatioChange = (next: string) => {
setCropTouched(true);
setCropRatio(next);
Expand Down Expand Up @@ -927,7 +1049,8 @@ export function EditClipModal({
<video
ref={cropVideoRef}
src={cropPreviewSource.src}
muted
// Unmuted: the file's own audio is what the main preview plays as its
// primary audio too (same src).
playsInline
style={{
position: "absolute",
Expand Down Expand Up @@ -1000,26 +1123,36 @@ export function EditClipModal({
</div>

<div style={{ flexShrink: 0 }}>
<div
style={{ display: "flex", gap: 24, marginBottom: 10 }}
aria-live="polite"
aria-atomic="true"
>
<RangeStat
label={t("editClipDialog.originalDuration")}
value={assetDurationSec === null ? "—" : formatSeconds(assetDurationSec)}
testId="edit-clip-original-duration"
/>
<RangeStat
label={t("editClipDialog.trimRange")}
value={`${formatSeconds(draftStart)}–${formatSeconds(draftEnd)}`}
testId="edit-clip-trim-range"
/>
<RangeStat
label={t("editClipDialog.duration")}
value={formatSeconds(durationSec)}
testId="edit-clip-final-duration"
/>
<div style={{ display: "flex", alignItems: "flex-start", gap: 24, marginBottom: 10 }}>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={togglePlay}
disabled={!cropPreviewSource}
aria-label={t("transport.playPause")}
title={t("transport.playPauseTitle")}
aria-pressed={playing}
data-testid="edit-clip-play"
>
{playing ? <Pause size={14} /> : <Play size={14} />}
</button>
<div style={{ display: "flex", gap: 24 }} aria-live="polite" aria-atomic="true">
<RangeStat
label={t("editClipDialog.originalDuration")}
value={assetDurationSec === null ? "—" : formatSeconds(assetDurationSec)}
testId="edit-clip-original-duration"
/>
<RangeStat
label={t("editClipDialog.trimRange")}
value={`${formatSeconds(draftStart)}–${formatSeconds(draftEnd)}`}
testId="edit-clip-trim-range"
/>
<RangeStat
label={t("editClipDialog.duration")}
value={formatSeconds(durationSec)}
testId="edit-clip-final-duration"
/>
</div>
</div>

<div
Expand All @@ -1037,7 +1170,12 @@ export function EditClipModal({
</div>
{/* The kept range is the timeline's clip card; the bare groove around it is the
discarded head and tail. Nothing else is painted over the grips. */}
<div ref={trackRef} data-testid="edit-clip-trim-track" className={styles.editClipTrack}>
<div
ref={trackRef}
data-testid="edit-clip-trim-track"
className={styles.editClipTrack}
onPointerDown={startScrub}
>
<div
className={`${styles.editClipRange}${activeEdge ? ` ${styles.editClipRangeDragging}` : ""}`}
style={{
Expand All @@ -1062,6 +1200,11 @@ export function EditClipModal({
title={t("editClipDialog.adjustEnd")}
/>
</div>
<div
className={styles.editClipPlayhead}
data-testid="edit-clip-playhead"
style={{ left: `${(keptPlayheadSec / sourceDurationSec) * 100}%` }}
/>
</div>
</div>

Expand Down
Loading
Loading