From ff4bb0f31ef3ff2627a0abfff606fc8e267ace3f Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Tue, 15 Sep 2026 21:08:44 +0000 Subject: [PATCH] feat(photos): multi-photo batch + device draft (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace single Add photo with Batch: accumulate 1–N photos, Continue when ≥1 (M1 bridge: mask on first), persist batch blobs in the device draft, Resume Continue restores. Keeps Mask/Axis/Measure intact. --- frontend/src/App.svelte | 15 ++- frontend/src/capture/AddPhoto.svelte | 39 ------ frontend/src/capture/BatchPhotos.svelte | 102 +++++++++++++++ frontend/src/capture/capture.types.ts | 28 ++++- frontend/src/capture/session.ts | 159 +++++++++++++++++++++--- 5 files changed, 273 insertions(+), 70 deletions(-) delete mode 100644 frontend/src/capture/AddPhoto.svelte create mode 100644 frontend/src/capture/BatchPhotos.svelte diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 973b4ac..300a03c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,7 +4,7 @@ import { createCaptureSession, type CaptureSession } from './capture/session'; import type { Capture } from './capture/capture.types'; import Resume from './capture/Resume.svelte'; - import AddPhoto from './capture/AddPhoto.svelte'; + import BatchPhotos from './capture/BatchPhotos.svelte'; import MaskPanel from './capture/MaskPanel.svelte'; import AxisPanel from './capture/AxisPanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte'; @@ -12,7 +12,7 @@ const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const session: CaptureSession = createCaptureSession(); - let cap: Capture = { k: 'addPhoto' }; + let cap: Capture = { k: 'batch', photos: [] }; let busy = false; let pngUrl: string | null = null; @@ -24,8 +24,6 @@ busy = b; }); - const pick = (f: File) => void session.addPhoto(f); - onMount(() => { void session.resumeProbe(); }); @@ -49,8 +47,13 @@
{#if cap.k === 'resume'} session.continueDraft()} onFresh={() => void session.startFresh()} /> - {:else if cap.k === 'addPhoto'} - + {:else if cap.k === 'batch'} + void session.addToBatch(f)} + onRemove={session.removeFromBatch} + onContinue={session.continueBatch} + /> {:else if cap.k === 'mask'} - export let onPicked: (f: File) => void; - - const pick = (e: Event) => { - const f = (e.target as HTMLInputElement).files?.[0]; - if (f) onPicked(f); - }; - - -
-

Add photo

-
    -
  • Sheet is green or black, fills the frame around the slab.
  • -
  • Slab fully on the sheet, nothing on top of it.
  • -
  • Top-down, lens parallel to the face.
  • -
  • Slab length along the long side of the picture.
  • -
-
- - -
-
- - diff --git a/frontend/src/capture/BatchPhotos.svelte b/frontend/src/capture/BatchPhotos.svelte new file mode 100644 index 0000000..f115195 --- /dev/null +++ b/frontend/src/capture/BatchPhotos.svelte @@ -0,0 +1,102 @@ + + +
+

Batch photos

+
    +
  • Sheet is green or black, fills the frame around the slab.
  • +
  • Slab fully on the sheet, nothing on top of it.
  • +
  • Top-down reference shot: lens parallel to the face.
  • +
  • Slab length along the long side of the picture.
  • +
  • Add one or more photos of the same slab, then Continue.
  • +
+ + {#if photos.length > 0} +
    + {#each photos as _p, i (urls[i])} +
  • + {`Batch + +
  • + {/each} +
+ {:else} +

No photos yet. Add at least one to continue.

+ {/if} + +
+ + +
+ + +
+ + diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 2e61ba5..e57d0db 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -1,4 +1,4 @@ -// Domain model for the M1 capture flow. +// Domain model for the capture flow (M1 + M2 batch). // The Capture tagged union is the state machine: each branch carries exactly the // data its state can hold, so an illegal state is a type error. @@ -71,9 +71,10 @@ export interface Measures { } // The machine. 'resume' exists only while a draft exists and has not been chosen. +// M2: 'batch' replaces M1 'addPhoto' — accumulate 1–N photos before calibration. export type Capture = | { k: 'resume'; draft: Draft } - | { k: 'addPhoto' } + | { k: 'batch'; photos: Source[] } | { k: 'mask'; source: Source; @@ -97,13 +98,30 @@ export type Capture = results: Measures | null; }; +// Placeholder geometry for batch-only drafts (no mask yet). +export const EMPTY_BBOX: BBox = { + corners: [ + [0, 0], + [0, 0], + [0, 0], + [0, 0], + ], + longSidePx: 0, + angleDeg: 0, +}; +export const EMPTY_ABOX: ABox = { x: 0, y: 0, w: 0, h: 0 }; +export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 }; + // Device draft (IndexedDB): one in-progress slab. Small by design — the bitset // and PNG are re-derived from stored data, never stored second-hand. +// M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it. export interface Draft { version: 1; - stage: 'mask' | 'axis' | 'measure'; // last approved state - source: Source; - maskParams: MaskParams; // → re-derive mask on resume + stage: 'batch' | 'mask' | 'axis' | 'measure'; // last approved / persisted state + /** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */ + batch?: Source[]; + source: Source; // calibration / active photo; for stage==='batch' = photos[0] + maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch') mask: Omit; // slabPx, bbox, slabBounds axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts lengthEighths: Eighths; diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 6c62e96..c80a42c 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -2,7 +2,9 @@ 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 { + 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'; @@ -11,13 +13,25 @@ import { encodeInventoryPng } from '../png/inventory'; 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. M1 bridge: mask on first photo. */ + continueBatch(): void; retakes(): void; setSensitivity(n: number): void; setSheet(s: Sheet): void; @@ -32,12 +46,23 @@ export interface CaptureSession { dispose(): void; } +export interface SessionOpts { + draft?: DraftStore; +} + +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(): CaptureSession { - const capture = writable({ k: 'addPhoto' }); +export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { + const drafts = opts.draft ?? defaultDraft; + const capture = writable({ k: 'batch', photos: [] }); const busy = writable(false); - let state: Capture = { k: 'addPhoto' }; + let state: Capture = { k: 'batch', photos: [] }; + let batchPhotos: Source[] = []; let worker: Worker | null = null; let jobId = 0; let timer: ReturnType | null = null; @@ -57,8 +82,12 @@ export function createCaptureSession(): CaptureSession { timer = null; } if (worker) { - worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn); - worker.terminate(); + try { + worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn); + worker.terminate(); + } catch { + /* worker unavailable */ + } worker = null; } if (pngUrl) { @@ -68,10 +97,15 @@ export function createCaptureSession(): CaptureSession { }; const makeWorker = (blob: Blob) => { + if (typeof Worker === 'undefined') return; if (worker) worker.terminate(); - 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); + 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) => { @@ -124,9 +158,25 @@ export function createCaptureSession(): CaptureSession { 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 === 'axis') { return { - version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params, + version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source], + source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; @@ -134,24 +184,38 @@ export function createCaptureSession(): CaptureSession { if (state.k === 'measure') { const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); return { - version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params, + version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], + 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, }; } + if (state.k === 'mask' && state.overlay) { + return { + version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], + source: state.source, maskParams: state.overlay.params, + mask: strip(state.overlay), + lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, + }; + } return null; }; const persist = async () => { const d = draftFromState(); - if (d) await saveDraft(d as Draft); + if (d) await drafts.saveDraft(d); + 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 => 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; const fail = (reason: string) => { @@ -187,6 +251,12 @@ export function createCaptureSession(): CaptureSession { const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox; const restoreFromDraft = async (d: Draft) => { + batchPhotos = batchPhotosOf(d); + if (d.stage === 'batch') { + teardownWorker(); + set({ k: 'batch', photos: [...batchPhotos] }); + return; + } const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob }; if (d.stage === 'mask') { teardownWorker(); @@ -219,6 +289,21 @@ export function createCaptureSession(): CaptureSession { } }; + const enterMaskFromBatch = () => { + if (batchPhotos.length === 0) return; + const source = batchPhotos[0]; + teardownWorker(); + set({ + k: 'mask', + source, + control: { ...DEFAULT_MASK_PARAMS }, + overlay: null, + failed: false, + }); + makeWorker(source.blob); + void persist(); + }; + return { capture, busy, @@ -226,7 +311,7 @@ export function createCaptureSession(): CaptureSession { async resumeProbe() { if (resumed) return; resumed = true; - const d = await loadDraft(); + const d = await drafts.loadDraft(); if (d) set({ k: 'resume', draft: d }); }, @@ -238,19 +323,52 @@ export function createCaptureSession(): CaptureSession { async startFresh() { if (state.k !== 'resume') return; teardownWorker(); - await clearDraft(); - set({ k: 'addPhoto' }); + batchPhotos = []; + 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) { - teardownWorker(); - set({ k: 'mask', source: { widthPx: 0, heightPx: 0, blob: file }, control: { sheet: 'auto', sensitivity: 50 }, overlay: null, failed: false }); - makeWorker(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]; + enterMaskFromBatch(); }, retakes() { teardownWorker(); - set({ k: 'addPhoto' }); + // Return to the batch (photos kept) so the user can add/replace before continuing. + set({ k: 'batch', photos: [...batchPhotos] }); + void persist(); }, setSensitivity(n: number) { @@ -332,7 +450,8 @@ export function createCaptureSession(): CaptureSession { back() { if (state.k === 'mask') { teardownWorker(); - set({ k: 'addPhoto' }); + set({ k: 'batch', 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;