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:
@@ -0,0 +1,356 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type {
|
||||
Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
} from './capture.types';
|
||||
import { eighthsToIn } from './capture.types';
|
||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||
import { measure } from '../measure/measure';
|
||||
import { encodeInventoryPng } from '../png/inventory';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
|
||||
export interface CaptureSession {
|
||||
capture: import('svelte/store').Readable<Capture>;
|
||||
busy: import('svelte/store').Readable<boolean>;
|
||||
resumeProbe(): Promise<void>;
|
||||
continueDraft(): void;
|
||||
startFresh(): void;
|
||||
addPhoto(file: File): Promise<void>;
|
||||
retakes(): void;
|
||||
setSensitivity(n: number): void;
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
rotateAxis(): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
calculate(): Promise<void>;
|
||||
back(): void;
|
||||
getInventoryUrl(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
||||
// Every transition to an approved branch persists; no explicit "save".
|
||||
export function createCaptureSession(): CaptureSession {
|
||||
const capture = writable<Capture>({ k: 'addPhoto' });
|
||||
const busy = writable(false);
|
||||
let state: Capture = { k: 'addPhoto' };
|
||||
let worker: Worker | null = null;
|
||||
let jobId = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let passStarted = 0;
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
let resumed = false;
|
||||
|
||||
const set = (next: Capture) => {
|
||||
state = next;
|
||||
capture.set(next);
|
||||
};
|
||||
|
||||
const teardownWorker = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (worker) {
|
||||
worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
}
|
||||
if (pngUrl) {
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeWorker = (blob: Blob) => {
|
||||
if (worker) worker.terminate();
|
||||
worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (e: MessageEvent<WorkerOut>) => onWorkerOut(e.data);
|
||||
worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn);
|
||||
};
|
||||
|
||||
const onWorkerOut = (msg: WorkerOut) => {
|
||||
if (msg.id < jobId - 1) return; // stale
|
||||
if (msg.kind === 'decoded') {
|
||||
if (state.k === 'mask') {
|
||||
set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } });
|
||||
}
|
||||
schedulePass();
|
||||
return;
|
||||
}
|
||||
busy.set(false);
|
||||
if (busyTimer) {
|
||||
clearTimeout(busyTimer);
|
||||
busyTimer = null;
|
||||
}
|
||||
if (state.k !== 'mask') return;
|
||||
if (msg.kind === 'failed') {
|
||||
set({ ...state, failed: true });
|
||||
return;
|
||||
}
|
||||
const mask: Mask = {
|
||||
params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity },
|
||||
slabPx: msg.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.bbox,
|
||||
slabBounds: msg.slabBounds,
|
||||
};
|
||||
set({ ...state, overlay: mask, failed: false });
|
||||
};
|
||||
|
||||
const runPass = () => {
|
||||
if (state.k !== 'mask' || !worker) return;
|
||||
passStarted = Date.now();
|
||||
busyTimer = setTimeout(() => busy.set(true), BUSY_MS);
|
||||
worker.postMessage({
|
||||
id: ++jobId,
|
||||
kind: 'mask',
|
||||
sheet: state.control.sheet,
|
||||
sensitivity: state.control.sensitivity,
|
||||
} satisfies WorkerIn);
|
||||
};
|
||||
|
||||
// The 2.0s debounce: re-armed on every control change; a sheet change
|
||||
// recomputes immediately (spec allows), so it clears the pending pass.
|
||||
const schedulePass = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(runPass, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const draftFromState = (): Draft | null => {
|
||||
const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds });
|
||||
if (state.k === 'axis') {
|
||||
return {
|
||||
version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), heading: state.heading,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
if (state.k === 'measure') {
|
||||
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
|
||||
return {
|
||||
version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), heading: state.axis.heading,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const persist = async () => {
|
||||
const d = draftFromState();
|
||||
if (d) await saveDraft(d as Draft);
|
||||
};
|
||||
|
||||
// Re-derive the mask bitset from the stored photo + params so Measure can run.
|
||||
const rederiveMask = (source: Source, params: MaskParams, stub: Omit<Mask, 'bitset' | 'params'>): Promise<Mask> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
let id = 0;
|
||||
const fail = (reason: string) => {
|
||||
w.terminate();
|
||||
reject(new Error(reason));
|
||||
};
|
||||
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
||||
const msg = e.data;
|
||||
if (msg.kind === 'decoded') {
|
||||
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'failed') {
|
||||
fail(msg.reason);
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'ready') {
|
||||
w.terminate();
|
||||
resolve({
|
||||
params,
|
||||
slabPx: msg.slabPx || stub.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
|
||||
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
|
||||
});
|
||||
}
|
||||
};
|
||||
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
|
||||
});
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
|
||||
if (d.stage === 'mask') {
|
||||
teardownWorker();
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false });
|
||||
makeWorker(source.blob);
|
||||
return;
|
||||
}
|
||||
teardownWorker();
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, heading: d.heading });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: mask.bbox, heading: d.heading },
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
|
||||
: null,
|
||||
});
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
busy,
|
||||
|
||||
async resumeProbe() {
|
||||
if (resumed) return;
|
||||
resumed = true;
|
||||
const d = await loadDraft();
|
||||
if (d) set({ k: 'resume', draft: d });
|
||||
},
|
||||
|
||||
continueDraft() {
|
||||
if (state.k !== 'resume') return;
|
||||
void restoreFromDraft(state.draft);
|
||||
},
|
||||
|
||||
async startFresh() {
|
||||
if (state.k !== 'resume') return;
|
||||
teardownWorker();
|
||||
await clearDraft();
|
||||
set({ k: 'addPhoto' });
|
||||
},
|
||||
|
||||
async addPhoto(file: File) {
|
||||
teardownWorker();
|
||||
set({ k: 'mask', source: { widthPx: 0, heightPx: 0, blob: file }, control: { sheet: 'auto', sensitivity: 50 }, overlay: null, failed: false });
|
||||
makeWorker(file);
|
||||
},
|
||||
|
||||
retakes() {
|
||||
teardownWorker();
|
||||
set({ k: 'addPhoto' });
|
||||
},
|
||||
|
||||
setSensitivity(n: number) {
|
||||
if (state.k !== 'mask') return;
|
||||
const s = Math.max(0, Math.min(100, Math.round(n)));
|
||||
set({ ...state, control: { ...state.control, sensitivity: s } });
|
||||
schedulePass();
|
||||
},
|
||||
|
||||
setSheet(s: Sheet) {
|
||||
if (state.k !== 'mask') return;
|
||||
set({ ...state, control: { ...state.control, sheet: s } });
|
||||
runPass();
|
||||
},
|
||||
|
||||
approveMask() {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.overlay, heading: 1 });
|
||||
void persist();
|
||||
},
|
||||
|
||||
rotateAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({ ...state, heading: state.heading === 1 ? -1 : 1 });
|
||||
void persist();
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.approvedMask.bbox, heading: state.heading },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
setLength(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setThickness(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
async calculate() {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await createImageBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, axis.heading, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventoryPng(bitmap, approvedMask.slabBounds);
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
pngBlob = new Blob();
|
||||
}
|
||||
bitmap.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
|
||||
set({ ...state, results });
|
||||
void persist();
|
||||
},
|
||||
|
||||
back() {
|
||||
if (state.k === 'mask') {
|
||||
teardownWorker();
|
||||
set({ k: 'addPhoto' });
|
||||
} else if (state.k === 'axis') {
|
||||
// back to mask keeps the last approved mask as the live overlay
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, heading: state.axis.heading });
|
||||
}
|
||||
},
|
||||
|
||||
getInventoryUrl() {
|
||||
return pngUrl;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
teardownWorker();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Display helper: format eighths as inches with fractions.
|
||||
export function fmtEighths(e: Eighths): string {
|
||||
const whole = Math.floor(eighthsToIn(e));
|
||||
const rem = e - whole * 8;
|
||||
if (rem === 0) return `${whole}"`;
|
||||
const frac: Record<number, string> = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' };
|
||||
const part = frac[rem] ?? `${rem}/8`;
|
||||
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user