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
95 changes: 95 additions & 0 deletions src/components/ai-edition/EditClipModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<EditClipModal
open
onClose={vi.fn()}
clip={CLIP}
assetMeta={ASSET}
videoSources={videoSources}
onApply={onApply}
/>,
);
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");
});
});
108 changes: 105 additions & 3 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,27 @@ import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
useEffect,
useId,
useRef,
useState,
} from "react";
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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -731,6 +743,8 @@ export function EditClipModal({
// fraction-of-frame width/height. 16/9 is just a placeholder until the
// crop <video>'s real metadata loads (see the effect below).
const [videoAspectRatio, setVideoAspectRatio] = useState(16 / 9);
// The same frame in pixels, for the X / Y / Width / Height fields. Null until the metadata loads.
const [videoSize, setVideoSize] = useState<FrameSize | null>(null);
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
Expand Down Expand Up @@ -791,12 +805,14 @@ export function EditClipModal({
// below refills them (immediately, when this clip's metadata is already
// loaded).
setVideoAspectRatio(16 / 9);
setVideoSize(null);
const v = cropVideoRef.current;
if (!v) return;
const onMetadata = () => {
v.pause();
if (v.videoWidth > 0 && v.videoHeight > 0) {
setVideoAspectRatio(v.videoWidth / v.videoHeight);
setVideoSize({ width: v.videoWidth, height: v.videoHeight });
}
};
if (v.readyState >= 1) onMetadata();
Expand Down Expand Up @@ -940,6 +956,24 @@ export function EditClipModal({
setCropHPct(next.h);
};

// A value typed into one of the pixel fields. Size edits are free-form, so a ratio preset
// is re-detected from the new shape (and drops to Free when it no longer matches).
const handleCropFieldChange = (field: CropField, px: number) => {
if (!isUsableFrameSize(videoSize)) return;
const current = { x: cropXPct, y: cropYPct, w: cropWPct, h: cropHPct };
const next = setCropFieldPx(current, field, px, videoSize);
setCropTouched(true);
setCrop(next);
if (field === "w" || field === "h") {
setCropRatio(
detectRatio(
{ x: next.x / 100, y: next.y / 100, width: next.w / 100, height: next.h / 100 },
videoAspectRatio,
),
);
}
};

// The keyboard's way to the same two gestures: the arrows move the crop, Shift + the
// arrows resize it from its bottom-right corner, a locked ratio held as the handles hold it.
const onCropKeyDown = (e: ReactKeyboardEvent) => {
Expand Down Expand Up @@ -1237,11 +1271,79 @@ export function EditClipModal({
/>
</div>
</div>
<div
role="group"
aria-label={ts("crop.position")}
style={{ display: "flex", gap: 10, marginTop: 8 }}
>
{(
[
["x", ts("crop.x")],
["y", ts("crop.y")],
["w", ts("crop.width")],
["h", ts("crop.height")],
] as const
).map(([field, label]) => {
const crop = { x: cropXPct, y: cropYPct, w: cropWPct, h: cropHPct };
const usable = isUsableFrameSize(videoSize);
return (
<CropPxField
key={field}
label={label}
value={usable ? cropPctToPx(crop, videoSize)[field] : null}
range={usable ? cropFieldRangePx(crop, field, videoSize) : null}
onChange={(px) => handleCropFieldChange(field, px)}
/>
);
})}
</div>
</div>
</ModalShell>
);
}

/** One of the crop's pixel fields. It keeps what is being typed until it loses focus, so a
* field can be cleared and retyped; every valid number is applied to the crop as it is typed,
* and the field shows the clamped value again on blur. */
function CropPxField({
label,
value,
range,
onChange,
}: {
label: string;
value: number | null;
range: { min: number; max: number } | null;
onChange: (px: number) => void;
}) {
const id = useId();
const [draft, setDraft] = useState<string | null>(null);
return (
<div style={{ display: "flex", alignItems: "center", gap: 6, flex: 1, minWidth: 0 }}>
<label htmlFor={id} className={styles.fieldLabel} style={{ flexShrink: 0 }}>
{label}
</label>
<input
id={id}
className={styles.control}
type="number"
inputMode="numeric"
step={1}
min={range?.min}
max={range?.max}
disabled={value === null}
value={value === null ? "" : (draft ?? String(value))}
onChange={(e) => {
setDraft(e.target.value);
if (e.target.value.trim() !== "") onChange(Number(e.target.value));
}}
onBlur={() => setDraft(null)}
style={{ flex: 1, minWidth: 0, fontVariantNumeric: "tabular-nums" }}
/>
</div>
);
}

function RangeStat({ label, value, testId }: { label: string; value: string; testId?: string }) {
return (
<div data-testid={testId} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
Expand Down
94 changes: 93 additions & 1 deletion src/components/ai-edition/cropDraft.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { cropDraftFromRegion, PREVIEW_MAX_HEIGHT_PX, previewBoxStyle } from "./cropDraft";
import {
type CropPct,
cropDraftFromRegion,
cropFieldRangePx,
cropPctToPx,
isUsableFrameSize,
MIN_CROP_PCT,
PREVIEW_MAX_HEIGHT_PX,
previewBoxStyle,
setCropFieldPx,
} from "./cropDraft";

describe("crop draft helpers", () => {
it("gives the preview box the video's own aspect ratio", () => {
Expand Down Expand Up @@ -28,3 +38,85 @@ describe("crop draft helpers", () => {
expect(draft.x).not.toBe(33);
});
});

describe("crop pixel fields", () => {
const frame = { width: 1920, height: 1080 };
const crop: CropPct = { x: 10, y: 20, w: 50, h: 40 };

it("converts the percent crop to whole source pixels", () => {
expect(cropPctToPx(crop, frame)).toEqual({ x: 192, y: 216, w: 960, h: 432 });
expect(cropPctToPx({ x: 0, y: 0, w: 100, h: 100 }, frame)).toEqual({
x: 0,
y: 0,
w: 1920,
h: 1080,
});
});

it("rounds fractional pixels to the nearest pixel", () => {
const px = cropPctToPx({ x: 33.333, y: 0, w: 33.333, h: 100 }, frame);
expect(px.x).toBe(640);
expect(px.w).toBe(640);
});

it("round-trips a typed pixel value through the stored percent", () => {
for (const [field, value] of [
["x", 123],
["y", 77],
["w", 801],
["h", 333],
] as const) {
const next = setCropFieldPx(crop, field, value, frame);
expect(cropPctToPx(next, frame)[field]).toBe(value);
}
});

it("changes only the field that was typed into", () => {
const next = setCropFieldPx(crop, "w", 1000, frame);
expect(next).toEqual({ ...crop, w: (1000 / 1920) * 100 });
});

it("keeps the crop inside the frame", () => {
// x can move up to the point where the right edge meets the frame.
expect(setCropFieldPx(crop, "x", 5000, frame).x).toBe(50);
expect(setCropFieldPx(crop, "x", -40, frame).x).toBe(0);
expect(setCropFieldPx(crop, "y", 5000, frame).y).toBe(60);
// Width and height can grow only to the frame edge from where the crop starts.
expect(setCropFieldPx(crop, "w", 5000, frame).w).toBe(90);
expect(setCropFieldPx(crop, "h", 5000, frame).h).toBe(80);
});

it("does not let a side shrink below the minimum", () => {
expect(setCropFieldPx(crop, "w", 0, frame).w).toBe(MIN_CROP_PCT);
expect(setCropFieldPx(crop, "h", -5, frame).h).toBe(MIN_CROP_PCT);
});

it("ignores a value that is not a number", () => {
expect(setCropFieldPx(crop, "x", Number.NaN, frame)).toBe(crop);
expect(setCropFieldPx(crop, "w", Number.POSITIVE_INFINITY, frame)).toBe(crop);
});

it("rounds a typed decimal to a whole pixel", () => {
const next = setCropFieldPx(crop, "x", 100.4, frame);
expect(cropPctToPx(next, frame).x).toBe(100);
});

it("reports the range each field accepts, matching what the setter allows", () => {
expect(cropFieldRangePx(crop, "x", frame)).toEqual({ min: 0, max: 960 });
expect(cropFieldRangePx(crop, "y", frame)).toEqual({ min: 0, max: 648 });
expect(cropFieldRangePx(crop, "w", frame)).toEqual({ min: 77, max: 1728 });
expect(cropFieldRangePx(crop, "h", frame)).toEqual({ min: 43, max: 864 });
for (const field of ["x", "y", "w", "h"] as const) {
const { min, max } = cropFieldRangePx(crop, field, frame);
expect(cropPctToPx(setCropFieldPx(crop, field, max, frame), frame)[field]).toBe(max);
expect(cropPctToPx(setCropFieldPx(crop, field, min, frame), frame)[field]).toBe(min);
}
});

it("only trusts a frame size with real dimensions", () => {
expect(isUsableFrameSize(frame)).toBe(true);
expect(isUsableFrameSize(null)).toBe(false);
expect(isUsableFrameSize({ width: 0, height: 1080 })).toBe(false);
expect(isUsableFrameSize({ width: 1920, height: Number.NaN })).toBe(false);
});
});
Loading
Loading