feat(photos): pick calibration photo before Mask (#60)

Add pickCalibration state/UI over batch thumbs. Continue from batch opens
pick (including single-photo confirm). User pick enters M1 Mask on that
Source only; draft stores calibrationIndex. Removes first-photo proxy.
This commit is contained in:
2026-09-15 21:11:07 +00:00
parent 4fa8126471
commit d18b96c10b
4 changed files with 141 additions and 10 deletions
+62 -7
View File
@@ -30,8 +30,10 @@ export interface CaptureSession {
/** 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. */
/** 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;
@@ -63,6 +65,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
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<typeof setTimeout> | null = null;
@@ -173,9 +176,25 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
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,
@@ -185,6 +204,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const { inventoryPng: _drop, ...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,
@@ -195,11 +215,22 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
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;
};
@@ -252,11 +283,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
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();
@@ -289,9 +328,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
};
const enterMaskFromBatch = () => {
if (batchPhotos.length === 0) return;
const source = batchPhotos[0];
const enterMaskOnSource = (source: Source) => {
teardownWorker();
set({
k: 'mask',
@@ -324,6 +361,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'resume') return;
teardownWorker();
batchPhotos = [];
calibrationIndex = null;
await drafts.clearDraft();
set({ k: 'batch', photos: [] });
},
@@ -361,11 +399,23 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
continueBatch() {
if (state.k !== 'batch' || state.photos.length === 0) return;
batchPhotos = [...state.photos];
enterMaskFromBatch();
calibrationIndex = null;
teardownWorker();
set({ k: 'pickCalibration', photos: [...batchPhotos] });
void persist();
},
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();
@@ -448,10 +498,15 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
},
back() {
if (state.k === 'mask') {
teardownWorker();
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;