- The cut file is measured against what its owner has left, for a YouTube range (whose fetch never reads yt-dlp's size ceiling) and for a Plex clip (which fetches nothing), before the fetch and again before it is placed; a refusal leaves the asset pending for a Retry. - A silent Plex file is cut silent; a file the probe cannot read says so. - A second job on a failed or renamed clip gets the same YouTube diagnosis and the fetched title. - The "too long" refusal names the server's cap; a contract test holds the client's copies of the clip limits to clip.py; the shipped player clients are pinned against the installed yt-dlp. - One HDR transfer set and one rating_key lookup; job status words from JobStatus; the display-name guard sees subscript writes and the clip route; stale comments and types follow the clip job.
134 lines
6.5 KiB
TypeScript
134 lines
6.5 KiB
TypeScript
// Crop geometry for the video editor — the display-px ↔ source-px mapping, isolated as pure
|
||
// functions so the math is unit-testable without a DOM.
|
||
//
|
||
// The bug this fixes: the crop marquee is drawn in fractions of the *video element box*,
|
||
// but a letterboxed video (object-fit: contain) does NOT fill that box — pillar/letterbox bars sit
|
||
// between the box edge and the actual picture. Multiplying box-fractions by the source resolution
|
||
// therefore emitted an ffmpeg crop shifted/scaled off from what the selector showed. The fix is to
|
||
// anchor the overlay to the *content* rect (below), so a fraction of the overlay is a fraction of
|
||
// the real picture, and cropToSourcePx() is then a straight fraction→pixel scale.
|
||
|
||
export type Frac = { x: number; y: number; w: number; h: number };
|
||
type Rect = { left: number; top: number; width: number; height: number };
|
||
type CropPx = { x: number; y: number; w: number; h: number };
|
||
|
||
/**
|
||
* The rendered video-content rectangle inside an element box of `boxW × boxH`, given the video's
|
||
* intrinsic aspect ratio, when the video is laid out `object-fit: contain` (letterboxed). Returns
|
||
* null while any input is unmeasurable (0/NaN), so callers can skip drawing until the video sizes.
|
||
*/
|
||
export function contentRect(boxW: number, boxH: number, videoAspect: number): Rect | null {
|
||
if (!(boxW > 0) || !(boxH > 0) || !(videoAspect > 0)) return null;
|
||
const boxAspect = boxW / boxH;
|
||
if (boxAspect > videoAspect) {
|
||
// box wider than the picture → pillarbox (bars left/right), full height
|
||
const width = boxH * videoAspect;
|
||
return { left: (boxW - width) / 2, top: 0, width, height: boxH };
|
||
}
|
||
// box taller than the picture → letterbox (bars top/bottom), full width
|
||
const height = boxW / videoAspect;
|
||
return { left: 0, top: (boxH - height) / 2, width: boxW, height };
|
||
}
|
||
|
||
/**
|
||
* A crop expressed as fractions of the video *content* → integer, even source-pixel crop args
|
||
* (ffmpeg `crop=w:h:x:y`). Even dimensions keep chroma-subsampled encoders happy; the window is
|
||
* clamped inside the frame so even-rounding can never push `x+w`/`y+h` past the source resolution
|
||
* (which ffmpeg rejects as "crop area out of bounds"). Returns null for an unusable source size.
|
||
*/
|
||
export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx | null {
|
||
if (!(srcW > 0) || !(srcH > 0)) return null;
|
||
const evenDim = (n: number) => Math.max(2, Math.round(n / 2) * 2); // w/h ≥ 2
|
||
const evenOff = (n: number) => Math.max(0, Math.round(n / 2) * 2); // x/y ≥ 0 (top-left is valid)
|
||
const w = Math.min(evenDim(crop.w * srcW), srcW);
|
||
const h = Math.min(evenDim(crop.h * srcH), srcH);
|
||
const x = Math.min(evenOff(crop.x * srcW), srcW - w);
|
||
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's floor (downloads/clip), held equal by
|
||
* clipLimits.contract.test.ts. */
|
||
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 };
|
||
}
|