f7040f5947
TV-6: 3:4 portrait crop, 80% fill at extremes, transparent PNG, shorter side >= 1600px when source allows. TV-7: undersized source warning, no upscaling, photo not publishable until retake. Real fixture output: - TV-6 large source (4000x3200, slab 1281x3000): crop target 1601x2135, shorter 1601 >= 1600, fill at limiting dim 0.80 - TV-7 small source (800x640, slab 400x300): crop target 281x375, shorter 281 < 1600, undersized - No upscale property: output never exceeds source dimensions New files: - frontend/src/lib/pipeline/crop.ts: cropTo34(), findSlabBounds(), computeCropTarget(), checkSourceSize() - frontend/src/__tests__/fixtures/crop.ts: tv6LargeSourceFixture, tv7SmallSourceFixture, simpleAxis - frontend/src/__tests__/tv6-tv7-crop.test.ts: 7 tests (2 TV-6, 2 TV-7, 3 property)
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
ASPECT_H,
|
||
ASPECT_W,
|
||
FILL_TARGET,
|
||
OUTPUT_PX_MIN,
|
||
} from "../lib/pipeline/constants.js";
|
||
import {
|
||
computeCropTarget,
|
||
cropTo34,
|
||
findSlabBounds,
|
||
} from "../lib/pipeline/crop.js";
|
||
import {
|
||
simpleAxis,
|
||
tv6LargeSourceFixture,
|
||
tv7SmallSourceFixture,
|
||
} from "./fixtures/crop.js";
|
||
|
||
describe("TV-6 3:4 crop, 80% fill, transparent PNG, ≥1600px", () => {
|
||
it("computes crop target dimensions preserving 3:4 aspect and 80% fill at extremes", () => {
|
||
// Slab 1281×3000 — height limiting, width gets extra margin
|
||
const t1 = computeCropTarget(1281, 3000);
|
||
expect(Math.abs(t1.width / t1.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||
|
||
// Width-limiting case: slab 2000×1600
|
||
const t2 = computeCropTarget(2000, 1600);
|
||
expect(Math.abs(t2.width / t2.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-6",
|
||
target1: { slabW: 1281, slabH: 3000, frame: t1, aspect: t1.width / t1.height },
|
||
target2: { slabW: 2000, slabH: 1600, frame: t2, aspect: t2.width / t2.height },
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("finds slab bounding box correctly", () => {
|
||
const fx = tv6LargeSourceFixture();
|
||
const bounds = findSlabBounds(fx.mask, fx.width, fx.height);
|
||
// findSlabBounds returns inclusive coordinates; fixture uses exclusive.
|
||
expect(bounds.left).toBe(fx.slabLeft);
|
||
expect(bounds.top).toBe(fx.slabTop);
|
||
expect(bounds.right).toBe(fx.slabRight - 1);
|
||
expect(bounds.bottom).toBe(fx.slabBottom - 1);
|
||
// WidthPx should match slab dimensions
|
||
expect(bounds.widthPx).toBe(fx.slabRight - fx.slabLeft);
|
||
expect(bounds.heightPx).toBe(fx.slabBottom - fx.slabTop);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-6",
|
||
bounds: {
|
||
left: bounds.left,
|
||
top: bounds.top,
|
||
right: bounds.right,
|
||
bottom: bounds.bottom,
|
||
widthPx: bounds.widthPx,
|
||
heightPx: bounds.heightPx,
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("crops large source to 3:4 with 80% fill, shorter side ≥ 1600, transparent PNG", () => {
|
||
const fx = tv6LargeSourceFixture();
|
||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||
|
||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||
|
||
expect(result.width).toBe(fx.expectedFrameW);
|
||
expect(result.height).toBe(fx.expectedFrameH);
|
||
expect(result.meetsMinPx).toBe(true);
|
||
|
||
// Verify 3:4 aspect ratio
|
||
expect(Math.abs(result.width / result.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||
|
||
// Verify shorter side ≥ OUTPUT_PX_MIN
|
||
expect(Math.min(result.width, result.height)).toBeGreaterThanOrEqual(OUTPUT_PX_MIN);
|
||
|
||
// Verify transparency: margin pixels have alpha = 0
|
||
const marginAlpha = result.image.data[3];
|
||
expect(marginAlpha).toBe(0);
|
||
|
||
// Verify slab pixels are opaque (alpha = 255)
|
||
const slabCenterX = Math.round(result.width / 2);
|
||
const slabCenterY = Math.round(result.height / 2);
|
||
const slabCenterAlpha = result.image.data[(slabCenterY * result.width + slabCenterX) * 4 + 3]!;
|
||
expect(slabCenterAlpha).toBe(255);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-6",
|
||
frameW: result.width,
|
||
frameH: result.height,
|
||
shorterSide: Math.min(result.width, result.height),
|
||
meetsMinPx: result.meetsMinPx,
|
||
marginAlpha: marginAlpha,
|
||
slabCenterAlpha: slabCenterAlpha,
|
||
aspect: result.width / result.height,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("property: fill at extremes equals FILL_TARGET at limiting dimension", () => {
|
||
const fx = tv6LargeSourceFixture();
|
||
const slabW = fx.slabRight - fx.slabLeft;
|
||
const slabH = fx.slabBottom - fx.slabTop;
|
||
const target = computeCropTarget(slabW, slabH);
|
||
const slabAspect = slabW / slabH;
|
||
const targetAspect = ASPECT_W / ASPECT_H;
|
||
|
||
// At the extremes, fill should be FILL_TARGET
|
||
const fillAtLimitingDim = slabAspect >= targetAspect
|
||
? slabH / target.height
|
||
: slabW / target.width;
|
||
|
||
expect(fillAtLimitingDim).toBeCloseTo(FILL_TARGET, 3);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-6",
|
||
fillAtLimitingDim,
|
||
expectedFill: FILL_TARGET,
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("TV-7 Too-small source — warn, no upscale, not publishable", () => {
|
||
it("detects undersized source and refuses upscale", () => {
|
||
const fx = tv7SmallSourceFixture();
|
||
const bounds = findSlabBounds(fx.mask, fx.width, fx.height);
|
||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||
|
||
expect(Math.min(target.width, target.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||
expect(target.width).toBe(fx.expectedFrameW);
|
||
expect(target.height).toBe(fx.expectedFrameH);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-7",
|
||
slabW: bounds.widthPx,
|
||
slabH: bounds.heightPx,
|
||
frameW: target.width,
|
||
frameH: target.height,
|
||
shorterSide: Math.min(target.width, target.height),
|
||
outputPxMin: OUTPUT_PX_MIN,
|
||
undersized: Math.min(target.width, target.height) < OUTPUT_PX_MIN,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("cropTo34 returns meetsMinPx = false for undersized source", () => {
|
||
const fx = tv7SmallSourceFixture();
|
||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||
|
||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||
|
||
expect(result.meetsMinPx).toBe(false);
|
||
expect(Math.min(result.width, result.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-7",
|
||
frameW: result.width,
|
||
frameH: result.height,
|
||
shorterSide: Math.min(result.width, result.height),
|
||
meetsMinPx: result.meetsMinPx,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("property: no upscale path — output is never larger than source", () => {
|
||
// For any source, the crop output dimensions should never exceed
|
||
// the source dimensions. This ensures no fake upscaling.
|
||
const fx = tv7SmallSourceFixture();
|
||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||
|
||
expect(result.width).toBeLessThanOrEqual(fx.width);
|
||
expect(result.height).toBeLessThanOrEqual(fx.height);
|
||
|
||
// Also verify for the large source
|
||
const fxLarge = tv6LargeSourceFixture();
|
||
const { axis: axis2, lengthIn: lengthIn2 } = simpleAxis(
|
||
fxLarge.slabRight - fxLarge.slabLeft,
|
||
fxLarge.slabBottom - fxLarge.slabTop,
|
||
);
|
||
const result2 = cropTo34(fxLarge.image, fxLarge.mask, axis2, lengthIn2);
|
||
expect(result2.width).toBeLessThanOrEqual(fxLarge.width);
|
||
expect(result2.height).toBeLessThanOrEqual(fxLarge.height);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
case: "TV-7",
|
||
noUpscaleCheck: {
|
||
small: {
|
||
source: `${fx.width}x${fx.height}`,
|
||
output: `${result.width}x${result.height}`,
|
||
},
|
||
large: {
|
||
source: `${fxLarge.width}x${fxLarge.height}`,
|
||
output: `${result2.width}x${result2.height}`,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
});
|