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:
@@ -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');
|
||||
|
||||
@@ -1410,19 +1410,29 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
const boxBottom = Math.min(height, Math.ceil(Math.max(...ys) + widest + pad));
|
||||
const fillSurface = own(createSurface(boxRight - boxLeft, boxBottom - boxTop));
|
||||
const fillCanvas = fillSurface.getCanvas();
|
||||
// The box's own corner is the surface's, and both rects are the same one:
|
||||
// a 1:1 copy at an offset, which is the draw this Skia filters correctly.
|
||||
// What the box names is drawn at the surface's own corner, 1:1 — the source
|
||||
// rect is the frame's, the destination is the surface's, and the two are a
|
||||
// whole number of pixels apart, which is the draw this Skia filters
|
||||
// correctly. Drawn at the frame's own coordinates instead — both rects the
|
||||
// box — the surface's left and top strips are left empty and its far side is
|
||||
// clipped, and the composite that reads it back with
|
||||
// `imageShaderChildAt(fillImage, boxLeft, boxTop)` then lands on the wrong
|
||||
// pixels: the repaired patch takes the frame's colour from a boxLeft,boxTop
|
||||
// up and to the left of where it is painted — a spot healed in the middle of
|
||||
// the frame came out with the tone of the frame's top-left corner, a shade
|
||||
// off on a smooth sky and the wrong colour outright on anything with shape.
|
||||
const box = Skia.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop);
|
||||
const onto = Skia.XYWHRect(0, 0, boxRight - boxLeft, boxBottom - boxTop);
|
||||
if (cutImage) {
|
||||
for (let k = levelCount; k >= 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());
|
||||
|
||||
Reference in New Issue
Block a user