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:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user