feat(clip): crop a clip to a frame drawn on the picture, and give the clip panel the room it needs
An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1; the whole picture stays the default). The frame is sent in fractions of the box it was drawn in, with that box's shape; the worker fits the real picture into the box by its display aspect (sample aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A frame covering the whole box is no crop at all, and one off the picture is refused by name. The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable. YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The crop frame: it sits on the picture, not on the box's bars; the arrows move it and, on a focused
|
||||
// corner, resize it, and the player underneath never sees those arrows; a drag moves it in picture
|
||||
// fractions; and the layer lets every press outside the frame through. jsdom lays nothing out, so the
|
||||
// box's size is stubbed.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
import { CropFrame } from "./CropFrame";
|
||||
import type { Frac } from "../../lib/cropGeometry";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeAll(() => {
|
||||
class PointerEventStub extends MouseEvent {
|
||||
pointerId: number;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 1;
|
||||
}
|
||||
}
|
||||
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
||||
// A 1600×900 box for every element: the frame's layer measures its own.
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 1600 });
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: 900 });
|
||||
});
|
||||
|
||||
const WIDE = 16 / 9;
|
||||
|
||||
function Harness({ aspect = WIDE, ratio = null }: { aspect?: number; ratio?: number | null }) {
|
||||
const [rect, setRect] = useState<Frac>({ x: 0.2, y: 0.2, w: 0.4, h: 0.4 });
|
||||
return (
|
||||
<div>
|
||||
<output data-testid="rect">
|
||||
{[rect.x, rect.y, rect.w, rect.h].map((v) => v.toFixed(3)).join(" ")}
|
||||
</output>
|
||||
<CropFrame aspect={aspect} rect={rect} ratio={ratio} onChange={setRect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rect = () => screen.getByTestId("rect").textContent;
|
||||
const picture = () => screen.getByRole("group", { name: "common.cropFrame.frame" }).parentElement!;
|
||||
|
||||
describe("CropFrame", () => {
|
||||
it("lies on the picture, between a 4:3 picture's bars in a 16:9 box", () => {
|
||||
render(<Harness aspect={4 / 3} />);
|
||||
const p = picture();
|
||||
expect([p.style.left, p.style.top, p.style.width, p.style.height]).toEqual([
|
||||
"200px",
|
||||
"0px",
|
||||
"1200px",
|
||||
"900px",
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves with the arrows, further with Shift, and keeps them from the player", () => {
|
||||
const player = vi.fn();
|
||||
render(
|
||||
<div onKeyDown={player}>
|
||||
<Harness />
|
||||
</div>,
|
||||
);
|
||||
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
||||
fireEvent.keyDown(frame, { key: "ArrowRight" });
|
||||
expect(rect()).toBe("0.210 0.200 0.400 0.400");
|
||||
fireEvent.keyDown(frame, { key: "ArrowUp", shiftKey: true });
|
||||
expect(rect()).toBe("0.210 0.100 0.400 0.400");
|
||||
expect(player).not.toHaveBeenCalled();
|
||||
// Not an arrow: the player's (Escape steps out of the crop there).
|
||||
fireEvent.keyDown(frame, { key: "Escape" });
|
||||
expect(player).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resizes from a focused corner, the opposite corner staying put", () => {
|
||||
render(<Harness />);
|
||||
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
|
||||
fireEvent.keyDown(se, { key: "ArrowRight", shiftKey: true });
|
||||
fireEvent.keyDown(se, { key: "ArrowDown" });
|
||||
expect(rect()).toBe("0.200 0.200 0.500 0.410");
|
||||
const nw = screen.getByRole("group", { name: "common.cropFrame.nw" });
|
||||
fireEvent.keyDown(nw, { key: "ArrowLeft" });
|
||||
expect(rect()).toBe("0.190 0.200 0.510 0.410");
|
||||
});
|
||||
|
||||
it("moves with a drag, in fractions of the picture", () => {
|
||||
render(<Harness />);
|
||||
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
||||
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 });
|
||||
fireEvent.pointerMove(frame, { clientX: 660, clientY: 390 });
|
||||
fireEvent.pointerUp(frame, { clientX: 660, clientY: 390 });
|
||||
// 160 px of 1600 and 90 px of 900: a tenth each way.
|
||||
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||
});
|
||||
|
||||
it("lets a press outside the frame through to the player, and keeps one on the frame", () => {
|
||||
const click = vi.fn();
|
||||
render(
|
||||
<div onClick={click}>
|
||||
<Harness />
|
||||
</div>,
|
||||
);
|
||||
expect(screen.getByTestId("crop-frame").className).toContain("pointer-events-none");
|
||||
fireEvent.click(screen.getByRole("group", { name: "common.cropFrame.frame" }));
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user