// 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 }; }