Files
siftlode/frontend/src/lib/cropGeometry.ts
T
peter fb2c843fa9 fix(downloads): anchor crop overlay to the letterboxed picture (E7 S2)
The video preview is object-fit: contain (the browser default for <video>), so a
non-16:9 source is letter/pillarboxed inside the element box. The crop marquee was
drawn in fractions of the whole box (bars included) and those fractions were then
multiplied by the source resolution, so the emitted ffmpeg crop diverged from what
the selector showed. Anchor the overlay to the computed content rect (and set
object-contain explicitly) so a marquee fraction is a fraction of the real picture;
extract the display-px<->source-px math into cropGeometry.ts with unit tests, and
clamp the even-rounded window in-bounds so a full-frame crop can't overflow.
2026-08-29 05:43:41 +02:00

49 lines
2.8 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 (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 };
}