158 lines
8.1 KiB
TypeScript
158 lines
8.1 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:
|
|
// the mask is solid out to here and gives way in the sliver that is left, which
|
|
// is the edge's own anti-aliasing and nothing else.
|
|
//
|
|
// It was 0.75 — solid to three quarters, faded over the last quarter — and that
|
|
// fade is what the brush painted wrong. A spot aimed at something bigger than
|
|
// itself (the crown of a tree against flat sky) faded out ON the thing, so the
|
|
// thing came back at partial weight: a grey cloud the shape of the brush, and
|
|
// the fill showing through it where the mask had already given way. Measured
|
|
// (heal-repro2.mjs, one stroke of four spots over the crown, |after - truth|
|
|
// over the mask): 34.02 with the fade against 3.29 without; the outer band,
|
|
// 0.85r to r, 154.4 against 10.2; and the seam outside the brush 0 either way,
|
|
// because the fill is made of the rim's own light and already agrees with the
|
|
// photo along the edge — it needs no fade to hide behind.
|
|
export const HEAL_FEATHER = 0.995;
|
|
|
|
// 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) => {
|
|
const spot: HealSpot = {
|
|
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)),
|
|
};
|
|
// Which drag the spot came from is carried through. The renderer solves
|
|
// each drag as its own repair and in the order they were drawn, so each
|
|
// one reads the repairs already made next to it rather than the photo
|
|
// they were made over (exportEngine's heal step), and it can only do that
|
|
// if the record of the gesture survives being read.
|
|
if (typeof s?.stroke === 'number' && Number.isFinite(s.stroke)) spot.stroke = s.stroke;
|
|
return spot;
|
|
})
|
|
.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 nothing
|
|
// over the brush, 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.
|
|
//
|
|
// The cut is the WHOLE stroke, and it is a step rather than the mask's own fade.
|
|
// The fill below reads the rim of this hole, so whatever is left standing inside
|
|
// it is weighed into the repair. Cut at the mask's fade instead — `a = 1 -
|
|
// mask.a`, the obvious reading of "take the mask out" — and the outer quarter of
|
|
// every spot stays behind at partial weight. A brush aimed at a blemish has the
|
|
// blemish's own rim in exactly that band, so the repair came back in the colour
|
|
// of the thing being repaired: on a grey ground with a red blemish, the obvious
|
|
// cut is 4.1 levels off the ground and the step is 1.1 (measured, the cut
|
|
// variants in heal-colour-pass.mjs). The threshold is lightcraft's own: the
|
|
// pixels a heal may read its tone from are the ones at alpha < 0.02 (spots.rs).
|
|
export const HEAL_CUT_SKSL = `
|
|
uniform shader img;
|
|
uniform shader mask;
|
|
|
|
half4 main(float2 pos) {
|
|
half4 c = img.eval(pos);
|
|
half a = mask.eval(pos).a < 0.02 ? 1.0 : 0.0;
|
|
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.
|
|
//
|
|
// The weight the fill is mixed in at is the mask's own ramp, read over the WHOLE
|
|
// of it: m is 1 out to HEAL_FEATHER of the radius and runs to 0 at its edge, so
|
|
// the fill arrives exactly as far as the mask says it may. Reaching full weight
|
|
// at 35% of the ramp instead — smoothstep(0, 0.35, m), which is what this shader
|
|
// did first — crosses the fade in a third of the room the mask gives it, and the
|
|
// repair then meets the photo along a line. Measured on the probe
|
|
// (probe-seam.mjs, one stroke across a photo's own detail, ROW 0.75 and 0.85),
|
|
// counting the pixels outside the brush that moved by more than six levels: 338
|
|
// and 392 along that line, by up to 73 and 148 levels, 13 and 39 of them more
|
|
// than a pixel and a half outside the rim. Read over the whole ramp: 66 and 57,
|
|
// by up to 11 and 45, and none of them more than a pixel and a half outside.
|
|
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];
|
|
|
|
|