import { writable } from 'svelte/store'; import type { BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut, } from './capture.types'; import { DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn, } from './capture.types'; import { loadDraft, saveDraft, clearDraft } from '../draft/idb'; import { measure } from '../measure/measure'; import { orientedRect } from '../geometry/rect'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; const DEBOUNCE_MS = 2000; const BUSY_MS = 300; export interface DraftStore { loadDraft(): Promise; saveDraft(d: Draft): Promise; clearDraft(): Promise; } export interface CaptureSession { capture: import('svelte/store').Readable; busy: import('svelte/store').Readable; resumeProbe(): Promise; continueDraft(): void; startFresh(): void; /** Append a photo to the batch; stays on batch. */ addToBatch(file: File): Promise; /** Alias: same as addToBatch (no longer jumps to mask). */ addPhoto(file: File): Promise; removeFromBatch(index: number): void; /** Advance when batch has ≥1 photo → pickCalibration. */ continueBatch(): void; /** User picks which batch photo is the calibration reference → Mask on that Source. */ pickCalibration(index: number): void; retakes(): void; setSensitivity(n: number): void; setSheet(s: Sheet): void; approveMask(): void; setAxisAngle(deg: number): void; approveAxis(): void; setLength(e: Eighths): void; setThickness(e: Eighths): void; calculate(): Promise; back(): void; getInventoryUrl(): string | null; dispose(): void; } export interface SessionOpts { draft?: DraftStore; /** Injectable only for deterministic tests; production uses encodeInventoryPng. */ encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; /** Injectable only for deterministic tests; production uses encodeOverlayPng. */ encodeOverlay?: (input: EncodeOverlayInput) => Promise; } const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; const batchPhotosOf = (d: Draft): Source[] => d.batch && d.batch.length > 0 ? d.batch : [d.source]; // 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(opts: SessionOpts = {}): CaptureSession { const drafts = opts.draft ?? defaultDraft; const encodeInventory = opts.encodeInventory ?? encodeInventoryPng; const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng; const capture = writable({ k: 'batch', photos: [] }); const busy = writable(false); let state: Capture = { k: 'batch', photos: [] }; let batchPhotos: Source[] = []; let calibrationIndex: number | null = null; let worker: Worker | null = null; let jobId = 0; let timer: ReturnType | null = null; let passStarted = 0; let busyTimer: ReturnType | 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) { try { worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn); worker.terminate(); } catch { /* worker unavailable */ } worker = null; } if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; } }; const makeWorker = (blob: Blob) => { if (typeof Worker === 'undefined') return; if (worker) worker.terminate(); try { worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); worker.onmessage = (e: MessageEvent) => onWorkerOut(e.data); worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn); } catch { worker = null; } }; 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 === 'batch') { if (state.photos.length === 0) return null; return { version: 1, stage: 'batch', batch: state.photos, source: state.photos[0], maskParams: DEFAULT_MASK_PARAMS, mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } if (state.k === 'pickCalibration') { return { version: 1, stage: 'pickCalibration', batch: state.photos, source: state.photos[0], maskParams: DEFAULT_MASK_PARAMS, mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } const calIdx = calibrationIndex ?? undefined; if (state.k === 'axis') { return { version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source], calibrationIndex: calIdx, source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } if (state.k === 'measure') { const { inventoryPng: _drop, overlayPng: _dropOverlay, ...rest } = state.results ?? ({} as Measures); return { version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], calibrationIndex: calIdx, source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, overlayPng: state.results?.overlayPng ?? null, batchApply: state.batchApply, }; } if (state.k === 'mask' && state.overlay) { return { version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], calibrationIndex: calIdx, source: state.source, maskParams: state.overlay.params, mask: strip(state.overlay), lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } // Mask before first overlay settles: still persist calibration choice + batch. if (state.k === 'mask') { return { version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], calibrationIndex: calIdx, source: state.source, maskParams: state.control, mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } return null; }; const persist = async () => { const d = draftFromState(); if (d) await drafts.saveDraft(d); else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft(); }; // Re-derive the mask bitset from a stored photo + frozen params. The decoded // dimensions are returned because batch Sources begin with dimensions unknown. const rederiveMaskDetailed = ( source: Source, params: MaskParams, stub: Omit, ): Promise<{ mask: Mask; widthPx: number; heightPx: number }> => new Promise((resolve, reject) => { if (typeof Worker === 'undefined') { reject(new Error('Worker unavailable')); return; } const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); let id = 0; let widthPx = source.widthPx; let heightPx = source.heightPx; const fail = (reason: string) => { w.terminate(); reject(new Error(reason)); }; w.onmessage = (e: MessageEvent) => { const msg = e.data; if (msg.kind === 'decoded') { widthPx = msg.w; heightPx = msg.h; 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({ widthPx, heightPx, mask: { 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 rederiveMask = ( source: Source, params: MaskParams, stub: Omit, ): Promise => rederiveMaskDetailed(source, params, stub).then((r) => r.mask); // Legacy drafts (pre-axisBbox, with heading) carry no usable axis state; // fall back to the mask's own bbox. const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox; const restoreFromDraft = async (d: Draft) => { batchPhotos = batchPhotosOf(d); calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null; if (d.stage === 'batch') { teardownWorker(); calibrationIndex = null; set({ k: 'batch', photos: [...batchPhotos] }); return; } if (d.stage === 'pickCalibration') { teardownWorker(); calibrationIndex = null; set({ k: 'pickCalibration', photos: [...batchPhotos] }); return; } 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, axis: { bbox: draftAxisBbox(d) } }); return; } if (d.inventoryPng) { if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(d.inventoryPng); } set({ k: 'measure', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) }, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, results: d.results ? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob(), overlayPng: d.overlayPng ?? undefined, } : null, batchApply: d.batchApply, }); } catch { set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true }); makeWorker(source.blob); } }; const enterMaskOnSource = (source: Source) => { teardownWorker(); set({ k: 'mask', source, control: { ...DEFAULT_MASK_PARAMS }, overlay: null, failed: false, }); makeWorker(source.blob); void persist(); }; return { capture, busy, async resumeProbe() { if (resumed) return; resumed = true; const d = await drafts.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(); batchPhotos = []; calibrationIndex = null; await drafts.clearDraft(); set({ k: 'batch', photos: [] }); }, async addToBatch(file: File) { if (state.k !== 'batch') return; const photo: Source = { widthPx: 0, heightPx: 0, blob: file }; batchPhotos = [...state.photos, photo]; set({ k: 'batch', photos: batchPhotos }); await persist(); }, async addPhoto(file: File) { // M2: addPhoto is an alias for addToBatch (no single-photo-only shortcut). if (state.k !== 'batch') { teardownWorker(); batchPhotos = []; set({ k: 'batch', photos: [] }); } if (state.k !== 'batch') return; const photo: Source = { widthPx: 0, heightPx: 0, blob: file }; batchPhotos = [...state.photos, photo]; set({ k: 'batch', photos: batchPhotos }); await persist(); }, removeFromBatch(index: number) { if (state.k !== 'batch') return; if (index < 0 || index >= state.photos.length) return; batchPhotos = state.photos.filter((_, i) => i !== index); set({ k: 'batch', photos: batchPhotos }); void persist(); }, continueBatch() { if (state.k !== 'batch' || state.photos.length === 0) return; batchPhotos = [...state.photos]; calibrationIndex = null; teardownWorker(); set({ k: 'pickCalibration', photos: [...batchPhotos] }); void persist(); }, // #61: enter M1 Mask on the chosen calibration Source only — never loop batch. pickCalibration(index: number) { if (state.k !== 'pickCalibration') return; if (index < 0 || index >= state.photos.length) return; batchPhotos = [...state.photos]; calibrationIndex = index; enterMaskOnSource(batchPhotos[index]); }, retakes() { teardownWorker(); calibrationIndex = null; // Return to the batch (photos kept) so the user can add/replace before continuing. set({ k: 'batch', photos: [...batchPhotos] }); void persist(); }, 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(); }, // #61: Axis uses the same calibration source; draft.batch siblings stay untouched. approveMask() { if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; teardownWorker(); const overlay = state.overlay; set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } }); void persist(); }, setAxisAngle(deg: number) { if (state.k !== 'axis') return; const a = ((Math.round(deg) % 180) + 180) % 180; set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } }); void persist(); }, approveAxis() { if (state.k !== 'axis') return; set({ k: 'measure', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox }, 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(); }, // #62: Calculate the calibration photo, then apply its frozen mask params // to siblings. This stays in Measure; siblings never enter Mask controls. async calculate() { if (state.k !== 'measure') return; const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; if (lengthEighths === 0 || thicknessEighths === 0) return; const bitmap = await decodeOrientedBitmap(source.blob); const base = measure( approvedMask.bitset, source.widthPx, source.heightPx, axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, ); let pngBlob: Blob; let belowFloor = false; try { const r = await encodeInventory({ bitmap, mask: approvedMask, source }); pngBlob = r.blob; belowFloor = r.sourceBelowFloor; } catch { // CAPTURE.md: inventory is image/png even when encode is unavailable. pngBlob = new Blob([], { type: 'image/png' }); } bitmap.close(); if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(pngBlob); // #63: second copy of the calibration inventory PNG with dimension overlays. // Keep clean inventoryPng untouched; never stash overlay into batchApply[cal]. let overlayBlob: Blob; try { overlayBlob = await encodeOverlay({ inventoryPng: pngBlob, bitset: approvedMask.bitset, widthPx: source.widthPx, heightPx: source.heightPx, bbox: axis.bbox, scale: base.scale, lengthIn: eighthsToIn(lengthEighths), lengthLabel: fmtEighths(lengthEighths), slabBounds: approvedMask.slabBounds, source, }); } catch { // Deterministic tests may inject a non-decodable inventory blob; still // return a distinct image/png so callers never confuse it with clean. overlayBlob = new Blob(['overlay'], { type: 'image/png' }); } const results: Measures = { ...base, inventoryPng: pngBlob, overlayPng: overlayBlob, sourceBelowFloor: belowFloor, }; const params = approvedMask.params; const calIndex = calibrationIndex != null && calibrationIndex >= 0 ? calibrationIndex : 0; const batch = batchPhotos.length ? batchPhotos : [source]; const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : { status: 'error', message: 'Batch photo was not processed', })); // Process serially so each worker owns exactly one sibling photo and a // failing decode/mask/encode cannot prevent later siblings from running. for (let index = 0; index < batch.length; index++) { if (index === calIndex) continue; const photo = batch[index]; try { const derived = await rederiveMaskDetailed(photo, params, { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX, }); if (derived.mask.slabPx === 0) { throw new Error('empty mask: no slab pixels detected'); } const siblingSource: Source = { ...photo, widthPx: derived.widthPx, heightPx: derived.heightPx, }; const siblingBitmap = await decodeOrientedBitmap(photo.blob); let encoded: { blob: Blob; sourceBelowFloor: boolean }; try { encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource }); } finally { siblingBitmap.close(); } if (encoded.sourceBelowFloor) { throw new Error('PNG size floor: shorter side is below 800 px'); } batchApply[index] = { status: 'ok', inventoryPng: encoded.blob }; } catch (error) { const message = error instanceof Error ? error.message : 'unable to process photo'; batchApply[index] = { status: 'error', message }; } } set({ ...state, results, batchApply }); void persist(); }, back() { if (state.k === 'pickCalibration') { calibrationIndex = null; set({ k: 'batch', photos: [...batchPhotos] }); void persist(); } else if (state.k === 'mask') { teardownWorker(); calibrationIndex = null; set({ k: 'pickCalibration', photos: [...batchPhotos] }); void persist(); } 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, axis: { bbox: state.axis.bbox } }); } }, 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 = { 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}"`; }