1b5ad112cc
Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and includes the photo (Photos-ready + batchApply ok+warn). Block excludes siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated.
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
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<Mask, 'bitset' | 'slabBounds'>;
|
|
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
|
|
|
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,
|
|
};
|
|
}
|