fix(studio): the heal's fill is drawn at the surface's own corner, not at the frame's, so a repair no longer wears the colour of the frame's far side

This commit is contained in:
2026-10-12 20:23:32 +07:00
parent cf6b7f5b9f
commit 2d9d7ef644
2 changed files with 83 additions and 9 deletions
+69 -5
View File
@@ -42,6 +42,11 @@ assert.match(engine, /HEAL_FILL_MAX_LEVELS/, 'exportEngine does not cap the fill
assert.match(engine, /1 \+ Math\.ceil\(Math\.log2\(2 \+ span \/ 2 \/ widest\)\)/, 'exportEngine no longer scales the fill levels with the depth of the hole');
// ...and the fill is built on the brush's box, read back at the frame's origin.
assert.match(engine, /imageShaderChildAt\(fillImage, boxLeft, boxTop\)/, 'the fill child is read at its own origin instead of the frame\'s');
// ...and the box the fill is built on is drawn at the surface's own corner, which
// is where that child reads it. Drawn at the frame's coordinates instead, the two
// disagree by the box's corner, and the repair comes back a patch of the frame
// from up and to the left of the spot (the case at the foot of this file).
assert.match(engine, /drawImageRect\(cutImage, box, onto, /, 'the fill is drawn at the box\'s frame coordinates, not at the surface\'s own corner');
// --- the stored spots --------------------------------------------------------
// A recipe written by the older brush carries sx/sy. It still loads: those two
@@ -58,8 +63,11 @@ const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const W = 160;
const H = 160;
// The frame every pass below is built on. One size for most of the check; the
// last case needs a bigger one, which is the whole point of it (see there), so
// these two are the frame and the functions here read them.
let W = 160;
let H = 160;
const GREY = 118;
const DUST = 38;
const R = 0.05; // the brush: an 8px radius on this frame
@@ -201,14 +209,22 @@ function healPass(rgba, spotsIn) {
const boxRight = Math.min(W, Math.ceil(Math.max(...xs) + widest + pad));
const boxBottom = Math.min(H, Math.ceil(Math.max(...ys) + widest + pad));
const box = CanvasKit.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop);
// ...and the box's region is drawn at the surface's OWN corner, which is what
// the child below reads it back with. Both rects the `box` — the frame's own
// coordinates on a surface the size of the box — is the draw that leaves the
// surface's left and top strips empty, clips its far side, and hands the
// composite a patch of the frame from boxLeft, boxTop up and to the left of the
// spot: a repair the wrong shade, on a ramp a far-off one. `box, onto`, and the
// case at the foot of this file is what keeps it that way.
const onto = CanvasKit.XYWHRect(0, 0, boxRight - boxLeft, boxBottom - boxTop);
const fillBox = surface(boxRight - boxLeft, boxBottom - boxTop);
for (let k = levelCount; k >= 1; k--) {
const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1);
const blurPaint = new CanvasKit.Paint();
blurPaint.setImageFilter(CanvasKit.ImageFilter.MakeBlur(sigma, sigma, CanvasKit.TileMode.Clamp, null));
fillBox.getCanvas().drawImageRect(cutImage, box, box, blurPaint);
fillBox.getCanvas().drawImageRect(cutImage, box, onto, blurPaint);
}
fillBox.getCanvas().drawImageRect(cutImage, box, box, new CanvasKit.Paint());
fillBox.getCanvas().drawImageRect(cutImage, box, onto, new CanvasKit.Paint());
const fillImage = fillBox.makeImageSnapshot();
const fillEffect = CanvasKit.RuntimeEffect.Make(HEAL_FILL_SKSL);
@@ -217,7 +233,7 @@ function healPass(rgba, spotsIn) {
const paint = new CanvasKit.Paint();
paint.setShader(fillEffect.makeShaderWithChildren([], [asChild(src), asChildAt(fillImage, boxLeft, boxTop), asChild(mask)]));
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), paint);
return { pixels: pixelsOf(out.makeImageSnapshot()), mask: pixelsOf(mask) };
return { pixels: pixelsOf(out.makeImageSnapshot()), mask: pixelsOf(mask), boxLeft, boxTop };
}
const flat = bytesOf(shadeFlat);
@@ -362,4 +378,52 @@ for (let y = 0; y < H; y++) {
}
assert.ok(worstBand <= 8, `a painted pixel at x=${bandAt} is ${worstBand} levels off the rim it sits in`);
// 9. The one case every size above hides: a frame big enough that the fill's box
// sits INSIDE it. On a 160px frame with a 8px brush the padding is wider than
// the frame, so the box is the frame and its corner is (0,0) — a fill drawn at
// the box's coordinates and one drawn at the surface's own are then the same
// draw, and a repair off by the box's corner is off by nothing. On a real photo
// it is not nothing: a brush at the sensor-dust end of the scale, a frame a few
// thousand pixels across, and the corner is hundreds of pixels up and to the
// left of the spot. That is the shape the reader heals with, and on a ramp the
// patch comes back at the ramp's value hundreds of pixels away — the healed
// area is the wrong shade, which is what this case is here to catch.
{
const keptW = W;
const keptH = H;
// Tall enough that the box clears BOTH edges: the padding is three blur
// radii of the widest brush, which on this frame is 192px, so a shorter
// frame would push boxTop to 16 and the corner would nearly be the edge.
W = 640;
H = 720;
const shadeWide = (x) => {
const v = 40 + (120 * x) / (W - 1);
return [v, v, v];
};
const spot = [{ x: 0.5, y: 0.5, r: 0.05 }]; // a 32px brush on this frame
const wide = healPass(bytesOf(shadeWide), spot);
assert.ok(
wide.boxLeft > 16 && wide.boxTop > 16,
`the fill's box is not inset on this frame (boxLeft=${wide.boxLeft}, boxTop=${wide.boxTop}) — this case is no longer testing the bug it exists for`
);
let worstWide = 0;
let wideAt = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (wide.mask[(y * W + x) * 4 + 3] < 250) continue;
const off = Math.abs(wide.pixels[(y * W + x) * 4] - shadeWide(x)[0]);
if (off > worstWide) {
worstWide = off;
wideAt = x;
}
}
}
assert.ok(
worstWide <= 4,
`a spot healed at the middle of a ${W}x${H} frame came back ${worstWide} levels off at x=${wideAt} — the fill is read at the box's corner, not at the spot (box is at ${wide.boxLeft},${wide.boxTop})`
);
W = keptW;
H = keptH;
}
console.log('ok the stroke is one mask, the fill carries the light, and nothing outside it moved');