feat(capture): ship Milestone 1 browser capture app

Why
Phone and PC need a single capture flow that ends at inventory PNG and
board-foot numbers with no backend, inference, or Woo create.

Scope
Add frontend/ Vite+Svelte capture UI with Resume, Add photo, Mask,
Length axis, and Measure. Mask runs in a worker with Auto/Green/Black
and a 2.0s sensitivity debounce. Measure computes scale, widths, sqft,
bdft, and a 3:4 alpha inventory PNG. Persist one device draft in
IndexedDB. Serve the static build from frontend/Dockerfile via nginx
(deploy/nginx-m1.conf). No FastAPI.

Verification
sudo docker build -f frontend/Dockerfile -t slabuploader-m1 .
sudo docker run -d --name slabuploader-m1 -p 0.0.0.0:18080:80 slabuploader-m1
curl http://127.0.0.1:18080/ returns 200
Playwright: synthetic green-sheet photo through Calculate shows sqft/bdft
This commit is contained in:
2026-09-14 07:53:39 +00:00
parent 3bae76cea3
commit d86fc93ed6
24 changed files with 2969 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import type { ABox } from '../capture/capture.types';
const FLOOR = 1600; // shorter side, px
const FILL = 0.8; // 80% fill at widest and tallest points
const MARGIN = 0.08; // crop padding around slab bounds
const ASPECT_W = 3;
const ASPECT_H = 4;
// 3:4 portrait RGBA PNG: slab centered at 80% fill (widest and tallest points),
// shorter side >= 1600 when the source can supply it. No upscale: an undersized
// source yields sourceBelowFloor = true (warn + require a new photo).
export async function encodeInventoryPng(
bitmap: ImageBitmap,
slab: ABox,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
const cw = Math.round(slab.w * (1 + 2 * MARGIN));
const ch = Math.round(slab.h * (1 + 2 * MARGIN));
const sx = Math.max(0, Math.round(slab.x - slab.w * MARGIN));
const sy = Math.max(0, Math.round(slab.y - slab.h * MARGIN));
const srcW = Math.min(bitmap.width - sx, Math.max(1, cw));
const srcH = Math.min(bitmap.height - sy, Math.max(1, ch));
const belowFloor = Math.min(srcW, srcH) < FLOOR;
// Portrait 3:4: width is the shorter side. Meet the floor when source allows.
const outW = belowFloor ? Math.max(1, Math.min(srcW, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, outW, outH);
const maxW = outW * FILL;
const maxH = outH * FILL;
const scale = Math.min(maxW / srcW, maxH / srcH);
const dw = srcW * scale;
const dh = srcH * scale;
ctx.drawImage(bitmap, sx, sy, srcW, srcH, (outW - dw) / 2, (outH - dh) / 2, dw, dh);
const blob = await canvas.convertToBlob({ type: 'image/png' });
return { blob, sourceBelowFloor: belowFloor };
}