From ca983c4dc115090ecf7a2d227ab276d17751d41b Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 20:47:19 -0700 Subject: [PATCH] fix(capture): show bg-removed cutout on Measure Zoom/Pan immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tape-mode PhotoStage skipped mask checkerboard painting, so Zoom/Pan depended on async createMaskedPreviewBlob — a hang/fail left the stage without a cutout. Paint mask overlay in tape mode, mount PhotoStage on source+bitset right away (swap to alpha PNG when ready), checkerboard stage backdrop, and prefer HTML canvas for the preview encode. --- frontend/src/capture/MeasurePanel.svelte | 30 +++++++++++++----------- frontend/src/capture/PhotoStage.svelte | 16 +++++++++++-- frontend/src/png/maskedPreview.ts | 26 +++++++++++++------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index 1ad1bc4..dc52f7b 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -228,19 +228,21 @@ {/if}

- {#if maskedPreview} - - {:else} -
- {previewError || 'Preparing background-removed preview…'} -
+ + + {#if previewError} +

{previewError}

{/if}
@@ -449,7 +451,7 @@ .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-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; } + .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 1fc3d85..d40bf2c 100644 --- a/frontend/src/capture/PhotoStage.svelte +++ b/frontend/src/capture/PhotoStage.svelte @@ -69,7 +69,9 @@ }; const paintOverlays = (ctx: CanvasRenderingContext2D) => { - if (!canvas || mode === 'tape') return; + 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. if (maskBitset && maskW > 0 && maskH > 0) { const off = document.createElement('canvas'); off.width = maskW; @@ -91,6 +93,7 @@ octx.putImageData(d, 0, 0); ctx.drawImage(off, 0, 0, canvas.width, canvas.height); } + if (mode === 'tape') return; if (bbox && bbox.corners.length === 4) { const c = bbox.corners; ctx.strokeStyle = '#00e5ff'; @@ -392,7 +395,16 @@ .tape-stage { width: 100%; outline: none; } .tw { position: relative; width: 100%; aspect-ratio: 3 / 4; - background: #111; border: 1px solid #333; border-radius: 8px; overflow: hidden; + /* Checkerboard so bg-removed / transparent pixels read as cutout (same SoT as Mask). */ + background-color: #eee; + background-image: + linear-gradient(45deg, #ccc 25%, transparent 25%), + linear-gradient(-45deg, #ccc 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ccc 75%), + linear-gradient(-45deg, transparent 75%, #ccc 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + border: 1px solid #333; border-radius: 8px; overflow: hidden; touch-action: none; user-select: none; cursor: grab; } .tw.dragging { cursor: grabbing; } diff --git a/frontend/src/png/maskedPreview.ts b/frontend/src/png/maskedPreview.ts index 07b872a..dd6a96d 100644 --- a/frontend/src/png/maskedPreview.ts +++ b/frontend/src/png/maskedPreview.ts @@ -30,22 +30,30 @@ export async function createMaskedPreviewBlob( 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 }); + if (bitset.length < width * height) { + throw new Error(`Mask bitset length ${bitset.length} < ${width * height}`); + } + // Prefer HTMLCanvasElement on the window thread — OffscreenCanvas.convertToBlob + // has hung / failed on some WebKit builds; worker code can still use Offscreen. + const useHtml = typeof document !== 'undefined'; + const canvas: OffscreenCanvas | HTMLCanvasElement = useHtml + ? Object.assign(document.createElement('canvas'), { width, height }) + : new OffscreenCanvas(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' }); + if (useHtml) { + 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', + ); + }); } - 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'); - }); + return await (canvas as OffscreenCanvas).convertToBlob({ type: 'image/png' }); } finally { bitmap.close(); }