feat(photos): multi-photo batch + device draft (#59)

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.
This commit is contained in:
2026-09-15 21:08:44 +00:00
parent e919fe9a35
commit ff4bb0f31e
5 changed files with 273 additions and 70 deletions
+139 -20
View File
@@ -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<Draft | null>;
saveDraft(d: Draft): Promise<void>;
clearDraft(): Promise<void>;
}
export interface CaptureSession {
capture: import('svelte/store').Readable<Capture>;
busy: import('svelte/store').Readable<boolean>;
resumeProbe(): Promise<void>;
continueDraft(): void;
startFresh(): void;
/** Append a photo to the batch; stays on batch. */
addToBatch(file: File): Promise<void>;
/** Alias: same as addToBatch (no longer jumps to mask). */
addPhoto(file: File): Promise<void>;
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<Capture>({ k: 'addPhoto' });
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const drafts = opts.draft ?? defaultDraft;
const capture = writable<Capture>({ 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<typeof setTimeout> | 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<WorkerOut>) => 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<WorkerOut>) => 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<Mask, 'bitset' | 'params'>): Promise<Mask> =>
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;