5ac2ffb916
The dust brush had two faults and the second was hidden behind the first. A stroke was a row of independent repairs. Every circle the brush laid down found a patch of its own somewhere else in the frame and pasted it, so a stroke of twenty spots was twenty pastes with twenty rims to match, and what showed on the photo was the run of circles itself. The mask was their union on paper but not on the picture. And the mask was not a stroke either. The brush only ever dropped a spot where the pointer's own sample fell, and a fast drag reports a sample every few centimetres of screen — several brush radii apart. Circles that far apart do not touch, so their union was the row of dots the tool was complained about. The segment the pointer travelled is now walked at the brush's own spacing and a spot is laid at every step, whatever speed the stroke was drawn at. With the mask one shape, the patch had nothing left to justify it. The fill is now a push-pull pyramid over the whole mask: the painted pixels are cut out of the frame as a hole, the remaining pixels are halved and halved again until a level is small enough that no hole is left in it, and each level is drawn back over the one below it. What comes up out of that is the photo where the photo was, and the light of the mask's own rim carried inward where it was not — nothing is copied from anywhere, and a repair matches the grade, the blur and the grain around it because those are the pixels it was made from. Non-destructive as before: the recipe still holds the circles, and the preview and the full-size export run the two passes in engine/exportEngine.ts, the shaders and the mask stops in shared/utils/heal.ts. A recipe written by the old brush still loads; the patch coordinates it stored are simply not read any more.
316 lines
12 KiB
JavaScript
316 lines
12 KiB
JavaScript
// HEAL is a mask and a fill now, not a patch pasted under each spot. Four
|
|
// things can go wrong and none of them throws: the circles of one stroke can
|
|
// stay separate shapes (which is what a row of round grey blobs on the photo
|
|
// is), the fill can fail to carry the light of the photo around it (a repair
|
|
// that reads as a mark of its own), the search can still borrow a patch from
|
|
// somewhere (the card three radii away, which is the brush the user complained
|
|
// about), or the composite can touch pixels it was never given (a smear across
|
|
// the whole frame). So the mask, the fill and the untouched ground are all
|
|
// measured here, on synthetic photos whose answer is known, with CanvasKit
|
|
// running the same two passes exportEngine runs.
|
|
//
|
|
// node scripts/heal-check.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import ts from 'typescript';
|
|
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
|
|
|
const transpile = (path) =>
|
|
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText;
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'heal-check-'));
|
|
writeFileSync(join(dir, 'heal.mjs'), transpile('../shared/utils/heal.ts'));
|
|
const { readHeal, HEAL_MASK_STOPS, HEAL_MAX_LEVELS, HEAL_CUT_SKSL, HEAL_FILL_SKSL } = await import(
|
|
pathToFileURL(join(dir, 'heal.mjs')).href
|
|
);
|
|
|
|
// The other half of the check: the passes below are these two shaders, so the
|
|
// engine has to be compiling them — and it must not still be building the
|
|
// per-spot shader this replaced.
|
|
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
|
|
assert.match(engine, /healCutEffect = Skia\.RuntimeEffect\.Make\(HEAL_CUT_SKSL\)/, 'exportEngine does not compile HEAL_CUT_SKSL');
|
|
assert.match(engine, /healFillEffect = Skia\.RuntimeEffect\.Make\(HEAL_FILL_SKSL\)/, 'exportEngine does not compile HEAL_FILL_SKSL');
|
|
assert.doesNotMatch(engine, /healSkSL\(/, 'the per-spot heal shader is still in exportEngine');
|
|
assert.doesNotMatch(engine, /healUniforms\(/, 'the per-spot uniform block is still in exportEngine');
|
|
|
|
// --- the stored spots --------------------------------------------------------
|
|
// A recipe written by the older brush carries sx/sy. It still loads: those two
|
|
// numbers are no longer read, and nothing else about the spot moves.
|
|
assert.deepEqual(readHeal([{ x: 0.5, y: 0.5, r: 0.02, sx: 0.9, sy: 0.1 }]), [{ x: 0.5, y: 0.5, r: 0.02 }]);
|
|
// Out of frame, non-numeric, or a radius of nothing: a spot the brush and the
|
|
// renderer would disagree about is dropped either way.
|
|
assert.deepEqual(readHeal([{ x: 2, y: -1, r: 0.05 }, { x: 'a', y: 0, r: 0.05 }, { x: 0.5, y: 0.5, r: 0 }]), [
|
|
{ x: 1, y: 0, r: 0.05 },
|
|
{ x: 0, y: 0, r: 0.05 },
|
|
]);
|
|
|
|
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;
|
|
const GREY = 118;
|
|
const DUST = 38;
|
|
const R = 0.05; // the brush: an 8px radius on this frame
|
|
// A stroke of nine spots, one every 0.6 of a radius, running down the frame.
|
|
const spots = Array.from({ length: 9 }, (_, i) => ({ x: 0.5, y: (48 + i * R * 0.6 * W) / H, r: R }));
|
|
const cx = 0.5 * W;
|
|
const cy = 48 + 4 * R * 0.6 * W;
|
|
// Three radii to the right of the stroke: where the old search looked.
|
|
const CARD_X = Math.round(cx + 3.2 * R * W);
|
|
|
|
const shadeFlat = (x, y) => {
|
|
if (Math.abs(x - CARD_X) < 6 && Math.abs(y - cy) < 6) return [220, 30, 30];
|
|
if (Math.hypot(x - cx, y - cy) < 6) return [DUST, DUST, DUST];
|
|
return [GREY, GREY, GREY];
|
|
};
|
|
const shadeRamp = (x) => {
|
|
const v = 40 + (120 * x) / (W - 1);
|
|
return [v, v, v];
|
|
};
|
|
|
|
function bytesOf(shade) {
|
|
const rgba = new Uint8Array(W * H * 4);
|
|
for (let y = 0; y < H; y++) {
|
|
for (let x = 0; x < W; x++) {
|
|
const [r, g, b] = shade(x, y);
|
|
const i = (y * W + x) * 4;
|
|
rgba[i] = r;
|
|
rgba[i + 1] = g;
|
|
rgba[i + 2] = b;
|
|
rgba[i + 3] = 255;
|
|
}
|
|
}
|
|
return rgba;
|
|
}
|
|
const imageOf = (rgba) =>
|
|
CanvasKit.MakeImage(
|
|
{
|
|
width: W,
|
|
height: H,
|
|
colorType: CanvasKit.ColorType.RGBA_8888,
|
|
alphaType: CanvasKit.AlphaType.Unpremul,
|
|
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
},
|
|
rgba,
|
|
W * 4
|
|
);
|
|
const surface = (w, h) => {
|
|
const s = CanvasKit.MakeSurface(w, h);
|
|
s.getCanvas().clear(CanvasKit.TRANSPARENT);
|
|
return s;
|
|
};
|
|
const pixelsOf = (image) => {
|
|
const s = surface(image.width(), image.height());
|
|
s.getCanvas().drawImage(image, 0, 0);
|
|
return s.getCanvas().readPixels(0, 0, {
|
|
width: image.width(),
|
|
height: image.height(),
|
|
colorType: CanvasKit.ColorType.RGBA_8888,
|
|
alphaType: CanvasKit.AlphaType.Unpremul,
|
|
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
});
|
|
};
|
|
const asChild = (image) =>
|
|
image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None);
|
|
|
|
// The brush's circles as ONE mask: a radial gradient per spot, solid out to
|
|
// HEAL_FEATHER of its radius and clear at its edge, each laid over the last.
|
|
function maskOf(spotsIn) {
|
|
const surf = surface(W, H);
|
|
const canvas = surf.getCanvas();
|
|
for (const s of spotsIn) {
|
|
const rad = s.r * W;
|
|
const shader = CanvasKit.Shader.MakeRadialGradient(
|
|
[s.x * W, s.y * H],
|
|
rad,
|
|
new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]),
|
|
HEAL_MASK_STOPS,
|
|
CanvasKit.TileMode.Clamp
|
|
);
|
|
const paint = new CanvasKit.Paint();
|
|
paint.setAntiAlias(true);
|
|
paint.setShader(shader);
|
|
canvas.drawCircle(s.x * W, s.y * H, rad, paint);
|
|
}
|
|
return surf.makeImageSnapshot();
|
|
}
|
|
|
|
// exportEngine's 6e, pass for pass: cut, the pyramid down and up again, and the
|
|
// fill laid back under the brush.
|
|
function healPass(rgba, spotsIn) {
|
|
const src = imageOf(rgba);
|
|
const mask = maskOf(spotsIn);
|
|
const cutEffect = CanvasKit.RuntimeEffect.Make(HEAL_CUT_SKSL);
|
|
assert.ok(cutEffect, 'HEAL_CUT_SKSL does not compile');
|
|
const cut = surface(W, H);
|
|
const cutPaint = new CanvasKit.Paint();
|
|
cutPaint.setShader(cutEffect.makeShaderWithChildren([], [asChild(src), asChild(mask)]));
|
|
cut.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), cutPaint);
|
|
|
|
const levels = [];
|
|
let carried = cut.makeImageSnapshot();
|
|
for (let k = 1; k <= HEAL_MAX_LEVELS; k++) {
|
|
const lw = Math.max(1, W >> k);
|
|
const lh = Math.max(1, H >> k);
|
|
const level = surface(lw, lh);
|
|
level
|
|
.getCanvas()
|
|
.drawImageRectOptions(
|
|
carried,
|
|
CanvasKit.XYWHRect(0, 0, carried.width(), carried.height()),
|
|
CanvasKit.XYWHRect(0, 0, lw, lh),
|
|
CanvasKit.FilterMode.Linear,
|
|
CanvasKit.MipmapMode.None
|
|
);
|
|
levels.push({ surf: level, w: lw, h: lh });
|
|
carried = level.makeImageSnapshot();
|
|
if (lw === 1 && lh === 1) break;
|
|
}
|
|
let up = null;
|
|
let fillImage = null;
|
|
for (let k = levels.length - 1; k >= 0; k--) {
|
|
const { surf, w: lw, h: lh } = levels[k];
|
|
if (up) {
|
|
surf
|
|
.getCanvas()
|
|
.drawImageRectOptions(
|
|
up,
|
|
CanvasKit.XYWHRect(0, 0, up.width(), up.height()),
|
|
CanvasKit.XYWHRect(0, 0, lw, lh),
|
|
CanvasKit.FilterMode.Linear,
|
|
CanvasKit.MipmapMode.None
|
|
);
|
|
}
|
|
const pulled = surf.makeImageSnapshot();
|
|
if (k === 0) {
|
|
fillImage = pulled;
|
|
break;
|
|
}
|
|
up = pulled;
|
|
}
|
|
|
|
const fillEffect = CanvasKit.RuntimeEffect.Make(HEAL_FILL_SKSL);
|
|
assert.ok(fillEffect, 'HEAL_FILL_SKSL does not compile');
|
|
const out = surface(W, H);
|
|
const paint = new CanvasKit.Paint();
|
|
paint.setShader(fillEffect.makeShaderWithChildren([], [asChild(src), asChild(fillImage), asChild(mask)]));
|
|
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), paint);
|
|
return { pixels: pixelsOf(out.makeImageSnapshot()), mask: pixelsOf(mask) };
|
|
}
|
|
|
|
const flat = bytesOf(shadeFlat);
|
|
const made = healPass(flat, spots);
|
|
const alphaAt = (x, y) => made.mask[(y * W + x) * 4 + 3];
|
|
const inMask = (x, y) => alphaAt(x, y) > 200;
|
|
const rgbAt = (x, y) => {
|
|
const i = (y * W + x) * 4;
|
|
return [made.pixels[i], made.pixels[i + 1], made.pixels[i + 2]];
|
|
};
|
|
|
|
// 1. The mask is one shape. Painting a stroke as nine overlapping spots has to
|
|
// leave one painted region, not nine: every painted pixel is reachable from
|
|
// the first spot's centre without ever stepping off the mask. This is what
|
|
// "mask liền mạch" is, and it is the thing the row of circles failed.
|
|
const painted = [];
|
|
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (inMask(x, y)) painted.push(y * W + x);
|
|
const seen = new Set();
|
|
const queue = [painted[0]];
|
|
seen.add(painted[0]);
|
|
while (queue.length) {
|
|
const p = queue.pop();
|
|
const x = p % W;
|
|
const y = (p - x) / W;
|
|
for (const [dx, dy] of [
|
|
[1, 0],
|
|
[-1, 0],
|
|
[0, 1],
|
|
[0, -1],
|
|
]) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
const q = ny * W + nx;
|
|
if (seen.has(q) || !inMask(nx, ny)) continue;
|
|
seen.add(q);
|
|
queue.push(q);
|
|
}
|
|
}
|
|
assert.equal(seen.size, painted.length, `${painted.length - seen.size} painted pixels are not reachable from the rest of the stroke`);
|
|
// ...and the mask is solid in the middle and clear well outside it.
|
|
assert.equal(alphaAt(Math.round(cx), Math.round(cy)), 255, 'the middle of the stroke is not fully painted');
|
|
assert.equal(alphaAt(2, 2), 0, 'the mask leaked into the corner of the frame');
|
|
|
|
// 2. The dust is gone and nothing was borrowed to do it: every painted pixel
|
|
// ends on the ground's own value, 3.2 radii from a red card that a patch
|
|
// search would have pasted in.
|
|
let worstIn = 0;
|
|
for (const p of painted) {
|
|
const i = p * 4;
|
|
for (let c = 0; c < 3; c++) worstIn = Math.max(worstIn, Math.abs(made.pixels[i + c] - GREY));
|
|
}
|
|
assert.ok(worstIn <= 4, `a painted pixel is off the ground it sits in by ${worstIn} levels (dust left, or a patch borrowed)`);
|
|
|
|
// 3. Nothing survives INSIDE the stroke either: neighbouring painted pixels
|
|
// differ by a level or two, the way the ground does, where a spot solved on
|
|
// its own would step at every seam.
|
|
let worstStep = 0;
|
|
for (let y = 0; y + 1 < H; y++) {
|
|
for (let x = 0; x + 1 < W; x++) {
|
|
if (!inMask(x, y) || !inMask(x + 1, y)) continue;
|
|
worstStep = Math.max(worstStep, Math.abs(rgbAt(x, y)[0] - rgbAt(x + 1, y)[0]));
|
|
}
|
|
}
|
|
assert.ok(worstStep <= 4, `the fill steps by ${worstStep} levels between neighbours inside the mask`);
|
|
|
|
// 4. The repair carries the LIGHT of where it landed. On a photo running from
|
|
// dark to light across the frame, the painted pixels come back on that ramp
|
|
// rather than at one flat value borrowed from somewhere else. The bound is
|
|
// the ramp's own steepness, not zero: the pyramid reads the rim of the hole,
|
|
// and where the hole takes weight off one side of a pixel its average slides
|
|
// that way by about a pixel's worth of the ramp — 0.75 levels a pixel across
|
|
// this frame. On the flat ground of 2 and 3 it is nothing at all.
|
|
const ramped = healPass(bytesOf(shadeRamp), spots);
|
|
let worstRamp = 0;
|
|
for (let y = 0; y < H; y++) {
|
|
for (let x = 0; x < W; x++) {
|
|
if (ramped.mask[(y * W + x) * 4 + 3] < 250) continue;
|
|
worstRamp = Math.max(worstRamp, Math.abs(ramped.pixels[(y * W + x) * 4] - shadeRamp(x)[0]));
|
|
}
|
|
}
|
|
assert.ok(worstRamp <= 6, `the fill is off the ramp it sits in by ${worstRamp} levels`);
|
|
|
|
// 5. Outside the mask, nothing moved. The pass is asked for the painted pixels
|
|
// and no others.
|
|
let outWorst = 0;
|
|
for (let p = 0; p < W * H; p++) {
|
|
if (inMask(p % W, (p - (p % W)) / W)) continue;
|
|
for (let c = 0; c < 3; c++) outWorst = Math.max(outWorst, Math.abs(made.pixels[p * 4 + c] - flat[p * 4 + c]));
|
|
}
|
|
assert.equal(outWorst, 0, `a pixel outside the mask changed by ${outWorst} levels`);
|
|
|
|
// 6. The same photo and the same spots give the same photo, byte for byte.
|
|
assert.deepEqual(
|
|
Buffer.from(healPass(flat, spots).pixels),
|
|
Buffer.from(made.pixels),
|
|
'the pass is not deterministic'
|
|
);
|
|
|
|
// 7. A mask with no rim to read from — one spot over the whole frame — leaves
|
|
// the photo alone. Nothing is painted black, because nothing was found to
|
|
// paint it with, and a frame is still a frame.
|
|
const all = healPass(flat, [{ x: 0.5, y: 0.5, r: 2 }]);
|
|
let worstAll = 0;
|
|
for (let p = 0; p < W * H; p++) {
|
|
for (let c = 0; c < 3; c++) worstAll = Math.max(worstAll, Math.abs(all.pixels[p * 4 + c] - flat[p * 4 + c]));
|
|
}
|
|
assert.equal(worstAll, 0, `a frame-wide mask changed the photo by ${worstAll} levels`);
|
|
|
|
console.log('ok the stroke is one mask, the fill carries the light, and nothing outside it moved');
|