diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index 9d5c5ad07..e8d93e548 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -357,3 +357,98 @@ describe("EditClipModal playhead for framing the crop", () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe("EditClipModal crop in pixels", () => { + beforeAll(() => { + // jsdom has no media pipeline: report a loaded 1920x1080 source. + Object.defineProperty(HTMLMediaElement.prototype, "readyState", { + configurable: true, + get: () => 1, + }); + Object.defineProperty(HTMLMediaElement.prototype, "currentTime", { + configurable: true, + get: () => 0, + set: vi.fn(), + }); + HTMLMediaElement.prototype.pause = vi.fn(); + Object.defineProperty(HTMLVideoElement.prototype, "videoWidth", { + configurable: true, + get: () => 1920, + }); + Object.defineProperty(HTMLVideoElement.prototype, "videoHeight", { + configurable: true, + get: () => 1080, + }); + }); + + function renderWithVideo(videoSources: Array<{ id: string; src: string; label: string }>) { + const onApply = vi.fn(); + renderWithI18n( + , + ); + return { onApply }; + } + const withVideo = [{ id: "asset_1", src: "file:///rec.mp4", label: "rec" }]; + const field = (name: string) => screen.getByLabelText(name) as HTMLInputElement; + + it("shows the whole frame in pixels, labelled for each side", () => { + renderWithVideo(withVideo); + expect(field("X (px)").value).toBe("0"); + expect(field("Y (px)").value).toBe("0"); + expect(field("Width (px)").value).toBe("1920"); + expect(field("Height (px)").value).toBe("1080"); + }); + + it("stays off until the source size is known", () => { + renderWithVideo([]); + expect(field("Width (px)")).toBeDisabled(); + expect(field("X (px)").value).toBe(""); + }); + + it("applies typed pixels as a crop, clamped to the frame", () => { + const { onApply } = renderWithVideo(withVideo); + fireEvent.change(field("Width (px)"), { target: { value: "960" } }); + fireEvent.change(field("X (px)"), { target: { value: "5000" } }); + expect(field("X (px)").value).toBe("5000"); // what is being typed stays until blur + fireEvent.blur(field("X (px)")); + expect(field("X (px)").value).toBe("960"); + + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onApply).toHaveBeenCalledWith( + 20, + 105, + expect.objectContaining({ x: 0.5, y: 0, width: 0.5, height: 1 }), + ); + }); + + it("lets a field be cleared and retyped", () => { + renderWithVideo(withVideo); + fireEvent.change(field("Height (px)"), { target: { value: "" } }); + expect(field("Height (px)").value).toBe(""); + fireEvent.change(field("Height (px)"), { target: { value: "540" } }); + expect(field("Height (px)").value).toBe("540"); + }); + + it("follows the crop when it is moved with the keyboard", () => { + renderWithVideo(withVideo); + fireEvent.change(field("Width (px)"), { target: { value: "960" } }); + fireEvent.keyDown(screen.getByRole("slider", { name: "Crop" }), { key: "ArrowRight" }); + expect(field("X (px)").value).toBe("19"); // 1% of 1920, rounded + expect(field("Width (px)").value).toBe("960"); + }); + + it("drops a ratio preset when a typed size no longer has that shape", () => { + renderWithVideo(withVideo); + fireEvent.click(screen.getByRole("button", { name: "1:1" })); + expect(screen.getByRole("button", { name: "1:1" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.change(field("Width (px)"), { target: { value: "1000" } }); + expect(screen.getByRole("button", { name: "1:1" })).toHaveAttribute("aria-pressed", "false"); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index abe259c12..8001c6053 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -4,6 +4,7 @@ import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, + useId, useRef, useState, } from "react"; @@ -11,7 +12,19 @@ import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { formatSeconds } from "@/lib/ai-edition/timeline/format"; -import { cropDraftFromRegion, cropDraftToPct, previewBoxStyle } from "./cropDraft"; +import { + type CropField, + type CropPct, + cropDraftFromRegion, + cropDraftToPct, + cropFieldRangePx, + cropPctToPx, + type FrameSize, + isUsableFrameSize, + MIN_CROP_PCT, + previewBoxStyle, + setCropFieldPx, +} from "./cropDraft"; import styles from "./NewEditorShell.module.css"; import { ChoiceRow } from "./RightPanes"; import type { VideoSource } from "./VirtualPreview"; @@ -614,11 +627,10 @@ function centeredFitPct(fr: number): { x: number; y: number; w: number; h: numbe return { x: (100 - w) / 2, y: 0, w, h: 100 }; } -const MIN_PCT = 4; +const MIN_PCT = MIN_CROP_PCT; const clampPct = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type ResizeEdges = { left?: boolean; right?: boolean; top?: boolean; bottom?: boolean }; -type CropPct = { x: number; y: number; w: number; h: number }; /** `start` with its `edges` moved by (dxPct, dyPct), kept inside the frame. With a locked * fraction-space ratio `fr`, the opposite dimension follows to keep width/height locked. */ @@ -731,6 +743,8 @@ export function EditClipModal({ // fraction-of-frame width/height. 16/9 is just a placeholder until the // crop