116 lines
5.4 KiB
TypeScript
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];
|