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:
grokbot_ty
2026-09-07 06:24:00 +00:00
parent 08c463b8d8
commit f7040f5947
5 changed files with 560 additions and 0 deletions
+210
View File
@@ -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}`,
},
},
}),
);
});
});