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
+9 -6
View File
@@ -4,7 +4,7 @@
import { createCaptureSession, type CaptureSession } from './capture/session'; import { createCaptureSession, type CaptureSession } from './capture/session';
import type { Capture } from './capture/capture.types'; import type { Capture } from './capture/capture.types';
import Resume from './capture/Resume.svelte'; 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 MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte'; import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte';
@@ -12,7 +12,7 @@
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession(); const session: CaptureSession = createCaptureSession();
let cap: Capture = { k: 'addPhoto' }; let cap: Capture = { k: 'batch', photos: [] };
let busy = false; let busy = false;
let pngUrl: string | null = null; let pngUrl: string | null = null;
@@ -24,8 +24,6 @@
busy = b; busy = b;
}); });
const pick = (f: File) => void session.addPhoto(f);
onMount(() => { onMount(() => {
void session.resumeProbe(); void session.resumeProbe();
}); });
@@ -49,8 +47,13 @@
<main class="app"> <main class="app">
{#if cap.k === 'resume'} {#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} /> <Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'addPhoto'} {:else if cap.k === 'batch'}
<AddPhoto onPicked={pick} /> <BatchPhotos
photos={cap.photos}
onAdd={(f) => void session.addToBatch(f)}
onRemove={session.removeFromBatch}
onContinue={session.continueBatch}
/>
{:else if cap.k === 'mask'} {:else if cap.k === 'mask'}
<MaskPanel <MaskPanel
state={cap} state={cap}
-39
View File
@@ -1,39 +0,0 @@
<script lang="ts">
export let onPicked: (f: File) => void;
const pick = (e: Event) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) onPicked(f);
};
</script>
<section class="panel">
<h1>Add photo</h1>
<ul class="setup">
<li>Sheet is green or black, fills the frame around the slab.</li>
<li>Slab fully on the sheet, nothing on top of it.</li>
<li>Top-down, lens parallel to the face.</li>
<li>Slab length along the long side of the picture.</li>
</ul>
<div class="pick">
<label class="pick-btn">Take photo
<input type="file" accept="image/*" capture="environment" on:change={pick} />
</label>
<label class="pick-btn">Choose from gallery
<input type="file" accept="image/*" on:change={pick} />
</label>
</div>
</section>
<style>
.panel { padding: 24px 16px; color: #eee; }
h1 { font-size: 1.4rem; }
.setup { color: #bbb; line-height: 1.6; font-size: 0.95rem; }
.pick { margin-top: 20px; display: flex; flex-direction: column; gap: 12px; }
.pick-btn {
background: #00e5ff; color: #002; border: 0; border-radius: 8px;
padding: 12px 20px; font-size: 1rem; font-weight: 600;
text-align: center; cursor: pointer;
}
.pick-btn input[type='file'] { display: none; }
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Source } from './capture.types';
export let photos: Source[];
export let onAdd: (f: File) => void;
export let onRemove: (index: number) => void;
export let onContinue: () => void;
let urls: string[] = [];
$: {
for (const u of urls) URL.revokeObjectURL(u);
urls = photos.map((p) => URL.createObjectURL(p.blob));
}
onDestroy(() => {
for (const u of urls) URL.revokeObjectURL(u);
});
const pick = (e: Event) => {
const input = e.target as HTMLInputElement;
const list = input.files;
if (!list || list.length === 0) return;
// Support multi-select from gallery when the browser allows it.
for (let i = 0; i < list.length; i++) onAdd(list[i]);
input.value = '';
};
$: canContinue = photos.length >= 1;
</script>
<section class="panel">
<h1>Batch photos</h1>
<ul class="setup">
<li>Sheet is green or black, fills the frame around the slab.</li>
<li>Slab fully on the sheet, nothing on top of it.</li>
<li>Top-down reference shot: lens parallel to the face.</li>
<li>Slab length along the long side of the picture.</li>
<li>Add one or more photos of the same slab, then Continue.</li>
</ul>
{#if photos.length > 0}
<ul class="thumbs" aria-label="Batch photos">
{#each photos as _p, i (urls[i])}
<li class="thumb">
<img src={urls[i]} alt={`Batch photo ${i + 1}`} />
<button type="button" class="rm" aria-label={`Remove photo ${i + 1}`} on:click={() => onRemove(i)}>Remove</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">No photos yet. Add at least one to continue.</p>
{/if}
<div class="pick">
<label class="pick-btn">Take photo
<input type="file" accept="image/*" capture="environment" on:change={pick} />
</label>
<label class="pick-btn">Choose from gallery
<input type="file" accept="image/*" multiple on:change={pick} />
</label>
</div>
<button
class="continue"
type="button"
disabled={!canContinue}
on:click={onContinue}
>Continue</button>
</section>
<style>
.panel { padding: 24px 16px; color: #eee; }
h1 { font-size: 1.4rem; }
.setup { color: #bbb; line-height: 1.6; font-size: 0.95rem; }
.empty { color: #888; margin: 16px 0; }
.thumbs {
list-style: none; padding: 0; margin: 16px 0;
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px;
}
.thumb {
background: #1e1e1e; border-radius: 8px; overflow: hidden;
display: flex; flex-direction: column;
}
.thumb img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; display: block; background: #000; }
.rm {
border: 0; background: #333; color: #ccc; padding: 8px; font-size: 0.85rem; cursor: pointer;
}
.pick { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
.pick-btn {
background: #00e5ff; color: #002; border: 0; border-radius: 8px;
padding: 12px 20px; font-size: 1rem; font-weight: 600;
text-align: center; cursor: pointer;
}
.pick-btn input[type='file'] { display: none; }
.continue {
margin-top: 20px; width: 100%; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700; cursor: pointer;
}
.continue:disabled { opacity: 0.35; cursor: not-allowed; }
</style>
+23 -5
View File
@@ -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 // 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. // 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. // 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 = export type Capture =
| { k: 'resume'; draft: Draft } | { k: 'resume'; draft: Draft }
| { k: 'addPhoto' } | { k: 'batch'; photos: Source[] }
| { | {
k: 'mask'; k: 'mask';
source: Source; source: Source;
@@ -97,13 +98,30 @@ export type Capture =
results: Measures | null; 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 // Device draft (IndexedDB): one in-progress slab. Small by design — the bitset
// and PNG are re-derived from stored data, never stored second-hand. // 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 { export interface Draft {
version: 1; version: 1;
stage: 'mask' | 'axis' | 'measure'; // last approved state stage: 'batch' | 'mask' | 'axis' | 'measure'; // last approved / persisted state
source: Source; /** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */
maskParams: MaskParams; // → re-derive mask on resume 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<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
lengthEighths: Eighths; lengthEighths: Eighths;
+133 -14
View File
@@ -2,7 +2,9 @@ import { writable } from 'svelte/store';
import type { import type {
Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
} from './capture.types'; } 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 { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure'; import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect'; import { orientedRect } from '../geometry/rect';
@@ -11,13 +13,25 @@ import { encodeInventoryPng } from '../png/inventory';
const DEBOUNCE_MS = 2000; const DEBOUNCE_MS = 2000;
const BUSY_MS = 300; const BUSY_MS = 300;
export interface DraftStore {
loadDraft(): Promise<Draft | null>;
saveDraft(d: Draft): Promise<void>;
clearDraft(): Promise<void>;
}
export interface CaptureSession { export interface CaptureSession {
capture: import('svelte/store').Readable<Capture>; capture: import('svelte/store').Readable<Capture>;
busy: import('svelte/store').Readable<boolean>; busy: import('svelte/store').Readable<boolean>;
resumeProbe(): Promise<void>; resumeProbe(): Promise<void>;
continueDraft(): void; continueDraft(): void;
startFresh(): 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>; 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; retakes(): void;
setSensitivity(n: number): void; setSensitivity(n: number): void;
setSheet(s: Sheet): void; setSheet(s: Sheet): void;
@@ -32,12 +46,23 @@ export interface CaptureSession {
dispose(): void; 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. // The one writer. Owns the worker port, the single debounce timer, and the draft.
// Every transition to an approved branch persists; no explicit "save". // Every transition to an approved branch persists; no explicit "save".
export function createCaptureSession(): CaptureSession { export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const capture = writable<Capture>({ k: 'addPhoto' }); const drafts = opts.draft ?? defaultDraft;
const capture = writable<Capture>({ k: 'batch', photos: [] });
const busy = writable(false); const busy = writable(false);
let state: Capture = { k: 'addPhoto' }; let state: Capture = { k: 'batch', photos: [] };
let batchPhotos: Source[] = [];
let worker: Worker | null = null; let worker: Worker | null = null;
let jobId = 0; let jobId = 0;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
@@ -57,8 +82,12 @@ export function createCaptureSession(): CaptureSession {
timer = null; timer = null;
} }
if (worker) { if (worker) {
try {
worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn); worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
worker.terminate(); worker.terminate();
} catch {
/* worker unavailable */
}
worker = null; worker = null;
} }
if (pngUrl) { if (pngUrl) {
@@ -68,10 +97,15 @@ export function createCaptureSession(): CaptureSession {
}; };
const makeWorker = (blob: Blob) => { const makeWorker = (blob: Blob) => {
if (typeof Worker === 'undefined') return;
if (worker) worker.terminate(); if (worker) worker.terminate();
try {
worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (e: MessageEvent<WorkerOut>) => onWorkerOut(e.data); worker.onmessage = (e: MessageEvent<WorkerOut>) => onWorkerOut(e.data);
worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn); worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn);
} catch {
worker = null;
}
}; };
const onWorkerOut = (msg: WorkerOut) => { const onWorkerOut = (msg: WorkerOut) => {
@@ -124,9 +158,25 @@ export function createCaptureSession(): CaptureSession {
const draftFromState = (): Draft | null => { const draftFromState = (): Draft | null => {
const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds }); 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') { if (state.k === 'axis') {
return { 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, mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
}; };
@@ -134,24 +184,38 @@ export function createCaptureSession(): CaptureSession {
if (state.k === 'measure') { if (state.k === 'measure') {
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
return { 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, mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null, results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? 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; return null;
}; };
const persist = async () => { const persist = async () => {
const d = draftFromState(); 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. // 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> => const rederiveMask = (source: Source, params: MaskParams, stub: Omit<Mask, 'bitset' | 'params'>): Promise<Mask> =>
new Promise((resolve, reject) => { 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' }); const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
let id = 0; let id = 0;
const fail = (reason: string) => { const fail = (reason: string) => {
@@ -187,6 +251,12 @@ export function createCaptureSession(): CaptureSession {
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox; const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
const restoreFromDraft = async (d: Draft) => { 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 }; const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
if (d.stage === 'mask') { if (d.stage === 'mask') {
teardownWorker(); 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 { return {
capture, capture,
busy, busy,
@@ -226,7 +311,7 @@ export function createCaptureSession(): CaptureSession {
async resumeProbe() { async resumeProbe() {
if (resumed) return; if (resumed) return;
resumed = true; resumed = true;
const d = await loadDraft(); const d = await drafts.loadDraft();
if (d) set({ k: 'resume', draft: d }); if (d) set({ k: 'resume', draft: d });
}, },
@@ -238,19 +323,52 @@ export function createCaptureSession(): CaptureSession {
async startFresh() { async startFresh() {
if (state.k !== 'resume') return; if (state.k !== 'resume') return;
teardownWorker(); teardownWorker();
await clearDraft(); batchPhotos = [];
set({ k: 'addPhoto' }); 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) { async addPhoto(file: File) {
// M2: addPhoto is an alias for addToBatch (no single-photo-only shortcut).
if (state.k !== 'batch') {
teardownWorker(); teardownWorker();
set({ k: 'mask', source: { widthPx: 0, heightPx: 0, blob: file }, control: { sheet: 'auto', sensitivity: 50 }, overlay: null, failed: false }); batchPhotos = [];
makeWorker(file); 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() { retakes() {
teardownWorker(); 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) { setSensitivity(n: number) {
@@ -332,7 +450,8 @@ export function createCaptureSession(): CaptureSession {
back() { back() {
if (state.k === 'mask') { if (state.k === 'mask') {
teardownWorker(); teardownWorker();
set({ k: 'addPhoto' }); set({ k: 'batch', photos: [...batchPhotos] });
void persist();
} else if (state.k === 'axis') { } else if (state.k === 'axis') {
// back to mask keeps the last approved mask as the live overlay // back to mask keeps the last approved mask as the live overlay
const m = state.approvedMask; const m = state.approvedMask;