An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1; the whole picture stays the default). The frame is sent in fractions of the box it was drawn in, with that box's shape; the worker fits the real picture into the box by its display aspect (sample aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A frame covering the whole box is no crop at all, and one off the picture is refused by name. The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable. YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
contentRect,
|
|
cropToSourcePx,
|
|
fitRatio,
|
|
fracRatio,
|
|
MIN_CROP,
|
|
moveCrop,
|
|
resizeCrop,
|
|
type Frac,
|
|
} 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();
|
|
});
|
|
});
|
|
|
|
const near = (r: Frac, want: Frac) => {
|
|
for (const k of ["x", "y", "w", "h"] as const) expect(r[k], k).toBeCloseTo(want[k], 6);
|
|
};
|
|
// The pixel shape of a frame on a 16:9 picture.
|
|
const shape = (r: Frac) => (r.w * 16) / 9 / r.h;
|
|
const inside = (r: Frac) => {
|
|
expect(r.x).toBeGreaterThanOrEqual(-1e-9);
|
|
expect(r.y).toBeGreaterThanOrEqual(-1e-9);
|
|
expect(r.x + r.w).toBeLessThanOrEqual(1 + 1e-9);
|
|
expect(r.y + r.h).toBeLessThanOrEqual(1 + 1e-9);
|
|
};
|
|
|
|
describe("the crop frame a user draws", () => {
|
|
const r: Frac = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 };
|
|
|
|
it("moves whole, stopping at the picture's edge", () => {
|
|
near(moveCrop(r, 0.1, -0.05), { x: 0.3, y: 0.15, w: 0.4, h: 0.4 });
|
|
near(moveCrop(r, 0.9, -0.9), { x: 0.6, y: 0, w: 0.4, h: 0.4 });
|
|
});
|
|
|
|
it("resizes free from an edge or a corner, the opposite side staying put", () => {
|
|
near(resizeCrop(r, "e", 0.1, 0.3, null), { x: 0.2, y: 0.2, w: 0.5, h: 0.4 });
|
|
near(resizeCrop(r, "nw", -0.1, -0.05, null), { x: 0.1, y: 0.15, w: 0.5, h: 0.45 });
|
|
// past the picture, and past the opposite edge: stopped at the edge and at the floor
|
|
near(resizeCrop(r, "se", 0.9, 0.9, null), { x: 0.2, y: 0.2, w: 0.8, h: 0.8 });
|
|
near(resizeCrop(r, "w", 0.9, 0, null), { x: 0.6 - MIN_CROP, y: 0.2, w: MIN_CROP, h: 0.4 });
|
|
});
|
|
|
|
it("keeps a locked shape from a corner, following the side the pointer changed more", () => {
|
|
const ratio = fracRatio(9 / 16, 16 / 9);
|
|
const start = fitRatio(r, ratio);
|
|
const grown = resizeCrop(start, "se", 0, -0.2, ratio);
|
|
expect(shape(grown)).toBeCloseTo(9 / 16, 6);
|
|
inside(grown);
|
|
// a mostly sideways drag inward shrinks it (a "larger of the two" rule never would)
|
|
const narrowed = resizeCrop({ x: 0.3, y: 0.1, w: 0.2, h: 0.6 }, "ne", -0.08, 0.01, 0.75);
|
|
expect(narrowed.w).toBeCloseTo(0.12, 6);
|
|
expect(narrowed.h).toBeCloseTo(0.16, 6);
|
|
expect(narrowed.y + narrowed.h).toBeCloseTo(0.7, 6); // the anchored corner (sw) stays
|
|
});
|
|
|
|
it("keeps a locked shape from an edge, growing the other side about the middle and in bounds", () => {
|
|
const ratio = fracRatio(1, 16 / 9);
|
|
const sq = { x: 0.4, y: 0.4, w: 0.2 * ratio, h: 0.2 };
|
|
const out = resizeCrop(sq, "e", 0.5, 0, ratio);
|
|
expect(shape(out)).toBeCloseTo(1, 6);
|
|
expect(out.y + out.h / 2).toBeCloseTo(0.5, 6);
|
|
inside(out);
|
|
const tall = resizeCrop(sq, "s", 0, 0.9, ratio);
|
|
expect(shape(tall)).toBeCloseTo(1, 6);
|
|
inside(tall);
|
|
});
|
|
|
|
it("fits a chosen shape as large as the picture allows, centred on the frame", () => {
|
|
const tall = fitRatio({ x: 0.7, y: 0.1, w: 0.2, h: 0.2 }, fracRatio(9 / 16, 16 / 9));
|
|
expect(tall.h).toBeCloseTo(1, 6);
|
|
expect(shape(tall)).toBeCloseTo(9 / 16, 6);
|
|
expect(tall.x + tall.w / 2).toBeCloseTo(0.8, 6);
|
|
const wide = fitRatio({ x: 0, y: 0, w: 1, h: 1 }, fracRatio(21 / 9, 16 / 9));
|
|
near(wide, { x: 0, y: (1 - (16 / 9) * (9 / 21)) / 2, w: 1, h: (16 / 9) * (9 / 21) });
|
|
});
|
|
});
|