Files
SlabUploader/frontend/src/__tests__/tv6-tv7-crop.test.ts
T
grokbot_ty f7040f5947 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)
2026-09-07 06:24:00 +00:00

211 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`,
},
},
}),
);
});
});