From af7d4c9d3cf03ec0e9102ad6496776f12f24b46c Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 20:56:05 -0700 Subject: [PATCH] fix(capture): punch tape cutout in-place; drop maskedPreview swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ca983c4 painted mask via offscreen createImageData→drawImage and swapped Measure to an async alpha PNG. On some clients that left the Zoom/Pan stage showing only the CSS checkerboard with no slab pixels. Paint the approved mask by mutating the photo bitmap in place (same as Mask/SessionCapture): tape mode clears non-slab alpha so the stage grid shows through; bbox mode still paints opaque checkerboard. Measure binds source+bitset like Axis — no createMaskedPreviewBlob race. onMount redraw covers the canvas-bind tick race. --- frontend/src/capture/MeasurePanel.svelte | 50 +++---------------- frontend/src/capture/PhotoStage.svelte | 63 +++++++++++++++++------- 2 files changed, 51 insertions(+), 62 deletions(-) diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index dc52f7b..787819a 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -8,7 +8,6 @@ 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 { @@ -44,40 +43,6 @@ $: 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[] = []; @@ -128,7 +93,6 @@ }); onDestroy(() => { - previewGeneration += 1; for (const u of lineupUrls) URL.revokeObjectURL(u); }); @@ -228,12 +192,14 @@ {/if}

- + - {#if previewError} -

{previewError}

- {/if}
@@ -451,7 +414,6 @@ .mode-btn.active { background: #00e5ff; color: #002; } .mode-hint { margin: 0 0 10px; } .nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; } - .preview-err { color: #ffb4a2; font-size: 0.85rem; margin: 8px 0 0; } .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/capture/PhotoStage.svelte b/frontend/src/capture/PhotoStage.svelte index d40bf2c..3867365 100644 --- a/frontend/src/capture/PhotoStage.svelte +++ b/frontend/src/capture/PhotoStage.svelte @@ -70,28 +70,52 @@ const paintOverlays = (ctx: CanvasRenderingContext2D) => { if (!canvas) return; - // Mask checkerboard is valid in tape mode too (source-grid, moves with the photo). - // Bbox/axis arrow stay bbox-mode only — tape uses the fixed SVG fixture instead. + // Mutate the photo bitmap in place (same approach as Mask/SessionCapture). + // Offscreen createImageData→drawImage can fail to composite on some WebKit + // builds, leaving only the checkerboard with the slab alpha wiped. + // Tape mode: punch non-slab to alpha 0 so the CSS stage checkerboard shows. + // Bbox mode: paint opaque checkerboard (no CSS grid behind the stage). if (maskBitset && maskW > 0 && maskH > 0) { - const off = document.createElement('canvas'); - off.width = maskW; - off.height = maskH; - const octx = off.getContext('2d')!; - const d = octx.createImageData(maskW, maskH); + const cw = canvas.width; + const ch = canvas.height; + const img = ctx.getImageData(0, 0, cw, ch); + const px = img.data; const tile = 8; - for (let p = 0; p < maskW * maskH; p++) { - if (!maskBitset[p]) { - const x = p % maskW; - const y = (p / maskW) | 0; - const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2; - d.data[p * 4] = light ? 220 : 180; - d.data[p * 4 + 1] = light ? 220 : 180; - d.data[p * 4 + 2] = light ? 220 : 180; - d.data[p * 4 + 3] = 220; + const punch = mode === 'tape'; + const matched = maskW === cw && maskH === ch; + const pixels = matched ? Math.min(maskW * maskH, (px.length / 4) | 0) : 0; + if (matched) { + for (let p = 0; p < pixels; p++) { + if (maskBitset[p]) continue; // slab: keep photo + const i = p * 4; + if (punch) { + px[i + 3] = 0; + } else { + const x = p % maskW; + const y = (p / maskW) | 0; + const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2; + const g = light ? 220 : 180; + px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255; + } + } + } else { + for (let y = 0; y < ch; y++) { + const sy = Math.min(maskH - 1, Math.round((y / ch) * maskH)); + for (let x = 0; x < cw; x++) { + const sx = Math.min(maskW - 1, Math.round((x / cw) * maskW)); + if (maskBitset[sy * maskW + sx]) continue; + const i = (y * cw + x) * 4; + if (punch) { + px[i + 3] = 0; + } else { + const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2; + const g = light ? 220 : 180; + px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255; + } + } } } - octx.putImageData(d, 0, 0); - ctx.drawImage(off, 0, 0, canvas.width, canvas.height); + ctx.putImageData(img, 0, 0); } if (mode === 'tape') return; if (bbox && bbox.corners.length === 4) { @@ -312,6 +336,9 @@ onMount(() => { if (stage) parentW = stage.clientWidth; updateVbScale(); + // Canvas bind:this may land after the first reactive tick().then(redraw) + // early-returned on !canvas — paint once mount has the element. + void redraw(); const ro = new ResizeObserver((entries) => { const e = entries[0]; if (!e) return;