Files
siftlode/frontend/src/lib/cropGeometry.ts
T
peter b4bf71ea64 fix(clip): a clip is held to its owner's storage cap, and its limits and failures say the server's truth
- 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.
2026-10-10 15:35:12 +02:00

134 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}