diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte
index a07c605..2befc41 100644
--- a/frontend/src/capture/MeasurePanel.svelte
+++ b/frontend/src/capture/MeasurePanel.svelte
@@ -8,6 +8,7 @@
import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
+ import { createMaskedPreviewBlob } from '../png/maskedPreview';
import { getTaxonomy } from '../api/slabs';
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
import {
@@ -41,6 +42,40 @@
$: tape = state.axis.tape;
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
+ let maskedPreview: Blob | null = null;
+ let previewError = '';
+ let previewGeneration = 0;
+ let previewSource: Blob | null = null;
+ let previewMask: Uint8Array | null = null;
+ let previewW = 0;
+ let previewH = 0;
+
+ // Build the tape photo once from the oriented source plus the approved mask.
+ // Never fall back to the raw source here: tape alignment must show the same
+ // background-removed slab footprint that was approved in Mask.
+ $: {
+ const sourceBlob = state.source.blob;
+ const bitset = state.approvedMask.bitset;
+ const width = state.source.widthPx;
+ const height = state.source.heightPx;
+ if (sourceBlob !== previewSource || bitset !== previewMask || width !== previewW || height !== previewH) {
+ previewSource = sourceBlob;
+ previewMask = bitset;
+ previewW = width;
+ previewH = height;
+ maskedPreview = null;
+ previewError = '';
+ const generation = ++previewGeneration;
+ void createMaskedPreviewBlob(sourceBlob, bitset)
+ .then((blob) => {
+ if (generation === previewGeneration) maskedPreview = blob;
+ })
+ .catch(() => {
+ if (generation === previewGeneration) previewError = 'Could not build the background-removed preview.';
+ });
+ }
+ }
+
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
@@ -84,6 +119,7 @@
});
onDestroy(() => {
+ previewGeneration += 1;
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
@@ -154,16 +190,19 @@
Orient the slab on the fixed tape line, then enter length, thickness, and species.
@@ -344,6 +383,7 @@
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
+ .preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
.ctl-label .sub { font-size: 0.7rem; color: #777; }
diff --git a/frontend/src/png/maskedPreview.test.ts b/frontend/src/png/maskedPreview.test.ts
new file mode 100644
index 0000000..6315f60
--- /dev/null
+++ b/frontend/src/png/maskedPreview.test.ts
@@ -0,0 +1,14 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { applyMaskAlpha } from './maskedPreview';
+
+test('applyMaskAlpha keeps slab pixels and clears the background', () => {
+ const rgba = new Uint8ClampedArray([
+ 1, 2, 3, 255,
+ 4, 5, 6, 255,
+ 7, 8, 9, 255,
+ ]);
+ const out = applyMaskAlpha(rgba, new Uint8Array([1, 0]));
+ assert.deepEqual([...out], [1, 2, 3, 255, 4, 5, 6, 0, 7, 8, 9, 0]);
+ assert.deepEqual([...rgba], [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255]);
+});
diff --git a/frontend/src/png/maskedPreview.ts b/frontend/src/png/maskedPreview.ts
new file mode 100644
index 0000000..07b872a
--- /dev/null
+++ b/frontend/src/png/maskedPreview.ts
@@ -0,0 +1,52 @@
+import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
+
+/**
+ * Apply a source-grid mask to decoded RGBA pixels without changing their
+ * dimensions. The transparent pixels remain in the full source frame so the
+ * tape transform and all measurement geometry keep using source coordinates.
+ */
+export function applyMaskAlpha(
+ rgba: Uint8ClampedArray,
+ bitset: Uint8Array,
+): Uint8ClampedArray {
+ const out = new Uint8ClampedArray(rgba);
+ const pixels = out.length / 4;
+ for (let p = 0; p < pixels; p++) {
+ if (p >= bitset.length || !bitset[p]) out[p * 4 + 3] = 0;
+ }
+ return out;
+}
+
+/**
+ * Encode an EXIF-oriented source photo with the approved mask as alpha.
+ * This is display-only; the original source blob and mask stay in session
+ * state for geometry, measurement, and inventory encoding.
+ */
+export async function createMaskedPreviewBlob(
+ source: Blob,
+ bitset: Uint8Array,
+): Promise {
+ const bitmap = await decodeOrientedBitmap(source);
+ try {
+ const width = bitmap.width;
+ const height = bitmap.height;
+ const hasOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';
+ const canvas: OffscreenCanvas | HTMLCanvasElement = hasOffscreenCanvas
+ ? new OffscreenCanvas(width, height)
+ : Object.assign(document.createElement('canvas'), { width, height });
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
+ if (!ctx) throw new Error('Could not create preview canvas');
+ ctx.drawImage(bitmap, 0, 0);
+ const image = ctx.getImageData(0, 0, width, height);
+ image.data.set(applyMaskAlpha(image.data, bitset));
+ ctx.putImageData(image, 0, 0);
+ if (hasOffscreenCanvas) {
+ return await (canvas as OffscreenCanvas).convertToBlob({ type: 'image/png' });
+ }
+ return await new Promise((resolve, reject) => {
+ (canvas as HTMLCanvasElement).toBlob((blob: Blob | null) => (blob ? resolve(blob) : reject(new Error('Could not encode preview'))), 'image/png');
+ });
+ } finally {
+ bitmap.close();
+ }
+}