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"; }