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:
2026-10-10 12:52:59 +02:00
parent e301632765
commit 520b0afb90
24 changed files with 1260 additions and 153 deletions
+95 -33
View File
@@ -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. */}