From 2d9d7ef64409d8f625794d14dda4be410964b8ed Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 12 Oct 2026 20:23:32 +0700 Subject: [PATCH] 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 --- docker/frontend/scripts/heal-check.mjs | 74 ++++++++++++++++++++-- docker/frontend/src/engine/exportEngine.ts | 18 ++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/docker/frontend/scripts/heal-check.mjs b/docker/frontend/scripts/heal-check.mjs index 771415f..557612a 100644 --- a/docker/frontend/scripts/heal-check.mjs +++ b/docker/frontend/scripts/heal-check.mjs @@ -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'); diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index f34ac59..7d3a887 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -1410,19 +1410,29 @@ export async function renderPhoto(input: RenderInput): Promise= 1; k--) { const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1); const blurPaint = own(Skia.Paint()); blurPaint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null))); - fillCanvas.drawImageRect(cutImage, box, box, blurPaint); + fillCanvas.drawImageRect(cutImage, box, onto, blurPaint); } // The cut itself, unblurred, on top: the pixels the brush did not touch // are the photo exactly, not a blur of it. - fillCanvas.drawImageRect(cutImage, box, box, own(Skia.Paint())); + fillCanvas.drawImageRect(cutImage, box, onto, own(Skia.Paint())); } flush(fillSurface); const fillImage = own(fillSurface.makeImageSnapshot());