fix(capture): punch tape cutout in-place; drop maskedPreview swap
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.
This commit is contained in:
@@ -8,7 +8,6 @@
|
|||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||||
import { cropMarginFraction } from '../png/crop';
|
import { cropMarginFraction } from '../png/crop';
|
||||||
import { createMaskedPreviewBlob } from '../png/maskedPreview';
|
|
||||||
import { getTaxonomy } from '../api/slabs';
|
import { getTaxonomy } from '../api/slabs';
|
||||||
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
||||||
import {
|
import {
|
||||||
@@ -44,40 +43,6 @@
|
|||||||
$: tape = state.axis.tape;
|
$: tape = state.axis.tape;
|
||||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
$: 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 lineupUrls: string[] = [];
|
||||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||||
|
|
||||||
@@ -128,7 +93,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
previewGeneration += 1;
|
|
||||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,12 +192,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Cutout first: source + maskBitset paints checkerboard immediately (tape mode).
|
<!-- Same binding as Mask/Axis: oriented source + approved bitset.
|
||||||
When createMaskedPreviewBlob finishes, swap to alpha PNG (same footprint). -->
|
PhotoStage tape mode punches non-slab alpha so the stage CSS checkerboard
|
||||||
|
shows through — no async createMaskedPreviewBlob swap (that path could
|
||||||
|
leave an empty/transparent canvas if encode hung or composited wrong). -->
|
||||||
<PhotoStage
|
<PhotoStage
|
||||||
mode="tape"
|
mode="tape"
|
||||||
blob={maskedPreview ?? state.source.blob}
|
blob={state.source.blob}
|
||||||
maskBitset={maskedPreview ? null : state.approvedMask.bitset}
|
maskBitset={state.approvedMask.bitset}
|
||||||
{maskW}
|
{maskW}
|
||||||
{maskH}
|
{maskH}
|
||||||
{tape}
|
{tape}
|
||||||
@@ -241,9 +207,6 @@
|
|||||||
{gestureMode}
|
{gestureMode}
|
||||||
onTapeChange={onTapeChange}
|
onTapeChange={onTapeChange}
|
||||||
/>
|
/>
|
||||||
{#if previewError}
|
|
||||||
<p class="preview-err" role="status">{previewError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div>
|
<div>
|
||||||
@@ -451,7 +414,6 @@
|
|||||||
.mode-btn.active { background: #00e5ff; color: #002; }
|
.mode-btn.active { background: #00e5ff; color: #002; }
|
||||||
.mode-hint { margin: 0 0 10px; }
|
.mode-hint { margin: 0 0 10px; }
|
||||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
.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; }
|
.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 { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||||
|
|||||||
@@ -70,28 +70,52 @@
|
|||||||
|
|
||||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
// Mask checkerboard is valid in tape mode too (source-grid, moves with the photo).
|
// Mutate the photo bitmap in place (same approach as Mask/SessionCapture).
|
||||||
// Bbox/axis arrow stay bbox-mode only — tape uses the fixed SVG fixture instead.
|
// 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) {
|
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||||
const off = document.createElement('canvas');
|
const cw = canvas.width;
|
||||||
off.width = maskW;
|
const ch = canvas.height;
|
||||||
off.height = maskH;
|
const img = ctx.getImageData(0, 0, cw, ch);
|
||||||
const octx = off.getContext('2d')!;
|
const px = img.data;
|
||||||
const d = octx.createImageData(maskW, maskH);
|
|
||||||
const tile = 8;
|
const tile = 8;
|
||||||
for (let p = 0; p < maskW * maskH; p++) {
|
const punch = mode === 'tape';
|
||||||
if (!maskBitset[p]) {
|
const matched = maskW === cw && maskH === ch;
|
||||||
const x = p % maskW;
|
const pixels = matched ? Math.min(maskW * maskH, (px.length / 4) | 0) : 0;
|
||||||
const y = (p / maskW) | 0;
|
if (matched) {
|
||||||
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
for (let p = 0; p < pixels; p++) {
|
||||||
d.data[p * 4] = light ? 220 : 180;
|
if (maskBitset[p]) continue; // slab: keep photo
|
||||||
d.data[p * 4 + 1] = light ? 220 : 180;
|
const i = p * 4;
|
||||||
d.data[p * 4 + 2] = light ? 220 : 180;
|
if (punch) {
|
||||||
d.data[p * 4 + 3] = 220;
|
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.putImageData(img, 0, 0);
|
||||||
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
}
|
||||||
if (mode === 'tape') return;
|
if (mode === 'tape') return;
|
||||||
if (bbox && bbox.corners.length === 4) {
|
if (bbox && bbox.corners.length === 4) {
|
||||||
@@ -312,6 +336,9 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (stage) parentW = stage.clientWidth;
|
if (stage) parentW = stage.clientWidth;
|
||||||
updateVbScale();
|
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 ro = new ResizeObserver((entries) => {
|
||||||
const e = entries[0];
|
const e = entries[0];
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user