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; // How many halvings of the frame the fill reads, at most. Each level is half of // the one below it, so twelve covers any photo a camera makes and stops a // pathological surface size from walking off the end of the pyramid. export const HEAL_MAX_LEVELS = 12; // 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 pyramid below is built on it: // a linear halving 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 pyramid's // answer for every pixel of the frame — 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];