fix(capture): opaque mask overlay on Measure Zoom/Pan (iOS)
Alpha-punch of non-slab pixels left a mostly-transparent canvas inside the CSS-transformed photo-layer. iOS Safari composites that blank — Ty's failure (checkerboard + tape only, no wood) on LAN :10892. Paint opaque checkerboard for non-slab like Mask; drop will-change. Unit-tested keep/cover counts.
This commit is contained in:
@@ -193,9 +193,8 @@
|
||||
</p>
|
||||
|
||||
<!-- Same binding as Mask/Axis: oriented source + approved bitset.
|
||||
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 paints opaque checkerboard on non-slab (iOS-safe under the
|
||||
CSS pan/zoom/tilt transform — alpha punch left Zoom/Pan blank for Ty). -->
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={state.source.blob}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import { applyMaskOverlayRgba } from './maskOverlay';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
@@ -70,51 +71,16 @@
|
||||
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas) return;
|
||||
// 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).
|
||||
// Mutate the photo bitmap in place (same approach as Mask).
|
||||
// Tape + bbox both paint opaque checkerboard on non-slab. Do NOT punch
|
||||
// alpha to 0: a mostly-transparent canvas inside the CSS-transformed
|
||||
// photo-layer composites blank on iOS Safari (Ty: checkerboard + tape,
|
||||
// no wood). Opaque bitmap stays visible under pan/zoom/tilt.
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const cw = canvas.width;
|
||||
const ch = canvas.height;
|
||||
const img = ctx.getImageData(0, 0, cw, ch);
|
||||
const px = img.data;
|
||||
const tile = 8;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
applyMaskOverlayRgba(img.data, cw, ch, maskBitset, maskW, maskH);
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
if (mode === 'tape') return;
|
||||
@@ -442,7 +408,8 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.vb > :global(*) { pointer-events: auto; }
|
||||
.photo-layer { will-change: transform; pointer-events: none; }
|
||||
/* No will-change: iOS Safari can blank transformed translucent layers. */
|
||||
.photo-layer { pointer-events: none; }
|
||||
.photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; }
|
||||
.band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; }
|
||||
.band.top { top: 0; }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyMaskOverlayRgba } from './maskOverlay.ts';
|
||||
|
||||
test('keeps slab pixels and paints opaque checkerboard on non-slab (matched dims)', () => {
|
||||
const w = 4;
|
||||
const h = 2;
|
||||
const rgba = new Uint8ClampedArray(w * h * 4);
|
||||
for (let p = 0; p < w * h; p++) {
|
||||
rgba[p * 4] = 180;
|
||||
rgba[p * 4 + 1] = 90;
|
||||
rgba[p * 4 + 2] = 40;
|
||||
rgba[p * 4 + 3] = 255;
|
||||
}
|
||||
const bitset = new Uint8Array([1, 1, 0, 0, 1, 1, 0, 0]);
|
||||
const { kept, covered } = applyMaskOverlayRgba(rgba, w, h, bitset, w, h);
|
||||
assert.equal(kept, 4);
|
||||
assert.equal(covered, 4);
|
||||
assert.deepEqual([...rgba.slice(0, 4)], [180, 90, 40, 255]);
|
||||
assert.equal(rgba[2 * 4 + 3], 255);
|
||||
assert.ok(rgba[2 * 4] > 100 && rgba[2 * 4] < 230);
|
||||
});
|
||||
|
||||
test('never leaves transparent holes (iOS transform-safe)', () => {
|
||||
const w = 3;
|
||||
const h = 3;
|
||||
const rgba = new Uint8ClampedArray(w * h * 4).fill(200);
|
||||
for (let i = 3; i < rgba.length; i += 4) rgba[i] = 255;
|
||||
const bitset = new Uint8Array(w * h);
|
||||
bitset[4] = 1;
|
||||
applyMaskOverlayRgba(rgba, w, h, bitset, w, h);
|
||||
for (let i = 3; i < rgba.length; i += 4) {
|
||||
assert.equal(rgba[i], 255);
|
||||
}
|
||||
assert.equal(rgba[4 * 4], 200);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Paint approved-mask non-slab pixels onto decoded photo RGBA.
|
||||
*
|
||||
* Tape Zoom/Pan wraps this canvas in a CSS-transformed photo-layer. Punching
|
||||
* non-slab to alpha 0 leaves a mostly-transparent canvas; iOS Safari WebKit
|
||||
* then composites that layer as blank — Ty's failure: CSS checkerboard + tape
|
||||
* fixture only, no wood. Always paint an opaque checkerboard for non-slab
|
||||
* (same as Mask/bbox) so the bitmap stays fully opaque under transforms.
|
||||
*/
|
||||
export type MaskOverlayBackdrop = 'checkerboard';
|
||||
|
||||
export function applyMaskOverlayRgba(
|
||||
rgba: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
bitset: Uint8Array,
|
||||
maskW: number,
|
||||
maskH: number,
|
||||
_backdrop: MaskOverlayBackdrop = 'checkerboard',
|
||||
): { kept: number; covered: number } {
|
||||
const tile = 8;
|
||||
let kept = 0;
|
||||
let covered = 0;
|
||||
const matched = maskW === width && maskH === height;
|
||||
const paintCover = (i: number, x: number, y: number) => {
|
||||
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
||||
const g = light ? 220 : 180;
|
||||
rgba[i] = g;
|
||||
rgba[i + 1] = g;
|
||||
rgba[i + 2] = g;
|
||||
rgba[i + 3] = 255;
|
||||
covered++;
|
||||
};
|
||||
if (matched) {
|
||||
const pixels = Math.min(maskW * maskH, (rgba.length / 4) | 0);
|
||||
for (let p = 0; p < pixels; p++) {
|
||||
if (bitset[p]) {
|
||||
kept++;
|
||||
continue;
|
||||
}
|
||||
paintCover(p * 4, p % maskW, (p / maskW) | 0);
|
||||
}
|
||||
} else if (maskW > 0 && maskH > 0) {
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sy = Math.min(maskH - 1, Math.round((y / height) * maskH));
|
||||
for (let x = 0; x < width; x++) {
|
||||
const sx = Math.min(maskW - 1, Math.round((x / width) * maskW));
|
||||
const i = (y * width + x) * 4;
|
||||
if (bitset[sy * maskW + sx]) {
|
||||
kept++;
|
||||
continue;
|
||||
}
|
||||
paintCover(i, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kept, covered };
|
||||
}
|
||||
Reference in New Issue
Block a user