From f7040f59473f3d707be4b7e87e7a075003e09fcb Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Mon, 7 Sep 2026 06:24:00 +0000 Subject: [PATCH] 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) --- frontend/src/__tests__/fixtures/crop.ts | 156 +++++++++++++++ frontend/src/__tests__/tv6-tv7-crop.test.ts | 210 ++++++++++++++++++++ frontend/src/lib/pipeline/crop.ts | 178 +++++++++++++++++ frontend/src/lib/pipeline/index.ts | 1 + frontend/src/lib/pipeline/types.ts | 15 ++ 5 files changed, 560 insertions(+) create mode 100644 frontend/src/__tests__/fixtures/crop.ts create mode 100644 frontend/src/__tests__/tv6-tv7-crop.test.ts create mode 100644 frontend/src/lib/pipeline/crop.ts diff --git a/frontend/src/__tests__/fixtures/crop.ts b/frontend/src/__tests__/fixtures/crop.ts new file mode 100644 index 0000000..ab88857 --- /dev/null +++ b/frontend/src/__tests__/fixtures/crop.ts @@ -0,0 +1,156 @@ +import type { MaskBuffer, RgbaImage, Vec2 } from "../../../lib/pipeline/types.js"; + +function blankImage(width: number, height: number): RgbaImage { + return { width, height, data: new Uint8ClampedArray(width * height * 4) }; +} + +function blankMask(width: number, height: number): MaskBuffer { + return new Uint8Array(width * height); +} + +function fillImageRect( + img: RgbaImage, + x0: number, y0: number, x1: number, y1: number, + r: number, g: number, b: number, a: number = 255, +): void { + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + const i = (y * img.width + x) * 4; + img.data[i] = r; + img.data[i + 1] = g; + img.data[i + 2] = b; + img.data[i + 3] = a; + } + } +} + +function fillMaskRect(mask: MaskBuffer, w: number, x0: number, y0: number, x1: number, y1: number): void { + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + mask[y * w + x] = 255; + } + } +} + +/** + * TV-6 fixture: large source with a tall slab so the 3:4 crop shorter side + * meets OUTPUT_PX_MIN (1600). + * + * Slab 1281×3000, aspect 0.427 < 0.75 → height limiting. + * computeCropTarget → frameW=1601, frameH=2135, shorter=1601 ≥ 1600. + */ +export function tv6LargeSourceFixture(): { + image: RgbaImage; + mask: MaskBuffer; + width: number; + height: number; + slabLeft: number; + slabTop: number; + slabRight: number; + slabBottom: number; + expectedFrameW: number; + expectedFrameH: number; + expectedMeetsMinPx: boolean; +} { + const width = 4000; + const height = 3200; + const image = blankImage(width, height); + const mask = blankMask(width, height); + + const slabW = 1281; + const slabH = 3000; + const sl = Math.round((width - slabW) / 2); + const st = Math.round((height - slabH) / 2); + + fillImageRect(image, 0, 0, width, height, 50, 200, 50, 255); + fillImageRect(image, sl, st, sl + slabW, st + slabH, 160, 100, 60, 255); + fillMaskRect(mask, width, sl, st, sl + slabW, st + slabH); + + return { + image, + mask, + width, + height, + slabLeft: sl, + slabTop: st, + slabRight: sl + slabW, + slabBottom: st + slabH, + expectedFrameW: 1601, + expectedFrameH: 2135, + expectedMeetsMinPx: true, + }; +} + +/** + * TV-7 fixture: small source that cannot supply 1600 px after crop. + * Slab 400×300, aspect 1.33 > 0.75 → width limiting. + * computeCropTarget → frameW=281, frameH=375, shorter=281 < 1600. + */ +export function tv7SmallSourceFixture(): { + image: RgbaImage; + mask: MaskBuffer; + width: number; + height: number; + slabLeft: number; + slabTop: number; + slabRight: number; + slabBottom: number; + expectedFrameW: number; + expectedFrameH: number; + expectedMeetsMinPx: boolean; +} { + const width = 800; + const height = 640; + const image = blankImage(width, height); + const mask = blankMask(width, height); + + const slabW = 400; + const slabH = 300; + const sl = Math.round((width - slabW) / 2); + const st = Math.round((height - slabH) / 2); + + fillImageRect(image, 0, 0, width, height, 50, 200, 50, 255); + fillImageRect(image, sl, st, sl + slabW, st + slabH, 160, 100, 60, 255); + fillMaskRect(mask, width, sl, st, sl + slabW, st + slabH); + + return { + image, + mask, + width, + height, + slabLeft: sl, + slabTop: st, + slabRight: sl + slabW, + slabBottom: st + slabH, + expectedFrameW: 281, + expectedFrameH: 375, + expectedMeetsMinPx: false, + }; +} + +/** + * Create a simple axis for testing, pointing right, centered in the slab. + * lengthIn = widthPx so scale is 1 px/in. + */ +export function simpleAxis(widthPx: number, heightPx: number): { + axis: { center: Vec2; direction: Vec2; lengthPx: number; widthPx: number; corners: [Vec2, Vec2, Vec2, Vec2] }; + lengthIn: number; +} { + const center = { x: widthPx / 2, y: heightPx / 2 }; + const direction = { x: 1, y: 0 }; + return { + axis: { + center, + direction, + lengthPx: widthPx, + widthPx: heightPx, + corners: [ + { x: 0, y: 0 }, + { x: widthPx, y: 0 }, + { x: widthPx, y: heightPx }, + { x: 0, y: heightPx }, + ], + }, + lengthIn: widthPx, + }; +} diff --git a/frontend/src/__tests__/tv6-tv7-crop.test.ts b/frontend/src/__tests__/tv6-tv7-crop.test.ts new file mode 100644 index 0000000..b9643cc --- /dev/null +++ b/frontend/src/__tests__/tv6-tv7-crop.test.ts @@ -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}`, + }, + }, + }), + ); + }); +}); diff --git a/frontend/src/lib/pipeline/crop.ts b/frontend/src/lib/pipeline/crop.ts new file mode 100644 index 0000000..47fb58a --- /dev/null +++ b/frontend/src/lib/pipeline/crop.ts @@ -0,0 +1,178 @@ +import { ASPECT_H, ASPECT_W, FILL_TARGET, OUTPUT_PX_MIN } from "./constants.js"; +import type { CropResult, CropStatus, LengthAxis, MaskBuffer, RgbaImage } from "./types.js"; + +/** + * Find the bounding box of the slab in the mask. + */ +export function findSlabBounds(mask: MaskBuffer, width: number, height: number): { + left: number; + top: number; + right: number; + bottom: number; + widthPx: number; + heightPx: number; +} { + let left = width; + let top = height; + let right = 0; + let bottom = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (mask[y * width + x]! >= 128) { + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + } + return { + left, + top, + right, + bottom, + widthPx: right - left + 1, + heightPx: bottom - top + 1, + }; +} + +/** + * Compute target 3:4 frame dimensions so the slab fills FILL_TARGET (80%) + * at the extremes. Returns the frame size in pixels. + */ +export function computeCropTarget(slabWidthPx: number, slabHeightPx: number): { + width: number; + height: number; +} { + const targetAspect = ASPECT_W / ASPECT_H; + const slabAspect = slabWidthPx / slabHeightPx; + + if (slabAspect >= targetAspect) { + // Width is limiting; height gets extra margin + const frameHeight = slabHeightPx / FILL_TARGET; + return { width: Math.round(frameHeight * targetAspect), height: Math.round(frameHeight) }; + } else { + // Height is limiting; width gets extra margin + const frameWidth = slabWidthPx / FILL_TARGET; + return { width: Math.round(frameWidth), height: Math.round(frameWidth / targetAspect) }; + } +} + +/** + * Crop the source to a 3:4 portrait frame, center the slab, with 80% fill + * at extremes, transparent background. No upscaling. + * + * @param image Original source RGBA image. + * @param mask Binary mask (255 = slab, 0 = background). + * @param axis Confirmed length axis for slab bounds. + * @param lengthIn User-entered length in inches (used to derive vertical scale). + * @returns CropResult with the cropped image and metadata. + */ +export function cropTo34( + image: RgbaImage, + mask: MaskBuffer, + axis: LengthAxis, + lengthIn: number, +): CropResult { + const scalePx = axis.lengthPx / lengthIn; + const slabBounds = findSlabBounds(mask, image.width, image.height); + + const target = computeCropTarget(slabBounds.widthPx, slabBounds.heightPx); + const { width: frameW, height: frameH } = target; + + // Check minimum output size before any scaling + const shorterSide = Math.min(frameW, frameH); + const meetsMinPx = shorterSide >= OUTPUT_PX_MIN; + + // Compute the scale factor: we want the slab to fill FILL_TARGET of the frame + // at the extremes. The slab is scaled down by FILL_TARGET, then the frame + // adds extra margin. + const slabW = slabBounds.widthPx; + const slabH = slabBounds.heightPx; + const slabAspect = slabW / slabH; + const targetAspect = ASPECT_W / ASPECT_H; + + // Compute how much to scale the slab to fit 80% of the frame + let scaleDown: number; + if (slabAspect >= targetAspect) { + // Width limiting: frameH = slabH / 0.8, frameW = frameH * 0.75 + // Scale factor for width: slabW / frameW + scaleDown = slabW / frameW; + } else { + // Height limiting: frameW = slabW / 0.8, frameH = frameW / 0.75 + // Scale factor for height: slabH / frameH + scaleDown = slabH / frameH; + } + + // Scaled slab dimensions within the frame + const scaledSlabW = Math.round(slabW * FILL_TARGET); + const scaledSlabH = Math.round(slabH * FILL_TARGET); + + // Center position within the frame + const offsetX = Math.round((frameW - scaledSlabW) / 2); + const offsetY = Math.round((frameH - scaledSlabH) / 2); + + // Create the output image + const outData = new Uint8ClampedArray(frameW * frameH * 4); + + // For each output pixel, compute which source pixel to sample + for (let oy = 0; oy < frameH; oy++) { + for (let ox = 0; ox < frameW; ox++) { + const oi = (oy * frameW + ox) * 4; + + // Check if this output pixel is within the slab region + if (ox < offsetX || ox >= offsetX + scaledSlabW || + oy < offsetY || oy >= offsetY + scaledSlabH) { + // Transparent margin + outData[oi] = 0; + outData[oi + 1] = 0; + outData[oi + 2] = 0; + outData[oi + 3] = 0; + continue; + } + + // Map to slab-local coordinates + const slabX = ox - offsetX; + const slabY = oy - offsetY; + + // Nearest-neighbor sample from source + const srcX = Math.round((slabX / scaledSlabW) * slabW) + slabBounds.left; + const srcY = Math.round((slabY / scaledSlabH) * slabH) + slabBounds.top; + + // Clamp to source bounds + const sx = Math.min(Math.max(0, srcX), image.width - 1); + const sy = Math.min(Math.max(0, srcY), image.height - 1); + const si = (sy * image.width + sx) * 4; + + outData[oi] = image.data[si]!; + outData[oi + 1] = image.data[si + 1]!; + outData[oi + 2] = image.data[si + 2]!; + + // Alpha from the mask at the source location + const maskVal = mask[sy * image.width + sx]!; + outData[oi + 3] = maskVal; + } + } + + return { + image: { width: frameW, height: frameH, data: outData }, + width: frameW, + height: frameH, + meetsMinPx, + }; +} + +/** + * Check if a source image is too small to produce an OUTPUT_PX_MIN result. + * Returns CropStatus. + */ +export function checkSourceSize( + mask: MaskBuffer, + width: number, + height: number, +): CropStatus { + const bounds = findSlabBounds(mask, width, height); + const target = computeCropTarget(bounds.widthPx, bounds.heightPx); + const shorterSide = Math.min(target.width, target.height); + return shorterSide >= OUTPUT_PX_MIN ? "ok" : "undersized"; +} diff --git a/frontend/src/lib/pipeline/index.ts b/frontend/src/lib/pipeline/index.ts index 4812bc2..3ecdfb7 100644 --- a/frontend/src/lib/pipeline/index.ts +++ b/frontend/src/lib/pipeline/index.ts @@ -3,3 +3,4 @@ export * from "./types.js"; export * from "./mask.js"; export * from "./units.js"; export * from "./axis.js"; +export * from "./crop.js"; diff --git a/frontend/src/lib/pipeline/types.ts b/frontend/src/lib/pipeline/types.ts index 212e1c6..5a1d05e 100644 --- a/frontend/src/lib/pipeline/types.ts +++ b/frontend/src/lib/pipeline/types.ts @@ -72,3 +72,18 @@ export interface WidthStats { average: number; samples: WidthSample[]; } + +/** 3:4 crop output with alpha channel (TECH-SPEC §5). */ +export interface CropResult { + /** Cropped image with alpha: 255 = slab, 0 = transparent background. */ + image: RgbaImage; + /** Width of the 3:4 output frame in pixels. */ + width: number; + /** Height of the 3:4 output frame in pixels. */ + height: number; + /** True when shorter side meets OUTPUT_PX_MIN; false when undersized. */ + meetsMinPx: boolean; +} + +/** Crop status for TV-7 (undersized source). */ +export type CropStatus = "ok" | "undersized";