Files
RecipesCam/docker/frontend/shared/utils/heal.ts
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

124 lines
6.0 KiB
TypeScript

import type { HealSpot } from '../types';
// FX tab > HEAL — the dust brush, and the fill behind it.
//
// What the brush paints is a mask: the circles it laid down, all of them at
// once, as ONE shape. What fills that shape is not borrowed from anywhere — the
// renderer takes the photo around the mask's rim and works inward, so the
// repair is made of the light and the colour that already border it. That is
// the whole of the change from a patch brush: a stroke of twenty overlapping
// spots used to be twenty independent repairs, each pasting its own patch and
// matching its own rim, which is exactly what a run of circles looks like on the
// photo; one mask and one solve have no circles in them to see.
//
// Non-destructive like everything else here: the recipe keeps the circles, and
// the preview and the export run the same two passes over them.
//
// x/y are fractions of the render's width and height, r of its width. The
// fractions are what make one set of spots land on the preview and on the
// full-size export, and keep a spot round whatever the photo's shape.
// The brush's radius, as a fraction of the photo's width — where this tool
// starts, at the sensor-dust end, where a speck is a few thousandths of the
// frame. The bounds and the wheel that move it between them belong to the brush
// both FX tools share (brush.ts).
export const HEAL_DEFAULT_R = 0.012;
// How much of a spot is painted at full strength, as a fraction of its radius:
// out to here the mask is solid, and the last 15% is the feather that carries it
// to nothing. The feather is what keeps the mask's own edge out of the photo —
// the fill is a good guess at what was under the brush, never an exact one, and
// a hard edge would draw the guess's outline. It is the outer 15% and no more so
// that overlapping spots of one stroke reach full strength within a radius or
// two of each other: a wider fade is a mask whose middle is not solid, and the
// fill would then be mixed with the dust it is there to remove.
export const HEAL_FEATHER = 0.85;
// The finest of the fill's blurs, as a multiple of the widest brush in the
// stroke. The light of the rim has to reach the middle of the hole, so the
// reach is about a brush wide and no more: much wider and the fill carries the
// light of places the repair has no business reading (a red card three radii
// away bled into the hole at 1.2), much narrower and a wide hole is left bare.
// Measured in heal-check.mjs; 1.0 is the widest that passes.
export const HEAL_FILL_BLUR = 1.0;
// How many widths of blur the fill is built from, each twice the last. The
// finest is HEAL_FILL_BLUR brushes wide; the widest of n of them reaches
// HEAL_FILL_BLUR * 2^(n-1) brush radii. The count has to grow with the depth of
// the hole — a stroke two brushes wide comes out hollow with too few levels and
// a blurred red card with too many — so the renderer picks it per stroke and
// this is only the ceiling. Measured in heal-check.mjs and tmp-blob-probe.mjs:
// a hole two brushes across needs four levels, one ten across needs six.
export const HEAL_FILL_MAX_LEVELS = 8;
// The stored spots, made readable: numbers, inside the frame. Everything below
// reads a recipe through this, so a hand-written or older file cannot produce a
// spot the brush and the renderer disagree about. The patch the older brush
// stored (sx/sy) is not read: a repair is no longer a copy of another place.
export function readHeal(heal: HealSpot[] | undefined): HealSpot[] {
if (!Array.isArray(heal)) return [];
return heal
.map((s) => ({
x: Math.min(1, Math.max(0, num(s?.x, 0))),
y: Math.min(1, Math.max(0, num(s?.y, 0))),
r: Math.max(0, num(s?.r, 0)),
}))
.filter((s) => s.r > 0);
}
function num(v: unknown, fallback: number): number {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
// The first of the fill's two passes: the photo with the painted pixels taken
// out of it. The colour of every pixel is kept and its alpha drops to what the
// brush did not touch, which is what turns the mask into a hole the second pass
// can see the rim of. Written as premultiplied — rgb times that alpha — because
// that is what every surface here holds, and the blur below is built on it: a
// blur of premultiplied pixels is a weighted average of the pixels that are
// still there, with the hole weighing nothing.
export const HEAL_CUT_SKSL = `
uniform shader img;
uniform shader mask;
half4 main(float2 pos) {
half4 c = img.eval(pos);
half a = 1.0 - mask.eval(pos).a;
half3 straight = c.a > 0.0001 ? c.rgb / c.a : c.rgb;
return half4(straight * a, a);
}
`;
// The second pass: the fill laid back under the brush. `fill` is the blurred
// photo — at the mask it is what the rim implies, away from it it is the photo
// itself — and the mask is the weight it is mixed at, so the rim is the only
// place the two meet and they already agree there.
//
// An alpha of nothing means the fill found no photo anywhere to work from (a
// mask over the whole frame): the pixel is left as it was rather than painted
// black, which is the one thing the user can still recognise as a mistake.
export const HEAL_FILL_SKSL = `
uniform shader img;
uniform shader fill;
uniform shader mask;
half4 main(float2 pos) {
half4 c = img.eval(pos);
half m = mask.eval(pos).a;
if (m <= 0.0) return c;
half4 f = fill.eval(pos);
half3 here = c.a > 0.0001 ? c.rgb / c.a : c.rgb;
half3 made = f.a > 0.0001 ? f.rgb / f.a : here;
return half4(mix(here, made, m) * c.a, c.a);
}
`;
// How the mask is painted onto a surface, as the stops of one radial gradient
// per spot: solid white out to HEAL_FEATHER of the radius, nothing at its edge,
// a straight ramp between. The renderer builds the gradient per spot (Skia has
// no one paint for a list of circles) and draws each at its own radius, so a
// brush turned down to three pixels and one at a quarter of the frame feather by
// the same fraction of themselves.
export const HEAL_MASK_STOPS: number[] = [0, HEAL_FEATHER, 1];