import type { Mask, Source } from '../capture/capture.types'; import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop'; import { getCropFill } from '../settings/cropFill'; type MaskPick = Pick; type SourcePick = Pick; export interface InventoryInput { bitmap: ImageBitmap; mask: MaskPick; source: SourcePick; /** Optional override; default reads live settings.crop_fill (#99). */ cropFill?: number; } export interface Frame { width: number; height: number; rgba: Uint8Array; /** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */ sourceBelowBlock: boolean; /** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */ sourceBelowWarn: boolean; } export function composeInventoryFrame( src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray }, mask: MaskPick, source: SourcePick, cropFill: number = DEFAULT_CROP_FILL, ): Frame { const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL; const b = mask.slabBounds; const x0 = Math.max(0, Math.round(b.x)); const y0 = Math.max(0, Math.round(b.y)); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(b.w))); const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h))); const sw = x1 - x0; const sh = y1 - y0; const shortSide = Math.min(sw, sh); const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX; const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX; // Undersized vs warn floor: frame from available source (no upscale to 800). const undersized = shortSide < FLOOR_WARN_PX; const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX; const outH = Math.round((outW * ASPECT_H) / ASPECT_W); const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1)); const dw = sw * scale; const dh = sh * scale; const ox = (outW - dw) / 2; const oy = (outH - dh) / 2; const rgba = new Uint8Array(outW * outH * 4); const xEnd = ox + dw; const yEnd = oy + dh; for (let y = 0; y < outH; y++) { for (let x = 0; x < outW; x++) { if (x + 0.5 < ox || x + 0.5 >= xEnd || y + 0.5 < oy || y + 0.5 >= yEnd) continue; const sx = Math.floor(x0 + (x + 0.5 - ox) / scale); const sy = Math.floor(y0 + (y + 0.5 - oy) / scale); if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue; if (mask.bitset[sy * source.widthPx + sx] !== 1) continue; const si = (sy * source.widthPx + sx) * 4; const di = (y * outW + x) * 4; rgba[di] = src.rgba[si]; rgba[di + 1] = src.rgba[si + 1]; rgba[di + 2] = src.rgba[si + 2]; rgba[di + 3] = 255; } } return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn }; } export async function encodeInventoryPng( input: InventoryInput, ): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> { const { bitmap, mask, source } = input; const fill = input.cropFill ?? getCropFill(); const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); const srcCtx = srcCanvas.getContext('2d')!; srcCtx.drawImage(bitmap, 0, 0); const img = srcCtx.getImageData(0, 0, source.widthPx, source.heightPx); const frame = composeInventoryFrame( { width: source.widthPx, height: source.heightPx, rgba: img.data }, mask, source, fill, ); const out = new OffscreenCanvas(frame.width, frame.height); const ctx = out.getContext('2d')!; ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0); const blob = await out.convertToBlob({ type: 'image/png' }); return { blob, sourceBelowBlock: frame.sourceBelowBlock, sourceBelowWarn: frame.sourceBelowWarn, }; }