037a4b870a
PhotoStage used new Image() while mask worker and calculate used bare createImageBitmap; Orientation-8 matched-set JPEGs can disagree across those paths so the approved pink axis is not the overlay (ux,uy). One decodeOrientedBitmap helper always requests imageOrientation from-image (with bare fallback), wired into PhotoStage, mask.worker, and session calculate/siblings.
650 lines
22 KiB
TypeScript
650 lines
22 KiB
TypeScript
import { writable } from 'svelte/store';
|
|
import type {
|
|
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
|
} 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';
|
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
|
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
|
|
|
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 → 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;
|
|
approveMask(): void;
|
|
setAxisAngle(deg: number): void;
|
|
approveAxis(): void;
|
|
setLength(e: Eighths): void;
|
|
setThickness(e: Eighths): void;
|
|
calculate(): Promise<void>;
|
|
back(): void;
|
|
getInventoryUrl(): string | null;
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface SessionOpts {
|
|
draft?: DraftStore;
|
|
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
|
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
|
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
|
|
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
|
|
}
|
|
|
|
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(opts: SessionOpts = {}): CaptureSession {
|
|
const drafts = opts.draft ?? defaultDraft;
|
|
const encodeInventory = opts.encodeInventory ?? encodeInventoryPng;
|
|
const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng;
|
|
const capture = writable<Capture>({ k: 'batch', photos: [] });
|
|
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;
|
|
let passStarted = 0;
|
|
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let pngUrl: string | null = null;
|
|
let resumed = false;
|
|
|
|
const set = (next: Capture) => {
|
|
state = next;
|
|
capture.set(next);
|
|
};
|
|
|
|
const teardownWorker = () => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
if (worker) {
|
|
try {
|
|
worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
|
|
worker.terminate();
|
|
} catch {
|
|
/* worker unavailable */
|
|
}
|
|
worker = null;
|
|
}
|
|
if (pngUrl) {
|
|
URL.revokeObjectURL(pngUrl);
|
|
pngUrl = null;
|
|
}
|
|
};
|
|
|
|
const makeWorker = (blob: Blob) => {
|
|
if (typeof Worker === 'undefined') return;
|
|
if (worker) worker.terminate();
|
|
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) => {
|
|
if (msg.id < jobId - 1) return; // stale
|
|
if (msg.kind === 'decoded') {
|
|
if (state.k === 'mask') {
|
|
set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } });
|
|
}
|
|
schedulePass();
|
|
return;
|
|
}
|
|
busy.set(false);
|
|
if (busyTimer) {
|
|
clearTimeout(busyTimer);
|
|
busyTimer = null;
|
|
}
|
|
if (state.k !== 'mask') return;
|
|
if (msg.kind === 'failed') {
|
|
set({ ...state, failed: true });
|
|
return;
|
|
}
|
|
const mask: Mask = {
|
|
params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity },
|
|
slabPx: msg.slabPx,
|
|
bitset: msg.bitset,
|
|
bbox: msg.bbox,
|
|
slabBounds: msg.slabBounds,
|
|
};
|
|
set({ ...state, overlay: mask, failed: false });
|
|
};
|
|
|
|
const runPass = () => {
|
|
if (state.k !== 'mask' || !worker) return;
|
|
passStarted = Date.now();
|
|
busyTimer = setTimeout(() => busy.set(true), BUSY_MS);
|
|
worker.postMessage({
|
|
id: ++jobId,
|
|
kind: 'mask',
|
|
sheet: state.control.sheet,
|
|
sensitivity: state.control.sensitivity,
|
|
} satisfies WorkerIn);
|
|
};
|
|
|
|
// The 2.0s debounce: re-armed on every control change; a sheet change
|
|
// recomputes immediately (spec allows), so it clears the pending pass.
|
|
const schedulePass = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(runPass, DEBOUNCE_MS);
|
|
};
|
|
|
|
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 === '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,
|
|
};
|
|
}
|
|
if (state.k === 'measure') {
|
|
const { inventoryPng: _drop, overlayPng: _dropOverlay, ...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,
|
|
results: state.results ? rest : null,
|
|
inventoryPng: state.results?.inventoryPng ?? null,
|
|
overlayPng: state.results?.overlayPng ?? null,
|
|
batchApply: state.batchApply,
|
|
};
|
|
}
|
|
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;
|
|
};
|
|
|
|
const persist = async () => {
|
|
const d = draftFromState();
|
|
if (d) await drafts.saveDraft(d);
|
|
else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft();
|
|
};
|
|
|
|
// 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<Mask, 'bitset' | 'params'>,
|
|
): Promise<{ mask: Mask; widthPx: number; heightPx: number }> =>
|
|
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;
|
|
let widthPx = source.widthPx;
|
|
let heightPx = source.heightPx;
|
|
const fail = (reason: string) => {
|
|
w.terminate();
|
|
reject(new Error(reason));
|
|
};
|
|
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
|
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;
|
|
}
|
|
if (msg.kind === 'failed') {
|
|
fail(msg.reason);
|
|
return;
|
|
}
|
|
if (msg.kind === 'ready') {
|
|
w.terminate();
|
|
resolve({
|
|
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<Mask, 'bitset' | 'params'>,
|
|
): Promise<Mask> => 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;
|
|
|
|
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();
|
|
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false });
|
|
makeWorker(source.blob);
|
|
return;
|
|
}
|
|
teardownWorker();
|
|
try {
|
|
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
|
if (d.stage === 'axis') {
|
|
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
|
return;
|
|
}
|
|
if (d.inventoryPng) {
|
|
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
|
pngUrl = URL.createObjectURL(d.inventoryPng);
|
|
}
|
|
set({
|
|
k: 'measure', source, approvedMask: mask,
|
|
axis: { bbox: draftAxisBbox(d) },
|
|
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
|
results: d.results
|
|
? {
|
|
...d.results,
|
|
inventoryPng: d.inventoryPng ?? new Blob(),
|
|
overlayPng: d.overlayPng ?? undefined,
|
|
}
|
|
: null,
|
|
batchApply: d.batchApply,
|
|
});
|
|
} catch {
|
|
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
|
makeWorker(source.blob);
|
|
}
|
|
};
|
|
|
|
const enterMaskOnSource = (source: Source) => {
|
|
teardownWorker();
|
|
set({
|
|
k: 'mask',
|
|
source,
|
|
control: { ...DEFAULT_MASK_PARAMS },
|
|
overlay: null,
|
|
failed: false,
|
|
});
|
|
makeWorker(source.blob);
|
|
void persist();
|
|
};
|
|
|
|
return {
|
|
capture,
|
|
busy,
|
|
|
|
async resumeProbe() {
|
|
if (resumed) return;
|
|
resumed = true;
|
|
const d = await drafts.loadDraft();
|
|
if (d) set({ k: 'resume', draft: d });
|
|
},
|
|
|
|
continueDraft() {
|
|
if (state.k !== 'resume') return;
|
|
void restoreFromDraft(state.draft);
|
|
},
|
|
|
|
async startFresh() {
|
|
if (state.k !== 'resume') return;
|
|
teardownWorker();
|
|
batchPhotos = [];
|
|
calibrationIndex = null;
|
|
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) {
|
|
// 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];
|
|
calibrationIndex = null;
|
|
teardownWorker();
|
|
set({ k: 'pickCalibration', photos: [...batchPhotos] });
|
|
void persist();
|
|
},
|
|
|
|
// #61: enter M1 Mask on the chosen calibration Source only — never loop batch.
|
|
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();
|
|
},
|
|
|
|
setSensitivity(n: number) {
|
|
if (state.k !== 'mask') return;
|
|
const s = Math.max(0, Math.min(100, Math.round(n)));
|
|
set({ ...state, control: { ...state.control, sensitivity: s } });
|
|
schedulePass();
|
|
},
|
|
|
|
setSheet(s: Sheet) {
|
|
if (state.k !== 'mask') return;
|
|
set({ ...state, control: { ...state.control, sheet: s } });
|
|
runPass();
|
|
},
|
|
|
|
// #61: Axis uses the same calibration source; draft.batch siblings stay untouched.
|
|
approveMask() {
|
|
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
|
teardownWorker();
|
|
const overlay = state.overlay;
|
|
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
|
void persist();
|
|
},
|
|
|
|
setAxisAngle(deg: number) {
|
|
if (state.k !== 'axis') return;
|
|
const a = ((Math.round(deg) % 180) + 180) % 180;
|
|
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
|
void persist();
|
|
},
|
|
|
|
approveAxis() {
|
|
if (state.k !== 'axis') return;
|
|
set({
|
|
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
|
axis: { bbox: state.axis.bbox },
|
|
lengthEighths: 0, thicknessEighths: 0, results: null,
|
|
});
|
|
void persist();
|
|
},
|
|
|
|
setLength(e: Eighths) {
|
|
if (state.k !== 'measure') return;
|
|
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
|
|
void persist();
|
|
},
|
|
|
|
setThickness(e: Eighths) {
|
|
if (state.k !== 'measure') return;
|
|
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
|
|
void persist();
|
|
},
|
|
|
|
// #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;
|
|
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
|
const bitmap = await decodeOrientedBitmap(source.blob);
|
|
const base = measure(
|
|
approvedMask.bitset, source.widthPx, source.heightPx,
|
|
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
|
);
|
|
let pngBlob: Blob;
|
|
let belowFloor = false;
|
|
try {
|
|
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
|
pngBlob = r.blob;
|
|
belowFloor = r.sourceBelowFloor;
|
|
} catch {
|
|
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
|
pngBlob = new Blob([], { type: 'image/png' });
|
|
}
|
|
bitmap.close();
|
|
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
|
pngUrl = URL.createObjectURL(pngBlob);
|
|
|
|
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
|
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
|
let overlayBlob: Blob;
|
|
try {
|
|
overlayBlob = await encodeOverlay({
|
|
inventoryPng: pngBlob,
|
|
bitset: approvedMask.bitset,
|
|
widthPx: source.widthPx,
|
|
heightPx: source.heightPx,
|
|
bbox: axis.bbox,
|
|
scale: base.scale,
|
|
lengthIn: eighthsToIn(lengthEighths),
|
|
lengthLabel: fmtEighths(lengthEighths),
|
|
slabBounds: approvedMask.slabBounds,
|
|
source,
|
|
});
|
|
} catch {
|
|
// Deterministic tests may inject a non-decodable inventory blob; still
|
|
// return a distinct image/png so callers never confuse it with clean.
|
|
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
|
|
}
|
|
const results: Measures = {
|
|
...base,
|
|
inventoryPng: pngBlob,
|
|
overlayPng: overlayBlob,
|
|
sourceBelowFloor: belowFloor,
|
|
};
|
|
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 decodeOrientedBitmap(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();
|
|
},
|
|
|
|
back() {
|
|
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;
|
|
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
|
} else if (state.k === 'measure') {
|
|
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
|
}
|
|
},
|
|
|
|
getInventoryUrl() {
|
|
return pngUrl;
|
|
},
|
|
|
|
dispose() {
|
|
teardownWorker();
|
|
},
|
|
};
|
|
}
|
|
|
|
// Display helper: format eighths as inches with fractions.
|
|
export function fmtEighths(e: Eighths): string {
|
|
const whole = Math.floor(eighthsToIn(e));
|
|
const rem = e - whole * 8;
|
|
if (rem === 0) return `${whole}"`;
|
|
const frac: Record<number, string> = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' };
|
|
const part = frac[rem] ?? `${rem}/8`;
|
|
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
|
}
|