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.
This commit is contained in:
2026-08-29 05:43:41 +02:00
parent bb6cb6e335
commit fb2c843fa9
3 changed files with 177 additions and 14 deletions
+41 -14
View File
@@ -7,6 +7,7 @@ import clsx from "clsx";
import Modal from "./Modal";
import { notify } from "../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button";
@@ -31,7 +32,6 @@ function parseTc(v: string): number | null {
}
type Seg = { id: number; start: number; end: number; keep: boolean };
type Frac = { x: number; y: number; w: number; h: number };
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
const numCls =
@@ -51,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [current, setCurrent] = useState(0);
const [playing, setPlaying] = useState(false);
const [trackW, setTrackW] = useState(0);
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
const [box, setBox] = useState({ w: 0, h: 0 });
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
@@ -83,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return () => ro.disconnect();
}, []);
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
useEffect(() => {
const v = videoRef.current;
if (!v || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
ro.observe(v);
setBox({ w: v.clientWidth, h: v.clientHeight });
return () => ro.disconnect();
}, []);
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
const picRect = contentRect(box.w, box.h, va);
const onLoaded = () => {
const v = videoRef.current;
if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d);
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
@@ -258,16 +280,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const v = videoRef.current;
const vw = v?.videoWidth || job.asset?.width || 0;
const vh = v?.videoHeight || job.asset?.height || 0;
if (!cropOn || !vw || !vh) return undefined;
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
return {
x: even(crop.x * vw),
y: even(crop.y * vh),
w: even(crop.w * vw),
h: even(crop.h * vh),
};
const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined;
};
const create = useMutation({
@@ -322,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onTimeUpdate={onTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="w-full max-h-[42vh] mx-auto block"
className="w-full max-h-[42vh] mx-auto block object-contain"
playsInline
/>
{cropOn && (
<div ref={overlayRef} className="absolute inset-0">
{cropOn && picRect && (
<div
ref={overlayRef}
className="absolute"
style={{
left: picRect.left,
top: picRect.top,
width: picRect.width,
height: picRect.height,
}}
>
<div
className="absolute border-2 border-accent cursor-move"
style={{