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;