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.
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { contentRect, cropToSourcePx } from "./cropGeometry";
|
|
|
|
describe("contentRect (display-px letterbox mapping)", () => {
|
|
it("returns the full box when box aspect matches the video aspect", () => {
|
|
// 16:9 picture in a 16:9 box → no bars, content == box
|
|
expect(contentRect(1600, 900, 16 / 9)).toEqual({
|
|
left: 0,
|
|
top: 0,
|
|
width: 1600,
|
|
height: 900,
|
|
});
|
|
});
|
|
|
|
it("pillarboxes (bars left/right) when the box is wider than the picture", () => {
|
|
// 16:9 picture in a 2:1 box → full height, narrower centered width
|
|
const r = contentRect(2000, 1000, 16 / 9)!;
|
|
expect(r.height).toBe(1000);
|
|
expect(r.width).toBeCloseTo(1000 * (16 / 9), 3);
|
|
expect(r.top).toBe(0);
|
|
expect(r.left).toBeCloseTo((2000 - 1000 * (16 / 9)) / 2, 3);
|
|
});
|
|
|
|
it("letterboxes (bars top/bottom) when the box is taller than the picture", () => {
|
|
// 16:9 picture in a 1:1 box → full width, shorter centered height
|
|
const r = contentRect(1000, 1000, 16 / 9)!;
|
|
expect(r.width).toBe(1000);
|
|
expect(r.height).toBeCloseTo(1000 / (16 / 9), 3);
|
|
expect(r.left).toBe(0);
|
|
expect(r.top).toBeCloseTo((1000 - 1000 / (16 / 9)) / 2, 3);
|
|
});
|
|
|
|
it("returns null for unmeasurable inputs", () => {
|
|
expect(contentRect(0, 900, 16 / 9)).toBeNull();
|
|
expect(contentRect(1600, 0, 16 / 9)).toBeNull();
|
|
expect(contentRect(1600, 900, 0)).toBeNull();
|
|
expect(contentRect(1600, 900, NaN)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("cropToSourcePx (fraction-of-content → even source px)", () => {
|
|
it("scales content fractions straight to source pixels", () => {
|
|
// A marquee anchored to the content rect: a fraction is a fraction of the real picture.
|
|
expect(cropToSourcePx({ x: 0.25, y: 0.25, w: 0.5, h: 0.5 }, 1920, 1080)).toEqual({
|
|
x: 480,
|
|
y: 270,
|
|
w: 960,
|
|
h: 540,
|
|
});
|
|
});
|
|
|
|
it("rounds to even dimensions", () => {
|
|
// 0.1 * 1920 = 192 (even); 0.333 * 1080 = 359.64 → 360
|
|
const c = cropToSourcePx({ x: 0.1, y: 0.333, w: 0.2, h: 0.111 }, 1920, 1080)!;
|
|
expect(c.w % 2).toBe(0);
|
|
expect(c.h % 2).toBe(0);
|
|
expect(c.x % 2).toBe(0);
|
|
expect(c.y % 2).toBe(0);
|
|
});
|
|
|
|
it("allows a top-left origin (x/y may be 0)", () => {
|
|
const c = cropToSourcePx({ x: 0, y: 0, w: 0.5, h: 0.5 }, 1920, 1080)!;
|
|
expect(c.x).toBe(0);
|
|
expect(c.y).toBe(0);
|
|
});
|
|
|
|
it("clamps the window inside the frame so even-rounding can't overflow", () => {
|
|
// A full-frame crop must stay exactly at the source size, not round past it.
|
|
const c = cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1921, 1081)!;
|
|
expect(c.w).toBeLessThanOrEqual(1921);
|
|
expect(c.h).toBeLessThanOrEqual(1081);
|
|
expect(c.x + c.w).toBeLessThanOrEqual(1921);
|
|
expect(c.y + c.h).toBeLessThanOrEqual(1081);
|
|
});
|
|
|
|
it("keeps x+w within the frame for an off-square region near the right edge", () => {
|
|
const srcW = 1280;
|
|
const srcH = 720;
|
|
const c = cropToSourcePx({ x: 0.7, y: 0.1, w: 0.3, h: 0.8 }, srcW, srcH)!;
|
|
expect(c.x + c.w).toBeLessThanOrEqual(srcW);
|
|
expect(c.y + c.h).toBeLessThanOrEqual(srcH);
|
|
});
|
|
|
|
it("returns null for an unusable source size", () => {
|
|
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 0, 1080)).toBeNull();
|
|
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1920, 0)).toBeNull();
|
|
});
|
|
});
|