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,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from "./types.js";
|
|||||||
export * from "./mask.js";
|
export * from "./mask.js";
|
||||||
export * from "./units.js";
|
export * from "./units.js";
|
||||||
export * from "./axis.js";
|
export * from "./axis.js";
|
||||||
|
export * from "./crop.js";
|
||||||
|
|||||||
@@ -72,3 +72,18 @@ export interface WidthStats {
|
|||||||
average: number;
|
average: number;
|
||||||
samples: WidthSample[];
|
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";
|
||||||
|
|||||||
Reference in New Issue
Block a user