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
+2
View File
@@ -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;
}
+73 -1
View File
@@ -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) });
});
});
+84
View File
@@ -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 };
}