feat(#24): Gate B 3:4 crop + no upscale (TV-6, TV-7)
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)
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user