Skip to content
Merged
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
122 changes: 122 additions & 0 deletions src/components/ai-edition/EditClipModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<EditClipModal
open
onClose={onClose}
clip={CLIP}
assetMeta={ASSET}
videoSources={[{ id: "asset_1", src: "file:///rec.mp4", label: "rec" }]}
onApply={vi.fn()}
/>,
);
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);
});
});
83 changes: 70 additions & 13 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,9 @@ export function EditClipModal({
const [videoAspectRatio, setVideoAspectRatio] = useState(16 / 9);
const cropFrameRef = useRef<HTMLDivElement | null>(null);
const cropVideoRef = useRef<HTMLVideoElement | null>(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.
Expand All @@ -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);
Expand All @@ -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 <video>'s metadata loads (see the effect below), so this
Expand All @@ -722,8 +739,8 @@ export function EditClipModal({
}, [open, clip, videoAspectRatio, cropTouched]);

// Crop preview: a paused still frame is enough to judge a crop (mirrors
// the standalone CropModal this replaced) — seek once per open to the
// clip's original in-point, not on every trim drag.
// the standalone CropModal this replaced). The playhead effect above seeks
// it; this one reads the frame's aspect ratio.
useEffect(() => {
if (!open || !clip) return;
// A clip switch must not leave the previous clip's dimensions live: an
Expand All @@ -735,16 +752,15 @@ export function EditClipModal({
setVideoAspectRatio(16 / 9);
const v = cropVideoRef.current;
if (!v) return;
const seek = () => {
const onMetadata = () => {
v.pause();
if (Number.isFinite(clip.sourceStartSec)) v.currentTime = clip.sourceStartSec;
if (v.videoWidth > 0 && v.videoHeight > 0) {
setVideoAspectRatio(v.videoWidth / v.videoHeight);
}
};
if (v.readyState >= 1) seek();
else v.addEventListener("loadedmetadata", seek, { once: true });
return () => v.removeEventListener("loadedmetadata", seek);
if (v.readyState >= 1) onMetadata();
else v.addEventListener("loadedmetadata", onMetadata, { once: true });
return () => v.removeEventListener("loadedmetadata", onMetadata);
}, [open, clip]);

if (!clip) return null;
Expand Down Expand Up @@ -785,12 +801,18 @@ export function EditClipModal({
setActiveEdge(edge);
const move = (moveEvent: PointerEvent) => {
const deltaSec = ((moveEvent.clientX - startClientX) / widthPx) * sourceDurationSec;
// A trim that crosses the playhead pushes it along, so it never leaves the kept range.
if (edge === "start") {
setDraftStart(Math.min(Math.max(startDraftStart + deltaSec, 0), startDraftEnd - 0.05));
const next = Math.min(Math.max(startDraftStart + deltaSec, 0), startDraftEnd - 0.05);
setDraftStart(next);
setPlayheadSec((p) => Math.max(p, next));
} else {
setDraftEnd(
Math.max(Math.min(startDraftEnd + deltaSec, sourceDurationSec), startDraftStart + 0.05),
const next = Math.max(
Math.min(startDraftEnd + deltaSec, sourceDurationSec),
startDraftStart + 0.05,
);
setDraftEnd(next);
setPlayheadSec((p) => Math.min(p, next));
}
};
const end = () => {
Expand All @@ -802,6 +824,28 @@ export function EditClipModal({
window.addEventListener("pointerup", end, { once: true });
};

// Click or drag the track to put the playhead on the frame to crop on, held inside the
// kept range. 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) => {
const sec = ((clientX - left) / widthPx) * sourceDurationSec;
setPlayheadSec(Math.min(Math.max(sec, draftStart), draftEnd));
};
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 handleCropRatioChange = (next: string) => {
setCropTouched(true);
setCropRatio(next);
Expand Down Expand Up @@ -903,8 +947,10 @@ export function EditClipModal({
};

const handleReset = () => {
const end = clip.sourceEndSec ?? clip.sourceStartSec;
setDraftStart(clip.sourceStartSec);
setDraftEnd(clip.sourceEndSec ?? clip.sourceStartSec);
setDraftEnd(end);
setPlayheadSec((p) => Math.min(Math.max(p, clip.sourceStartSec), end));
const region = clip.cropRegion ?? IDENTITY_CROP;
const pct = cropDraftToPct(cropDraftFromRegion(region));
setCropXPct(pct.x);
Expand Down Expand Up @@ -1088,8 +1134,14 @@ export function EditClipModal({
<span>{formatSeconds(sourceDurationSec)}</span>
</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}>
discarded head and tail. The playhead is the only thing painted over the grips, and
it never takes a grab. */}
<div
ref={trackRef}
data-testid="edit-clip-trim-track"
className={styles.editClipTrack}
onPointerDown={startScrub}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<div
className={`${styles.editClipRange}${activeEdge ? ` ${styles.editClipRangeDragging}` : ""}`}
style={{
Expand All @@ -1114,6 +1166,11 @@ export function EditClipModal({
title={t("editClipDialog.adjustEnd")}
/>
</div>
<div
className={styles.editClipPlayhead}
data-testid="edit-clip-playhead"
style={{ left: `${(playheadSec / sourceDurationSec) * 100}%` }}
/>
</div>
</div>

Expand Down
14 changes: 14 additions & 0 deletions src/components/ai-edition/NewEditorShell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,8 @@ textarea.control {
on it, the timeline's clip card (.tlClip's candy recipe) for the range the trim keeps. */
.editClipTrack {
position: relative;
cursor: pointer;
touch-action: none;
height: 32px;
flex-shrink: 0;
border-radius: var(--r-sm);
Expand Down Expand Up @@ -2268,6 +2270,18 @@ textarea.control {
.editClipGrip[data-edge="start"]::before { left: 9px; }
.editClipGrip[data-edge="end"]::before { right: 9px; }
.editClipGrip:hover::before { transform: scaleY(1.25); }
/* The preview's playhead: the timeline's line, over the range, and never in the way of a grab. */
.editClipPlayhead {
position: absolute;
top: -4px;
bottom: -4px;
width: 2px;
margin-left: -1px;
border-radius: 1px;
background: var(--playhead);
box-shadow: 0 0 8px var(--playhead-glow);
pointer-events: none;
}

/* ─── AI provider settings (axcut parity) ────────────────────────── */

Expand Down
Loading