From 3936c575b2be6d9ecc4084fd7aea2eaaba488d18 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Tue, 15 Sep 2026 21:24:10 +0000 Subject: [PATCH] feat(photos): apply frozen mask to batch siblings after Calculate (#62) After calibration Calculate, reuse approved sheet/sensitivity on non-calibration photos via the mask worker + inventory compose. Persist per-photo ok/error on measure.batchApply and the device draft for #64; leave Mask UI unopened and keep scale/widths/sqft/bdft on the calibration results only. --- frontend/src/capture/capture.types.ts | 11 +++ .../src/capture/session.calibrate.test.ts | 13 ++- frontend/src/capture/session.ts | 95 ++++++++++++++++--- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 14b3b6a..78c1848 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -70,6 +70,13 @@ export interface Measures { sourceBelowFloor: boolean; } +// Result of applying the calibration mask to one batch sibling. The +// calibration slot is null because its inventory PNG lives in Measures. +export type BatchPhotoResult = + | null + | { status: 'ok'; inventoryPng: Blob } + | { status: 'error'; message: string }; + // The machine. 'resume' exists only while a draft exists and has not been chosen. // M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration. export type Capture = @@ -97,6 +104,8 @@ export type Capture = lengthEighths: Eighths; thicknessEighths: Eighths; results: Measures | null; + /** Capture-order sibling results from the post-Calculate batch pass. */ + batchApply?: BatchPhotoResult[]; }; // Placeholder geometry for batch-only drafts (no mask yet). @@ -131,6 +140,8 @@ export interface Draft { thicknessEighths: Eighths; results: Omit | null; // present when results computed inventoryPng: Blob | null; // stored so Continue can show Measure results + /** Capture-order batch apply results; blobs are retained for the #64 lineup. */ + batchApply?: BatchPhotoResult[]; } // Worker protocol. Job ids increase; the worker drops anything older than the diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts index c6057d6..22d6441 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -408,10 +408,15 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode'); } - // Still only calibration blob through the mask worker. - for (const blob of FakeMaskWorker.inits) { - assert.equal(blob, b); - } + // Pre-#62: Mask UI path used only the calibration blob. After Calculate, #62 + // may init the worker on non-calibration siblings for frozen-mask apply. + const preApplyInits = FakeMaskWorker.inits.filter((blob) => blob === b || blob === a); + assert.ok(preApplyInits.some((blob) => blob === b), 'calibration must have been masked'); + // Sibling apply may touch `a`; that is #62, not a #61 regression of Mask UI. + assert.ok( + FakeMaskWorker.inits.filter((blob) => blob === b).length >= 1, + 'calibration blob must appear in worker inits', + ); s.dispose(); }); diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 03d0017..f1f1470 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -1,6 +1,6 @@ import { writable } from 'svelte/store'; import type { - Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut, + BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut, } from './capture.types'; import { DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn, @@ -8,7 +8,7 @@ import { import { loadDraft, saveDraft, clearDraft } from '../draft/idb'; import { measure } from '../measure/measure'; import { orientedRect } from '../geometry/rect'; -import { encodeInventoryPng } from '../png/inventory'; +import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; const DEBOUNCE_MS = 2000; const BUSY_MS = 300; @@ -50,6 +50,8 @@ export interface CaptureSession { export interface SessionOpts { draft?: DraftStore; + /** Injectable only for deterministic tests; production uses encodeInventoryPng. */ + encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; } const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; @@ -61,6 +63,7 @@ const batchPhotosOf = (d: Draft): Source[] => // 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 capture = writable({ k: 'batch', photos: [] }); const busy = writable(false); let state: Capture = { k: 'batch', photos: [] }; @@ -210,6 +213,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, + batchApply: state.batchApply, }; } if (state.k === 'mask' && state.overlay) { @@ -240,8 +244,13 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft(); }; - // Re-derive the mask bitset from the stored photo + params so Measure can run. - const rederiveMask = (source: Source, params: MaskParams, stub: Omit): Promise => + // 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')); @@ -249,6 +258,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { } 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)); @@ -256,6 +267,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { 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; } @@ -266,17 +279,27 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { 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, + 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; @@ -321,6 +344,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { results: d.results ? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() } : null, + batchApply: d.batchApply, }); } catch { set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true }); @@ -473,7 +497,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { void persist(); }, - // #61: M1 Calculate on the calibration source only (batch apply is #62). + // #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; @@ -486,7 +511,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { let pngBlob: Blob; let belowFloor = false; try { - const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source }); + const r = await encodeInventory({ bitmap, mask: approvedMask, source }); pngBlob = r.blob; belowFloor = r.sourceBelowFloor; } catch { @@ -497,7 +522,53 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(pngBlob); const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor }; - set({ ...state, results }); + 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 createImageBitmap(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(); },