// 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 (E7 S1→S2): 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 }; }