Files
RecipesCam/docker/frontend/shared/utils/heal.ts
T

116 lines
5.4 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 95% the mask is solid to completely eliminate defects without leaving
// residual dirt/shadows, and the last 5% is a smooth Hermite fade to blend into the photo.
export const HEAL_FEATHER = 0.95;
// 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.
// Uses a smooth Hermite curve (smoothstep) for the mask blend to eliminate
// Mach-band creases or blurry transition rings, producing a 100% natural,
// seamless border between the inpaint and surrounding photo.
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.001 ? f.rgb / f.a : here;
half sm = smoothstep(0.0, 1.0, m);
return half4(mix(here, made, sm) * 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];