Files
RecipesCam/docker/frontend/scripts/heal-check.mjs
T
3dtours 00063a7f7c fix(studio): the heal fill is a cascade of blurs, the brush is put down with its tab, and Immich is re-read on the way back
HEAL painted a grey blob over the photo it was meant to repair, and the
cause was one draw Skia gets wrong on this build: a snapshot used as a
SCALED source comes back with the alpha multiplied into its rgb a second
time, so a partly transparent rim — which is what the mask's feather is —
was read as a dark wash. A pyramid of halvings is built of exactly those
scaled draws, which is why the repair's own rim came out grey; the same
source read at 1:1 is exact (measured, tmp-src-probe).

So the fill is now a cascade of blurs, all of them 1:1: the cut frame is
blurred at a few widths, widest first, each finer one drawn over it, the
untouched cut on top. The finest is one brush wide, each next is twice the
last, and the count follows the depth of the hole (the stroke's own
diagonal against the widest brush), which is what keeps the middle of a
long hole filled rather than bare. Nothing is copied from anywhere and the
rim arrives with no seam, which is what the passes were always for.

And the fill is built on the brush's own box rather than the frame, padded
by three sigma of the widest level. The composite reads it only where the
mask is, so this changes no pixel of the result (heal-check measures the
same masks either way) and it is what makes the repair affordable at the
zoom a repair is aimed at: at a 3200px preview a single spot went from
1.7s of frozen main thread to 1.2s, and the heal's own share of that from
0.8s to 0.3s. What is left is the pipeline itself, which is a separate
matter.

The FIX brush outlived its tab. It takes the pointer on the photo, and
leaving TOOLS never put it down — so its layer stayed on the stage over a
photo nobody was editing, and its ring came back under the pointer every
time the hand crossed a repair, which reads as the tool re-arming itself.
It now goes down with the tab, with another chip or another strip, and on
a press outside its own two chips, the same way the mask tool does.

And the Immich button was missing for a visitor who had just connected a
server: the studio and the library are two screens of one page, both
mounted, so coming back is a route change and not a mount — the studio's
own effect, which reads the server list once a session, never ran again.
It is read again on a popstate that lands on /app.

Checks: node scripts/heal-check.mjs (the cascade and the box, to the
letter), npx tsc --noEmit, and the studio driven in a browser: the button
appears on the way back from the library, the brush goes down with TOOLS
and re-arms with HEAL, and a stroke over a photo lays one band with no grey
in it.
2026-10-12 18:12:45 +07:00

366 lines
16 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_FILL_BLUR, HEAL_FILL_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');
// ...and the fill's level count is the one this check builds, not a constant.
assert.match(engine, /HEAL_FILL_MAX_LEVELS/, 'exportEngine does not cap the fill levels at HEAL_FILL_MAX_LEVELS');
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');
// --- 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 fill's own child, read in the frame's coordinates: the fill is built on
// the brush's box (below), and a whole-pixel shift is what the engine does.
const asChildAt = (image, x, y) =>
image.makeShaderOptions(
CanvasKit.TileMode.Clamp,
CanvasKit.TileMode.Clamp,
CanvasKit.FilterMode.Linear,
CanvasKit.MipmapMode.None,
[1, 0, x, 0, 1, y, 0, 0, 1]
);
// 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 blurred fill, 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);
// The fill: the cut frame blurred at a few widths, widest first, each finer
// one drawn over it, the untouched cut on top. A blur is a weighted mean of
// the premultiplied pixels around each pixel, and the hole weighs nothing, so
// the hole comes back as the light of its rim. Widest first means the widest
// blur only has a say where every finer one has nothing — the middle of a hole
// deeper than a brush — and the untouched cut wins wherever the photo is still
// there, so the rim is the rim exactly.
//
// Blurs and not the pyramid of halvings the engine used to build: this Skia
// gets the premultiply wrong on a snapshot drawn with a SCALE — its rgb comes
// back with the alpha multiplied in a second time — and a pyramid is made of
// exactly those halvings, so a partly transparent rim came back a wash of
// grey. Every draw here is 1:1, which filters correctly.
const cutImage = cut.makeImageSnapshot();
// The count and the widths are exportEngine's own, to the letter: a check
// that picks its own levels is checking a renderer nobody ships. So is the
// surface the fill is built on — the brush's own box, padded by three sigma of
// the widest level, rather than the frame.
const widest = Math.max(...spotsIn.map((s) => s.r * W));
const xs = spotsIn.map((s) => s.x * W);
const ys = spotsIn.map((s) => s.y * H);
const span = Math.hypot(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
const levelCount = Math.min(
HEAL_FILL_MAX_LEVELS,
Math.max(2, 1 + Math.ceil(Math.log2(2 + span / 2 / widest)))
);
const pad = 3 * HEAL_FILL_BLUR * widest * 2 ** (levelCount - 1);
const boxLeft = Math.max(0, Math.floor(Math.min(...xs) - widest - pad));
const boxTop = Math.max(0, Math.floor(Math.min(...ys) - widest - pad));
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);
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, box, new CanvasKit.Paint());
const fillImage = fillBox.makeImageSnapshot();
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), asChildAt(fillImage, boxLeft, boxTop), 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 fill 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 — and "painted" is the whole brush, feather included: the last
// 15% of a spot is where the repair is mixed back in, so it is part of the
// repair. Past the brush's own edge the photo is the photo.
let outWorst = 0;
let outAt = 0;
for (let p = 0; p < W * H; p++) {
const x = p % W;
const y = (p - x) / W;
if (alphaAt(x, y) > 0) continue;
for (let c = 0; c < 3; c++) {
const off = Math.abs(made.pixels[p * 4 + c] - flat[p * 4 + c]);
if (off > outWorst) {
outWorst = off;
outAt = x;
}
}
}
assert.equal(outWorst, 0, `a pixel outside the mask (x=${outAt}) 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`);
// 8. The shape the brush is actually used in: one long thin run right across the
// frame, on a photo that runs dark to light along it. Its rim is the pixels
// just above and below it wherever it passes, so the repair comes back on the
// ramp there — not at the frame's average colour. A hole this long needs the
// level count to grow with it: too few levels and the middle of the run is
// farther from any rim than the widest blur reaches, so the fill comes back
// the average of a blur too wide to have read the rim — one flat mark.
const band = [];
for (let i = 0; i <= 60; i++) band.push({ x: 0.12 + (0.76 * i) / 60, y: 0.5, r: 0.02 });
const across = healPass(bytesOf(shadeRamp), band);
let worstBand = 0;
let bandAt = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (across.mask[(y * W + x) * 4 + 3] < 250) continue;
const off = Math.abs(across.pixels[(y * W + x) * 4] - shadeRamp(x)[0]);
if (off > worstBand) {
worstBand = off;
bandAt = x;
}
}
}
assert.ok(worstBand <= 8, `a painted pixel at x=${bandAt} is ${worstBand} levels off the rim it sits in`);
console.log('ok the stroke is one mask, the fill carries the light, and nothing outside it moved');