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:
@@ -31,7 +31,7 @@ vi.mock("../lib/actionErrors", async (importOriginal) => {
|
||||
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
|
||||
});
|
||||
|
||||
import ClipPanel, { type ClipSource } from "./ClipPanel";
|
||||
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -58,15 +58,20 @@ function Harness({
|
||||
onSeek?: (t: number, final: boolean) => void;
|
||||
}) {
|
||||
const [clip, setClip] = useState(start);
|
||||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
|
||||
<output data-testid="crop">{crop ? JSON.stringify(crop) : "none"}</output>
|
||||
<ClipPanel
|
||||
src={src}
|
||||
clip={clip}
|
||||
onChange={setClip}
|
||||
duration={3600}
|
||||
current={current}
|
||||
picture={16 / 9}
|
||||
crop={crop}
|
||||
onCrop={setCrop}
|
||||
onSeek={onSeek}
|
||||
onPlayFrom={onPlayFrom}
|
||||
onClose={onClose}
|
||||
@@ -156,4 +161,41 @@ describe("ClipPanel", () => {
|
||||
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
|
||||
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
|
||||
});
|
||||
|
||||
it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
|
||||
expect(enqueueClip.mock.calls[0]![0].crop).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
w: 0.8,
|
||||
h: 0.8,
|
||||
aspect: 1.7778,
|
||||
});
|
||||
});
|
||||
|
||||
it("holds the frame to a shape without cutting, and goes back to the whole picture", () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
// A segmented choice inside the panel's form: never a submit.
|
||||
fireEvent.click(screen.getByRole("button", { name: "9:16" }));
|
||||
expect(enqueueClip).not.toHaveBeenCalled();
|
||||
const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop;
|
||||
expect(tall.shape).toBe("9:16");
|
||||
expect(tall.rect.h).toBeCloseTo(1, 6);
|
||||
expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.whole" }));
|
||||
expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({
|
||||
rect: { x: 0, y: 0, w: 1, h: 1 },
|
||||
shape: "free",
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
expect(screen.getByTestId("crop").textContent).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Crosshair, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
||||
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
||||
import { api, type ClipRequest } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -10,7 +10,10 @@ import { navigateTo } from "../lib/nav";
|
||||
import { log } from "../lib/log";
|
||||
import { formatTimecode, parseTimecode } from "../lib/timecode";
|
||||
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
||||
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
|
||||
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
||||
import { CropFrame } from "./ui/CropFrame";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
@@ -27,6 +30,30 @@ import { Input } from "./ui/Input";
|
||||
//
|
||||
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
|
||||
// mark the same range by the same rules (lib/clipRange).
|
||||
//
|
||||
// A clip is the whole picture unless the user turns the crop on: then a frame over the video
|
||||
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
|
||||
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
|
||||
// before it closes the panel.
|
||||
|
||||
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
|
||||
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
|
||||
const SHAPES: Record<Exclude<ClipShape, "free">, number> = {
|
||||
"16:9": 16 / 9,
|
||||
"9:16": 9 / 16,
|
||||
"1:1": 1,
|
||||
};
|
||||
|
||||
/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */
|
||||
export interface ClipCrop {
|
||||
rect: Frac;
|
||||
shape: ClipShape;
|
||||
}
|
||||
|
||||
const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" };
|
||||
const round4 = (v: number) => Math.round(v * 10_000) / 10_000;
|
||||
const lockOf = (crop: ClipCrop, picture: number) =>
|
||||
crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture);
|
||||
|
||||
export interface ClipSource {
|
||||
source: ClipRequest["source"];
|
||||
@@ -42,6 +69,10 @@ interface Props {
|
||||
duration: number;
|
||||
/** The player's position, live. */
|
||||
current: number;
|
||||
/** The shape (width / height) of the picture the crop frame is drawn on. */
|
||||
picture: number;
|
||||
crop: ClipCrop | null;
|
||||
onCrop: (c: ClipCrop | null) => void;
|
||||
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
|
||||
onSeek: (time: number, final: boolean) => void;
|
||||
/** Play from `time` (the loop's restart, and "Play selection"). */
|
||||
@@ -60,6 +91,9 @@ export default function ClipPanel({
|
||||
onChange,
|
||||
duration,
|
||||
current,
|
||||
picture,
|
||||
crop,
|
||||
onCrop,
|
||||
onSeek,
|
||||
onPlayFrom,
|
||||
onClose,
|
||||
@@ -79,11 +113,25 @@ export default function ClipPanel({
|
||||
const length = clip.end - clip.start;
|
||||
|
||||
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
|
||||
// opened it is gone (its row is this panel now), and the arrows then move the mark at once.
|
||||
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
|
||||
// scrolling there: the player's card is still fitting itself around the new panel at this point,
|
||||
// and a card that has to scroll (a short window) scrolled the top of the video out of sight.
|
||||
useEffect(() => {
|
||||
rootRef.current?.querySelector<HTMLElement>('[data-handle="in"]')?.focus();
|
||||
rootRef.current
|
||||
?.querySelector<HTMLElement>('[data-handle="in"]')
|
||||
?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
// The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to
|
||||
// the Crop toggle rather than to <body>, where the player's dialog no longer holds it.
|
||||
const cropButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const cropOn = crop != null;
|
||||
useEffect(() => {
|
||||
if (cropOn) return;
|
||||
const lost = document.activeElement == null || document.activeElement === document.body;
|
||||
if (lost) cropButtonRef.current?.focus();
|
||||
}, [cropOn]);
|
||||
|
||||
// "Play selection" loops: past Out, back to In.
|
||||
const restart = useEffectEvent(() => onPlayFrom(clip.start));
|
||||
useEffect(() => {
|
||||
@@ -107,6 +155,17 @@ export default function ClipPanel({
|
||||
start_s: Math.round(clip.start * 1000) / 1000,
|
||||
end_s: Math.round(clip.end * 1000) / 1000,
|
||||
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
|
||||
...(crop
|
||||
? {
|
||||
crop: {
|
||||
x: round4(crop.rect.x),
|
||||
y: round4(crop.rect.y),
|
||||
w: round4(crop.rect.w),
|
||||
h: round4(crop.rect.h),
|
||||
aspect: round4(picture),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(name.trim() ? { display_name: name.trim() } : {}),
|
||||
};
|
||||
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
@@ -249,6 +308,46 @@ export default function ClipPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
ref={cropButtonRef}
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={crop != null}
|
||||
icon={<Crop />}
|
||||
onClick={() => onCrop(crop ? null : CROP_START)}
|
||||
>
|
||||
{t("clip.crop")}
|
||||
</Button>
|
||||
{crop && (
|
||||
<>
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("clip.shape")}
|
||||
value={crop.shape}
|
||||
onChange={(shape) => {
|
||||
const next = { ...crop, shape };
|
||||
const lock = lockOf(next, picture);
|
||||
onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) });
|
||||
}}
|
||||
options={[
|
||||
{ value: "free", label: t("clip.shapeFree") },
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
icon={<Maximize />}
|
||||
onClick={() => onCrop({ rect: WHOLE, shape: "free" })}
|
||||
>
|
||||
{t("clip.whole")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
size="sm"
|
||||
@@ -334,3 +433,24 @@ function MarkField({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */
|
||||
export function ClipCropFrame({
|
||||
picture,
|
||||
crop,
|
||||
onCrop,
|
||||
}: {
|
||||
picture: number;
|
||||
crop: ClipCrop;
|
||||
onCrop: (c: ClipCrop) => void;
|
||||
}) {
|
||||
return (
|
||||
<CropFrame
|
||||
aspect={picture}
|
||||
rect={crop.rect}
|
||||
ratio={lockOf(crop, picture)}
|
||||
onChange={(rect) => onCrop({ ...crop, rect })}
|
||||
className="z-base"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -691,6 +691,39 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||
});
|
||||
|
||||
it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy();
|
||||
// The clip panel sits under the video here too: the stacked card's floor, and its scroll past it
|
||||
// (without them the fit ran the card down to a sliver at 133% with the largest text).
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const cardEl = stage.parentElement!.parentElement!;
|
||||
expect(cardEl.parentElement!.style.maxWidth).toBe(
|
||||
"calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)",
|
||||
);
|
||||
expect(cardEl.className).toContain("overflow-y-auto");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
|
||||
expect(screen.getByTestId("player-info")).toBeTruthy();
|
||||
expect(cardEl.className).toContain("overflow-hidden");
|
||||
// Opened again by hand while clipping: it stays open, and closing the clip leaves it so.
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("player-strip")).getByRole("button", {
|
||||
name: "player.expandPanel",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
||||
@@ -827,8 +860,8 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||
});
|
||||
|
||||
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
|
||||
// not the player, the scissors gets the focus back, and a mark made in the big view steps back to
|
||||
// the card, where the panel lives.
|
||||
// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked
|
||||
// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes.
|
||||
describe("PlayerModal — clip mode", () => {
|
||||
const key = (k: string) =>
|
||||
act(
|
||||
@@ -865,16 +898,59 @@ describe("PlayerModal — clip mode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("marks in the big view by stepping back to the card", async () => {
|
||||
it("marks in the maximised view without leaving it: the panel takes the docked bar's place", async () => {
|
||||
clock.current = 10;
|
||||
open();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("f");
|
||||
expect(document.querySelector(".player-stage--max")).toBeTruthy();
|
||||
const stage = document.querySelector(".player-stage--max")!;
|
||||
expect(stage).toBeTruthy();
|
||||
key("]");
|
||||
expect(document.querySelector(".player-stage--max")).toBeNull();
|
||||
expect(document.querySelector(".player-stage--max")).toBe(stage);
|
||||
expect(stage.contains(screen.getByTestId("clip-panel"))).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: "player.exitMaximize" })).toBeNull();
|
||||
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
|
||||
"10",
|
||||
);
|
||||
// Escape: the panel first, then the maximised view, then (not here) the player.
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
expect(document.querySelector(".player-stage--max")).toBe(stage);
|
||||
expect(screen.getByRole("button", { name: "player.exitMaximize" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws a short's frame on its 9:16 picture and tells the cut so", async () => {
|
||||
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const S = { ...vid("S", 0), is_short: true } as Video;
|
||||
renderModal(
|
||||
<PlayerModal video={S} queue={[S]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("S"));
|
||||
key("c");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalled());
|
||||
expect(enqueue.mock.calls[0]![0].crop?.aspect).toBe(0.5625);
|
||||
});
|
||||
|
||||
it("lays the crop frame over the video, and Escape turns it off before closing the panel", async () => {
|
||||
// jsdom lays nothing out: the frame's layer measures a 16:9 box.
|
||||
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1600);
|
||||
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(900);
|
||||
open();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("c");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
const frame = screen.getByTestId("crop-frame");
|
||||
expect(document.querySelector(".player-stage")!.contains(frame)).toBe(true);
|
||||
act(() => screen.getByRole("group", { name: "common.cropFrame.frame" }).focus());
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("crop-frame")).toBeNull();
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
// The frame took the focus with it: it is on the Crop toggle, not on <body>.
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" }));
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
} from "../lib/layerStack";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import ClipPanel from "./ClipPanel";
|
||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
@@ -274,6 +274,10 @@ export default function PlayerModal({
|
||||
// queue steps on (a next video plays in this same modal), and a video opened from a card mounts a
|
||||
// fresh player, which starts with the panel open (owner).
|
||||
const [panelShut, setPanelShut] = useState(false);
|
||||
// Shut by clip mode rather than by the user (owner): the clip panel and the video take its width
|
||||
// while a clip is marked, and it opens again when the clip panel closes. A press on its own toggle
|
||||
// in between is the user's choice and holds.
|
||||
const [shutForClip, setShutForClip] = useState(false);
|
||||
// The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus
|
||||
// goes to its counterpart, not to <body>, where the player's keys would no longer reach it.
|
||||
const panelToggleRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -281,6 +285,7 @@ export default function PlayerModal({
|
||||
const togglePanel = (shut: boolean) => {
|
||||
refocusToggle.current = true;
|
||||
setPanelShut(shut);
|
||||
setShutForClip(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!refocusToggle.current) return;
|
||||
@@ -344,12 +349,37 @@ export default function PlayerModal({
|
||||
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
|
||||
// the video, which is its preview; another video starts without one.
|
||||
const [clip, setClip] = useState<ClipRange | null>(null);
|
||||
// The clip's crop frame over the picture, or null for the whole picture.
|
||||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||||
const [clipFor, setClipFor] = useState(currentVideoId);
|
||||
if (clipFor !== currentVideoId) {
|
||||
setClipFor(currentVideoId);
|
||||
setClip(null);
|
||||
setCrop(null);
|
||||
if (shutForClip) {
|
||||
setPanelShut(false);
|
||||
setShutForClip(false);
|
||||
}
|
||||
}
|
||||
const clipOpen = clip != null;
|
||||
// The picture the crop frame is drawn on. The iframe never tells its video's size: a short is
|
||||
// 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture
|
||||
// (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars.
|
||||
const picture = !navigated && active.is_short ? 9 / 16 : 16 / 9;
|
||||
// The docked clip panel's height, measured (it wraps with the window and the text size): the video
|
||||
// above it ends there.
|
||||
const clipDockRef = useRef<HTMLDivElement>(null);
|
||||
const [clipDockH, setClipDockH] = useState(0);
|
||||
const clipDocked = clipOpen && (maximized || isFullscreen);
|
||||
useLayoutEffect(() => {
|
||||
const el = clipDockRef.current;
|
||||
if (!clipDocked || !el) return;
|
||||
const measure = () => setClipDockH(el.offsetHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [clipDocked]);
|
||||
// The player's clock, read while the panel is open: the iframe reports no time of its own.
|
||||
const [clipClock, setClipClock] = useState({ now: 0, duration: 0 });
|
||||
useEffect(() => {
|
||||
@@ -372,6 +402,11 @@ export default function PlayerModal({
|
||||
const closeClip = () => {
|
||||
refocusClip.current = true;
|
||||
setClip(null);
|
||||
setCrop(null);
|
||||
if (shutForClip) {
|
||||
setPanelShut(false);
|
||||
setShutForClip(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (clipOpen || !refocusClip.current) return;
|
||||
@@ -379,14 +414,19 @@ export default function PlayerModal({
|
||||
clipButtonRef.current?.focus();
|
||||
}, [clipOpen]);
|
||||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
||||
* panel lives under the video, so a big view steps back to the card first. */
|
||||
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
||||
* browser's fullscreen steps back to the maximised view, which has room for it. */
|
||||
const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
const t = p.getCurrentTime() || 0;
|
||||
const d = p.getDuration() || 0;
|
||||
if (d <= 0) return;
|
||||
if (maximized || isFullscreen) exitBigView();
|
||||
if (isFullscreen) toggleTrueFullscreen();
|
||||
if (!clip && wide && !panelShut) {
|
||||
setPanelShut(true);
|
||||
setShutForClip(true);
|
||||
}
|
||||
setClipClock({ now: t, duration: d });
|
||||
setClip((r) => mark(r, t, d));
|
||||
};
|
||||
@@ -669,10 +709,12 @@ export default function PlayerModal({
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// The clip panel is the player's own layer: it closes before the player does.
|
||||
// The clip panel is the player's own layer: it closes before the player does, and a crop
|
||||
// turns off before the panel closes.
|
||||
if (clip) {
|
||||
ev.preventDefault();
|
||||
closeClip();
|
||||
if (crop) setCrop(null);
|
||||
else closeClip();
|
||||
return;
|
||||
}
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
@@ -751,6 +793,29 @@ export default function PlayerModal({
|
||||
onLoadVideo: loadVideo,
|
||||
};
|
||||
|
||||
// The clip panel: under the video in the card, in the docked bar's place in the big views.
|
||||
const clipPanel = clip && (
|
||||
<ClipPanel
|
||||
src={{ source: "youtube", ref: currentVideoId }}
|
||||
clip={clip}
|
||||
onChange={setClip}
|
||||
duration={clipClock.duration}
|
||||
current={clipClock.now}
|
||||
picture={picture}
|
||||
crop={crop}
|
||||
onCrop={setCrop}
|
||||
onSeek={(time, final) => {
|
||||
// A seek past what is buffered fetches from there: only once the drag settles.
|
||||
playerRef.current?.seekTo?.(time, final);
|
||||
setClipClock((c) => ({ ...c, now: time }));
|
||||
}}
|
||||
onPlayFrom={(time) => {
|
||||
seekTo(time);
|
||||
setClipClock((c) => ({ ...c, now: time }));
|
||||
}}
|
||||
onClose={closeClip}
|
||||
/>
|
||||
);
|
||||
// The card's width budget (its maxWidth, below): the video's, and the side panel's when wide.
|
||||
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
|
||||
const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL;
|
||||
@@ -792,23 +857,26 @@ export default function PlayerModal({
|
||||
// window's height less the backdrop's padding (`p-6` = 3rem).
|
||||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
|
||||
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
|
||||
// does not scroll there, always holds the whole video.
|
||||
// does not scroll there, always holds the whole video. Clip mode is the exception: its panel
|
||||
// sits under the video, so the wide card takes the stacked one's floor and scroll too.
|
||||
// Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows
|
||||
// (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px
|
||||
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
|
||||
// (a laptop at 100%, a 1080p screen at 175%; the wide clip panel did the same at 133% with the
|
||||
// largest text). Past the floor the card scrolls instead.
|
||||
style={{
|
||||
maxWidth: wide
|
||||
? `calc(${fitWidth} + ${panelWidth})`
|
||||
? `calc(${clipOpen ? `max(${MIN_VIDEO}, ${fitWidth})` : fitWidth} + ${panelWidth})`
|
||||
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
// Stacked, the card is what scrolls (its details under the video): the wheel's, while it can.
|
||||
data-wheel-scrolls={wide ? undefined : ""}
|
||||
// Stacked (and wide in clip mode), the card is what scrolls (what sits under the video): the
|
||||
// wheel's, while it can.
|
||||
data-wheel-scrolls={wide && !clipOpen ? undefined : ""}
|
||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||||
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
||||
wide ? `flex ${clipOpen ? "overflow-y-auto" : "overflow-hidden"}` : "overflow-y-auto"
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -832,9 +900,11 @@ export default function PlayerModal({
|
||||
<div
|
||||
className={
|
||||
maximized || isFullscreen
|
||||
? "absolute inset-x-0 top-0 bottom-14"
|
||||
? `absolute inset-x-0 top-0 ${clipDocked ? "" : "bottom-14"}`
|
||||
: "relative w-full h-full"
|
||||
}
|
||||
// Docked, the clip panel takes the bar's place: the video stands above its height.
|
||||
style={clipDocked ? { bottom: clipDockH } : undefined}
|
||||
>
|
||||
<div
|
||||
ref={mountRef}
|
||||
@@ -868,6 +938,11 @@ export default function PlayerModal({
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{/* The clip's crop frame, over the picture (this box is the iframe's). Pointers pass
|
||||
it outside the frame, so a click elsewhere on the video is still play / pause. */}
|
||||
{crop && playerError == null && (
|
||||
<ClipCropFrame picture={picture} crop={crop} onCrop={setCrop} />
|
||||
)}
|
||||
</div>
|
||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
||||
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
||||
@@ -879,7 +954,12 @@ export default function PlayerModal({
|
||||
the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen:
|
||||
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
|
||||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||
{(maximized || isFullscreen) && (
|
||||
{clipDocked && (
|
||||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu">
|
||||
{clipPanel}
|
||||
</div>
|
||||
)}
|
||||
{(maximized || isFullscreen) && !clipDocked && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
|
||||
@@ -907,7 +987,7 @@ export default function PlayerModal({
|
||||
{!navigated && (
|
||||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
||||
)}
|
||||
{/* The clip panel lives under the video in the card: this steps out of the big view. */}
|
||||
{/* Clip mode here: the clip panel takes this bar's place. */}
|
||||
{playerError == null && (
|
||||
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
|
||||
<Scissors />
|
||||
@@ -1006,25 +1086,7 @@ export default function PlayerModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clip && !maximized && !isFullscreen && (
|
||||
<ClipPanel
|
||||
src={{ source: "youtube", ref: currentVideoId }}
|
||||
clip={clip}
|
||||
onChange={setClip}
|
||||
duration={clipClock.duration}
|
||||
current={clipClock.now}
|
||||
onSeek={(time, final) => {
|
||||
// A seek past what is buffered fetches from there: only once the drag settles.
|
||||
playerRef.current?.seekTo?.(time, final);
|
||||
setClipClock((c) => ({ ...c, now: time }));
|
||||
}}
|
||||
onPlayFrom={(time) => {
|
||||
seekTo(time);
|
||||
setClipClock((c) => ({ ...c, now: time }));
|
||||
}}
|
||||
onClose={closeClip}
|
||||
/>
|
||||
)}
|
||||
{!clipDocked && clipPanel}
|
||||
</div>
|
||||
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
||||
description never makes the card taller than the video. */}
|
||||
|
||||
@@ -983,4 +983,44 @@ describe("PlexPlayer — clip mode", () => {
|
||||
audio_track: 0,
|
||||
});
|
||||
});
|
||||
it("crops on the picture's own shape, and Escape turns the crop off before the panel", async () => {
|
||||
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
|
||||
// A 720×480 stream: the frame is drawn on a 3:2 picture, and the cut is told so.
|
||||
const proto = HTMLVideoElement.prototype;
|
||||
const own = ["videoWidth", "videoHeight"].map(
|
||||
(k) => [k, Object.getOwnPropertyDescriptor(proto, k)] as const,
|
||||
);
|
||||
Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => 720 });
|
||||
Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => 480 });
|
||||
try {
|
||||
mount({ details: { a: item("a") } });
|
||||
await playingAt("/media/a", 60);
|
||||
key("c");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
// The frame's layer is the video's box: both end above the panel, both at the player's top
|
||||
// (its flex box centred the shortened video, and the frame sat 155 px above the picture).
|
||||
const video = document.querySelector("video")!;
|
||||
const layer = screen.getByTestId("crop-frame").parentElement!;
|
||||
expect(video.style.alignSelf).toBe("flex-start");
|
||||
expect(layer.className).toContain("top-0");
|
||||
expect(layer.style.height).toBe(video.style.height);
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("crop-frame")).toBeNull();
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalled());
|
||||
expect(enqueue.mock.calls[0]![0].crop).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
w: 0.8,
|
||||
h: 0.8,
|
||||
aspect: 1.5,
|
||||
});
|
||||
} finally {
|
||||
for (const [k, d] of own)
|
||||
if (d) Object.defineProperty(proto, k, d);
|
||||
else delete (proto as unknown as Record<string, unknown>)[k];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
type PlexPlayerPrefs,
|
||||
} from "../lib/plexPlayerPrefs";
|
||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||
import ClipPanel from "./ClipPanel";
|
||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
@@ -294,19 +294,29 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Clip mode: the range being marked, or null. Its panel takes the control band's place, and a
|
||||
// next item starts without one (reset against the item it was opened on).
|
||||
const [clip, setClip] = useState<ClipRange | null>(null);
|
||||
// The clip's crop frame over the picture, or null for the whole picture.
|
||||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||||
const [clipFor, setClipFor] = useState(id);
|
||||
if (clipFor !== id) {
|
||||
setClipFor(id);
|
||||
setClip(null);
|
||||
setCrop(null);
|
||||
}
|
||||
const clipRef = useLatest(clip);
|
||||
const cropRef = useLatest(crop);
|
||||
// The scissors that opened the panel is gone while it is open; closing it gives the focus back.
|
||||
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const refocusClip = useRef(false);
|
||||
const closeClip = useCallback(() => {
|
||||
refocusClip.current = true;
|
||||
setClip(null);
|
||||
setCrop(null);
|
||||
}, []);
|
||||
// Escape and Back inside clip mode: a crop turns off first, then the panel closes.
|
||||
const stepOutOfClip = useCallback(() => {
|
||||
if (cropRef.current) setCrop(null);
|
||||
else closeClip();
|
||||
}, [cropRef, closeClip]);
|
||||
useEffect(() => {
|
||||
if (clip != null || !refocusClip.current) return;
|
||||
refocusClip.current = false;
|
||||
@@ -626,6 +636,36 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
|
||||
// begins at the resume point), and a seek outside the stream restarts it.
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
// The picture's shape, for the clip's crop frame: the element's own once it knows it (a stream
|
||||
// switch can change it), 16:9 until then.
|
||||
const [picture, setPicture] = useState(16 / 9);
|
||||
useEffect(() => {
|
||||
if (!videoEl) return;
|
||||
const read = () => {
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0)
|
||||
setPicture(videoEl.videoWidth / videoEl.videoHeight);
|
||||
};
|
||||
read();
|
||||
videoEl.addEventListener("loadedmetadata", read);
|
||||
videoEl.addEventListener("resize", read);
|
||||
return () => {
|
||||
videoEl.removeEventListener("loadedmetadata", read);
|
||||
videoEl.removeEventListener("resize", read);
|
||||
};
|
||||
}, [videoEl]);
|
||||
// In clip mode the picture stands above the clip panel rather than under it, so the crop frame
|
||||
// reaches every edge of it: the panel's height, measured (it wraps with the window and the text).
|
||||
const [clipBand, setClipBand] = useState(0);
|
||||
const clipOpen = clip != null;
|
||||
useLayoutEffect(() => {
|
||||
const bar = bottomBarRef.current;
|
||||
if (!clipOpen || !bar) return;
|
||||
const measure = () => setClipBand(bar.offsetHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(bar);
|
||||
return () => ro.disconnect();
|
||||
}, [clipOpen]);
|
||||
useMediaSession(
|
||||
videoEl,
|
||||
detail && {
|
||||
@@ -1064,7 +1104,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
return;
|
||||
ev.preventDefault();
|
||||
// The clip panel is the player's own too, and closes before the player does.
|
||||
if (clipRef.current) closeClip();
|
||||
if (clipRef.current) stepOutOfClip();
|
||||
else back("key");
|
||||
},
|
||||
escape: (ev) => {
|
||||
@@ -1075,7 +1115,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
if (clipRef.current) closeClip();
|
||||
if (clipRef.current) stepOutOfClip();
|
||||
else back("escape");
|
||||
},
|
||||
});
|
||||
@@ -1109,6 +1149,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
statsOpenRef,
|
||||
clipRef,
|
||||
closeClip,
|
||||
stepOutOfClip,
|
||||
absRef,
|
||||
durationRef,
|
||||
]);
|
||||
@@ -1191,6 +1232,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||||
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
|
||||
className="w-full h-full object-contain touch-manipulation"
|
||||
// In clip mode the picture stands above the clip panel: shorter, and at the top of the player,
|
||||
// whose flex box would otherwise centre it and leave its bottom under the panel.
|
||||
style={
|
||||
clipOpen ? { height: `calc(100% - ${clipBand}px)`, alignSelf: "flex-start" } : undefined
|
||||
}
|
||||
onClick={onPictureClick}
|
||||
playsInline
|
||||
>
|
||||
@@ -1211,6 +1257,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
{crop && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0"
|
||||
style={{ height: `calc(100% - ${clipBand}px)` }}
|
||||
>
|
||||
<ClipCropFrame picture={picture} crop={crop} onCrop={setCrop} />
|
||||
</div>
|
||||
)}
|
||||
<PlexSubtitles
|
||||
video={videoEl}
|
||||
pos={prefs.subPos}
|
||||
@@ -1403,6 +1457,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
onChange={setClip}
|
||||
duration={duration}
|
||||
current={abs}
|
||||
picture={picture}
|
||||
crop={crop}
|
||||
onCrop={setCrop}
|
||||
onSeek={(time, final) => {
|
||||
if (final) seekTo(time);
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,8 @@ import clsx from "clsx";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { CropFrame } from "./ui/CropFrame";
|
||||
import { Button } from "./ui/Button";
|
||||
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
||||
import { markReported } from "../lib/api/core";
|
||||
@@ -48,11 +49,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
||||
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
||||
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
||||
// Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels.
|
||||
// From the asset's metadata, so the frame sizes before the media decodes.
|
||||
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
@@ -93,21 +92,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
||||
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
||||
ro.observe(v);
|
||||
setBox({ w: v.clientWidth, h: v.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
||||
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
||||
// The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's
|
||||
// box itself: max-h can pillarbox it).
|
||||
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||
const picRect = contentRect(box.w, box.h, va);
|
||||
|
||||
const onLoaded = () => {
|
||||
const v = videoRef.current;
|
||||
@@ -187,47 +174,6 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
|
||||
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
|
||||
|
||||
// --- crop drag ---
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(
|
||||
null,
|
||||
);
|
||||
const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } };
|
||||
};
|
||||
useEffect(() => {
|
||||
const move = (e: PointerEvent) => {
|
||||
const d = cropDrag.current;
|
||||
const el = overlayRef.current;
|
||||
if (!d || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const dx = (e.clientX - d.px) / r.width;
|
||||
const dy = (e.clientY - d.py) / r.height;
|
||||
if (d.mode === "move")
|
||||
setCrop({
|
||||
...d.orig,
|
||||
x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)),
|
||||
y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)),
|
||||
});
|
||||
else
|
||||
setCrop({
|
||||
...d.orig,
|
||||
w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)),
|
||||
h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)),
|
||||
});
|
||||
};
|
||||
const up = () => (cropDrag.current = null);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
|
||||
const tileStyle = (idx: number): React.CSSProperties => {
|
||||
const cols = strip?.cols ?? 12;
|
||||
@@ -370,35 +316,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
className="w-full max-h-[42dvh] mx-auto block object-contain"
|
||||
playsInline
|
||||
/>
|
||||
{cropOn && picRect && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: picRect.left,
|
||||
top: picRect.top,
|
||||
width: picRect.width,
|
||||
height: picRect.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
left: `${crop.x * 100}%`,
|
||||
top: `${crop.y * 100}%`,
|
||||
width: `${crop.w * 100}%`,
|
||||
height: `${crop.h * 100}%`,
|
||||
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
onPointerDown={(e) => onCropDown(e, "move")}
|
||||
>
|
||||
<div
|
||||
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-xs bg-accent cursor-nwse-resize"
|
||||
onPointerDown={(e) => onCropDown(e, "resize")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cropOn && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
|
||||
</div>
|
||||
|
||||
{/* transport */}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
contentRect,
|
||||
moveCrop,
|
||||
resizeCrop,
|
||||
type CropHandle,
|
||||
type Frac,
|
||||
} from "../../lib/cropGeometry";
|
||||
|
||||
// A crop frame over a picture: drawn on the picture itself, not on the box it is shown in (a
|
||||
// letterboxed video leaves bars there), in fractions of it. The frame is dragged to move, its edges
|
||||
// and corners to resize; from the keyboard the frame moves with the arrows and each corner, focused,
|
||||
// resizes with them (Shift for a bigger step). Outside the frame the picture is dimmed. The frame and
|
||||
// its corners are groups, not buttons: nothing happens on Enter, and the name says what the arrows do.
|
||||
//
|
||||
// It fills its positioned parent and measures it, so the caller only places it over the picture's
|
||||
// box (a <video>'s, an iframe's) and says the picture's shape. The layer itself lets pointers
|
||||
// through: only the frame and its handles take them, so a press elsewhere on the picture is still
|
||||
// the player's (play / pause).
|
||||
|
||||
type Corner = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
interface Props {
|
||||
/** The picture's shape as shown (width / height). */
|
||||
aspect: number;
|
||||
rect: Frac;
|
||||
/** A shape lock in picture fractions (cropGeometry.fracRatio), or null for a free frame. */
|
||||
ratio: number | null;
|
||||
onChange: (r: Frac) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP = 0.01;
|
||||
const BIG_STEP = 0.1;
|
||||
const ARROWS: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
};
|
||||
const CORNERS: Corner[] = ["nw", "ne", "sw", "se"];
|
||||
const EDGES: CropHandle[] = ["n", "s", "e", "w"];
|
||||
// Each handle's hit area, centred on its edge or corner (the frame's own border is 2 px).
|
||||
const HANDLE_BOX: Record<CropHandle, string> = {
|
||||
nw: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize",
|
||||
ne: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize",
|
||||
sw: "-left-2.5 -bottom-2.5 size-5 cursor-nesw-resize",
|
||||
se: "-right-2.5 -bottom-2.5 size-5 cursor-nwse-resize",
|
||||
n: "inset-x-3 -top-2 h-4 cursor-ns-resize",
|
||||
s: "inset-x-3 -bottom-2 h-4 cursor-ns-resize",
|
||||
e: "inset-y-3 -right-2 w-4 cursor-ew-resize",
|
||||
w: "inset-y-3 -left-2 w-4 cursor-ew-resize",
|
||||
};
|
||||
|
||||
type Drag = { handle: CropHandle | "move"; x: number; y: number; from: Frac };
|
||||
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
|
||||
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
|
||||
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
|
||||
|
||||
export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight });
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const pic = contentRect(box.w, box.h, aspect);
|
||||
|
||||
const begin = (handle: Drag["handle"]) => (e: PointerEvent<HTMLElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// No capture (a synthetic event): the drag still follows while the pointer stays on it.
|
||||
}
|
||||
setDrag({ handle, x: e.clientX, y: e.clientY, from: rect });
|
||||
};
|
||||
const follow = (e: PointerEvent<HTMLElement>) => {
|
||||
if (!drag || !pic) return;
|
||||
const dx = (e.clientX - drag.x) / pic.width;
|
||||
const dy = (e.clientY - drag.y) / pic.height;
|
||||
onChange(
|
||||
drag.handle === "move"
|
||||
? moveCrop(drag.from, dx, dy)
|
||||
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
|
||||
);
|
||||
};
|
||||
const end = () => setDrag(null);
|
||||
const pointer = (handle: Drag["handle"]) => ({
|
||||
onPointerDown: begin(handle),
|
||||
onPointerMove: follow,
|
||||
onPointerUp: end,
|
||||
onPointerCancel: end,
|
||||
// A click after the drag is the frame's too.
|
||||
onClick: (e: MouseEvent<HTMLElement>) => e.stopPropagation(),
|
||||
});
|
||||
|
||||
const keys = (handle: CropHandle | "move") => (e: KeyboardEvent<HTMLElement>) => {
|
||||
const dir = ARROWS[e.key];
|
||||
if (!dir || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
// Taken here, so the player underneath does not also seek or change the volume on the arrow.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const step = e.shiftKey ? BIG_STEP : STEP;
|
||||
const [dx, dy] = [dir[0] * step, dir[1] * step];
|
||||
onChange(handle === "move" ? moveCrop(rect, dx, dy) : resizeCrop(rect, handle, dx, dy, ratio));
|
||||
};
|
||||
|
||||
const pct = (v: number) => `${(v * 100).toFixed(3)}%`;
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
data-testid="crop-frame"
|
||||
className={clsx("pointer-events-none absolute inset-0 overflow-hidden", className)}
|
||||
>
|
||||
{pic && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: pic.left, top: pic.top, width: pic.width, height: pic.height }}
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={t("common.cropFrame.frame")}
|
||||
data-crop="frame"
|
||||
className={clsx(
|
||||
"pointer-events-auto absolute cursor-move touch-none border-2 border-white",
|
||||
FOCUS,
|
||||
)}
|
||||
style={{
|
||||
left: pct(rect.x),
|
||||
top: pct(rect.y),
|
||||
width: pct(rect.w),
|
||||
height: pct(rect.h),
|
||||
// The dim outside the frame, as far as the picture goes (the layer clips it).
|
||||
boxShadow: "0 0 0 100vmax rgb(0 0 0 / 0.55)",
|
||||
}}
|
||||
onKeyDown={keys("move")}
|
||||
{...pointer("move")}
|
||||
>
|
||||
{drag &&
|
||||
[1, 2].map((i) => (
|
||||
<div key={i} aria-hidden="true">
|
||||
<div
|
||||
className="absolute inset-y-0 w-px bg-white/40"
|
||||
style={{ left: `${(i * 100) / 3}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 h-px bg-white/40"
|
||||
style={{ top: `${(i * 100) / 3}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{EDGES.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
aria-hidden="true"
|
||||
className={clsx("absolute touch-none", HANDLE_BOX[h])}
|
||||
{...pointer(h)}
|
||||
/>
|
||||
))}
|
||||
{CORNERS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={t(`common.cropFrame.${h}`)}
|
||||
data-crop={h}
|
||||
className={clsx(
|
||||
"absolute flex touch-none items-center justify-center rounded-xs",
|
||||
HANDLE_BOX[h],
|
||||
FOCUS,
|
||||
)}
|
||||
onKeyDown={keys(h)}
|
||||
{...pointer(h)}
|
||||
>
|
||||
<span className="size-2.5 rounded-xs bg-white shadow" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
| area | components |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(opaqueWhats).toEqual([]);
|
||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
||||
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end"]));
|
||||
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
||||
expect(crewJobs).toEqual(
|
||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||
);
|
||||
|
||||
@@ -18,12 +18,17 @@
|
||||
"queued": "The clip is being made — it will be in your downloads",
|
||||
"view": "Downloads",
|
||||
"failed": "Couldn’t make the clip",
|
||||
"crop": "Crop",
|
||||
"shape": "Frame shape",
|
||||
"shapeFree": "Free",
|
||||
"whole": "Whole picture",
|
||||
"errors": {
|
||||
"bad_source": "This video can’t be clipped.",
|
||||
"bad_range": "The clip’s start or end isn’t valid.",
|
||||
"too_short": "A clip is at least a second long.",
|
||||
"too_long": "A clip is at most 10 minutes long.",
|
||||
"bad_audio": "That audio track isn’t available.",
|
||||
"bad_crop": "The crop frame isn’t valid. Make it bigger, or go back to the whole picture.",
|
||||
"not_found": "This item is no longer in the library.",
|
||||
"media_missing": "The item’s file can’t be reached from here.",
|
||||
"past_end": "The clip runs past the end of the video."
|
||||
|
||||
@@ -69,5 +69,12 @@
|
||||
"done": "Copied to the clipboard",
|
||||
"linkDone": "Link copied to the clipboard",
|
||||
"failed": "Couldn't copy to the clipboard"
|
||||
},
|
||||
"cropFrame": {
|
||||
"frame": "Crop frame — the arrow keys move it, with Shift further",
|
||||
"nw": "Top-left corner of the crop frame — the arrow keys resize it",
|
||||
"ne": "Top-right corner of the crop frame — the arrow keys resize it",
|
||||
"sw": "Bottom-left corner of the crop frame — the arrow keys resize it",
|
||||
"se": "Bottom-right corner of the crop frame — the arrow keys resize it"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,17 @@
|
||||
"queued": "Készül a klip — a letöltéseid közé kerül",
|
||||
"view": "Letöltések",
|
||||
"failed": "Nem sikerült elkészíteni a klipet",
|
||||
"crop": "Kivágás",
|
||||
"shape": "Keret aránya",
|
||||
"shapeFree": "Szabad",
|
||||
"whole": "Teljes kép",
|
||||
"errors": {
|
||||
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
|
||||
"bad_range": "A klip eleje vagy vége érvénytelen.",
|
||||
"too_short": "A klip legalább egy másodperc.",
|
||||
"too_long": "A klip legfeljebb 10 perc lehet.",
|
||||
"bad_audio": "Ez a hangsáv nem érhető el.",
|
||||
"bad_crop": "A kivágókeret érvénytelen. Nagyítsd meg, vagy térj vissza a teljes képre.",
|
||||
"not_found": "Ez az elem már nincs a könyvtárban.",
|
||||
"media_missing": "Az elem fájlja innen nem érhető el.",
|
||||
"past_end": "A klip túlnyúlik a videó végén."
|
||||
|
||||
@@ -69,5 +69,12 @@
|
||||
"done": "Vágólapra másolva",
|
||||
"linkDone": "Link a vágólapra másolva",
|
||||
"failed": "Nem sikerült a vágólapra másolni"
|
||||
},
|
||||
"cropFrame": {
|
||||
"frame": "Kivágókeret — a nyilakkal mozgatható, Shifttel nagyobbat lép",
|
||||
"nw": "A kivágókeret bal felső sarka — a nyilakkal méretezhető",
|
||||
"ne": "A kivágókeret jobb felső sarka — a nyilakkal méretezhető",
|
||||
"sw": "A kivágókeret bal alsó sarka — a nyilakkal méretezhető",
|
||||
"se": "A kivágókeret jobb alsó sarka — a nyilakkal méretezhető"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
--bg: var(--media-chrome);
|
||||
--border: rgb(255 255 255 / 0.16);
|
||||
--edge-strong: rgb(255 255 255 / 0.4);
|
||||
/* A card on the chrome (a segmented control's track): a veil of it, never the theme's card — a
|
||||
light theme's white under this scope's white text left the unchosen options unreadable. */
|
||||
--card: rgb(255 255 255 / 0.08);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface ClipRequest {
|
||||
start_s: number;
|
||||
end_s: number;
|
||||
audio_track?: number;
|
||||
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */
|
||||
crop?: { x: number; y: number; w: number; h: number; aspect: number };
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contentRect, cropToSourcePx } from "./cropGeometry";
|
||||
import {
|
||||
contentRect,
|
||||
cropToSourcePx,
|
||||
fitRatio,
|
||||
fracRatio,
|
||||
MIN_CROP,
|
||||
moveCrop,
|
||||
resizeCrop,
|
||||
type Frac,
|
||||
} from "./cropGeometry";
|
||||
|
||||
describe("contentRect (display-px letterbox mapping)", () => {
|
||||
it("returns the full box when box aspect matches the video aspect", () => {
|
||||
@@ -86,3 +95,66 @@ describe("cropToSourcePx (fraction-of-content → even source px)", () => {
|
||||
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1920, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const near = (r: Frac, want: Frac) => {
|
||||
for (const k of ["x", "y", "w", "h"] as const) expect(r[k], k).toBeCloseTo(want[k], 6);
|
||||
};
|
||||
// The pixel shape of a frame on a 16:9 picture.
|
||||
const shape = (r: Frac) => (r.w * 16) / 9 / r.h;
|
||||
const inside = (r: Frac) => {
|
||||
expect(r.x).toBeGreaterThanOrEqual(-1e-9);
|
||||
expect(r.y).toBeGreaterThanOrEqual(-1e-9);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(1 + 1e-9);
|
||||
expect(r.y + r.h).toBeLessThanOrEqual(1 + 1e-9);
|
||||
};
|
||||
|
||||
describe("the crop frame a user draws", () => {
|
||||
const r: Frac = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 };
|
||||
|
||||
it("moves whole, stopping at the picture's edge", () => {
|
||||
near(moveCrop(r, 0.1, -0.05), { x: 0.3, y: 0.15, w: 0.4, h: 0.4 });
|
||||
near(moveCrop(r, 0.9, -0.9), { x: 0.6, y: 0, w: 0.4, h: 0.4 });
|
||||
});
|
||||
|
||||
it("resizes free from an edge or a corner, the opposite side staying put", () => {
|
||||
near(resizeCrop(r, "e", 0.1, 0.3, null), { x: 0.2, y: 0.2, w: 0.5, h: 0.4 });
|
||||
near(resizeCrop(r, "nw", -0.1, -0.05, null), { x: 0.1, y: 0.15, w: 0.5, h: 0.45 });
|
||||
// past the picture, and past the opposite edge: stopped at the edge and at the floor
|
||||
near(resizeCrop(r, "se", 0.9, 0.9, null), { x: 0.2, y: 0.2, w: 0.8, h: 0.8 });
|
||||
near(resizeCrop(r, "w", 0.9, 0, null), { x: 0.6 - MIN_CROP, y: 0.2, w: MIN_CROP, h: 0.4 });
|
||||
});
|
||||
|
||||
it("keeps a locked shape from a corner, following the side the pointer changed more", () => {
|
||||
const ratio = fracRatio(9 / 16, 16 / 9);
|
||||
const start = fitRatio(r, ratio);
|
||||
const grown = resizeCrop(start, "se", 0, -0.2, ratio);
|
||||
expect(shape(grown)).toBeCloseTo(9 / 16, 6);
|
||||
inside(grown);
|
||||
// a mostly sideways drag inward shrinks it (a "larger of the two" rule never would)
|
||||
const narrowed = resizeCrop({ x: 0.3, y: 0.1, w: 0.2, h: 0.6 }, "ne", -0.08, 0.01, 0.75);
|
||||
expect(narrowed.w).toBeCloseTo(0.12, 6);
|
||||
expect(narrowed.h).toBeCloseTo(0.16, 6);
|
||||
expect(narrowed.y + narrowed.h).toBeCloseTo(0.7, 6); // the anchored corner (sw) stays
|
||||
});
|
||||
|
||||
it("keeps a locked shape from an edge, growing the other side about the middle and in bounds", () => {
|
||||
const ratio = fracRatio(1, 16 / 9);
|
||||
const sq = { x: 0.4, y: 0.4, w: 0.2 * ratio, h: 0.2 };
|
||||
const out = resizeCrop(sq, "e", 0.5, 0, ratio);
|
||||
expect(shape(out)).toBeCloseTo(1, 6);
|
||||
expect(out.y + out.h / 2).toBeCloseTo(0.5, 6);
|
||||
inside(out);
|
||||
const tall = resizeCrop(sq, "s", 0, 0.9, ratio);
|
||||
expect(shape(tall)).toBeCloseTo(1, 6);
|
||||
inside(tall);
|
||||
});
|
||||
|
||||
it("fits a chosen shape as large as the picture allows, centred on the frame", () => {
|
||||
const tall = fitRatio({ x: 0.7, y: 0.1, w: 0.2, h: 0.2 }, fracRatio(9 / 16, 16 / 9));
|
||||
expect(tall.h).toBeCloseTo(1, 6);
|
||||
expect(shape(tall)).toBeCloseTo(9 / 16, 6);
|
||||
expect(tall.x + tall.w / 2).toBeCloseTo(0.8, 6);
|
||||
const wide = fitRatio({ x: 0, y: 0, w: 1, h: 1 }, fracRatio(21 / 9, 16 / 9));
|
||||
near(wide, { x: 0, y: (1 - (16 / 9) * (9 / 21)) / 2, w: 1, h: (16 / 9) * (9 / 21) });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,3 +46,87 @@ export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx |
|
||||
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
// --- the frame a user draws: moved, resized, fitted to a shape ---------------------------------------
|
||||
//
|
||||
// All in fractions of the picture (0..1 each way), so a frame means the same thing whatever size the
|
||||
// picture is shown at. A shape lock is the frame's width / height IN THOSE FRACTIONS: a 9:16 output
|
||||
// on a 16:9 picture is (9/16) / (16/9) — `fracRatio` turns one into the other.
|
||||
|
||||
/** The smallest a frame gets on either side (the server holds the same floor, downloads/clip). */
|
||||
export const MIN_CROP = 0.1;
|
||||
export const WHOLE: Frac = { x: 0, y: 0, w: 1, h: 1 };
|
||||
|
||||
export type CropHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/** An output shape (width / height in pixels) as a ratio of picture fractions. */
|
||||
export function fracRatio(shape: number, pictureAspect: number): number {
|
||||
return shape / pictureAspect;
|
||||
}
|
||||
|
||||
/** The frame moved by (dx, dy), kept whole inside the picture. */
|
||||
export function moveCrop(r: Frac, dx: number, dy: number): Frac {
|
||||
return { ...r, x: clamp(r.x + dx, 0, 1 - r.w), y: clamp(r.y + dy, 0, 1 - r.h) };
|
||||
}
|
||||
|
||||
/** The frame with one edge or corner moved by (dx, dy); the opposite one stays put. Under a lock
|
||||
* (`ratio`, see fracRatio) a corner follows whichever side the pointer changed more, and an edge
|
||||
* grows the other side about the frame's middle. Never under MIN_CROP, never out of the picture. */
|
||||
export function resizeCrop(
|
||||
r: Frac,
|
||||
handle: CropHandle,
|
||||
dx: number,
|
||||
dy: number,
|
||||
ratio: number | null,
|
||||
): Frac {
|
||||
const sx = handle.includes("e") ? 1 : handle.includes("w") ? -1 : 0;
|
||||
const sy = handle.includes("s") ? 1 : handle.includes("n") ? -1 : 0;
|
||||
const right = r.x + r.w;
|
||||
const bottom = r.y + r.h;
|
||||
// The free move first: each moved edge, stopped at the picture and at the floor.
|
||||
const l = sx < 0 ? clamp(r.x + dx, 0, right - MIN_CROP) : r.x;
|
||||
const rt = sx > 0 ? clamp(right + dx, r.x + MIN_CROP, 1) : right;
|
||||
const t = sy < 0 ? clamp(r.y + dy, 0, bottom - MIN_CROP) : r.y;
|
||||
const b = sy > 0 ? clamp(bottom + dy, r.y + MIN_CROP, 1) : bottom;
|
||||
if (ratio == null) return { x: l, y: t, w: rt - l, h: b - t };
|
||||
|
||||
// Locked: one width, the height from it, then the room the anchor leaves on both axes.
|
||||
const floor = Math.max(MIN_CROP, MIN_CROP * ratio);
|
||||
if (sx !== 0 && sy !== 0) {
|
||||
const ax = sx > 0 ? r.x : right;
|
||||
const ay = sy > 0 ? r.y : bottom;
|
||||
const byW = rt - l;
|
||||
const byH = (b - t) * ratio;
|
||||
const want = Math.abs(byW - r.w) >= Math.abs(byH - r.h * ratio) ? byW : byH;
|
||||
const room = Math.min(sx > 0 ? 1 - ax : ax, (sy > 0 ? 1 - ay : ay) * ratio);
|
||||
const w = Math.min(Math.max(want, floor), room);
|
||||
const h = w / ratio;
|
||||
return { x: sx > 0 ? ax : ax - w, y: sy > 0 ? ay : ay - h, w, h };
|
||||
}
|
||||
if (sx !== 0) {
|
||||
const ax = sx > 0 ? r.x : right;
|
||||
const cy = r.y + r.h / 2;
|
||||
const room = Math.min(sx > 0 ? 1 - ax : ax, 2 * Math.min(cy, 1 - cy) * ratio);
|
||||
const w = Math.min(Math.max(rt - l, floor), room);
|
||||
const h = w / ratio;
|
||||
return { x: sx > 0 ? ax : ax - w, y: cy - h / 2, w, h };
|
||||
}
|
||||
const ay = sy > 0 ? r.y : bottom;
|
||||
const cx = r.x + r.w / 2;
|
||||
const room = Math.min(sy > 0 ? 1 - ay : ay, (2 * Math.min(cx, 1 - cx)) / ratio);
|
||||
const h = Math.min(Math.max(b - t, floor / ratio), room);
|
||||
const w = h * ratio;
|
||||
return { x: cx - w / 2, y: sy > 0 ? ay : ay - h, w, h };
|
||||
}
|
||||
|
||||
/** The largest frame of `ratio` (see fracRatio) the picture holds, centred where `r` is centred as
|
||||
* far as the picture lets it be. */
|
||||
export function fitRatio(r: Frac, ratio: number): Frac {
|
||||
const w = ratio >= 1 ? 1 : ratio;
|
||||
const h = w / ratio;
|
||||
const cx = r.x + r.w / 2;
|
||||
const cy = r.y + r.h / 2;
|
||||
return { x: clamp(cx - w / 2, 0, 1 - w), y: clamp(cy - h / 2, 0, 1 - h), w, h };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user