f670de1377
This reverts8809eb8. The step cut takes the whole brush out of the frame, so the fill below has only the rim left to read from. The colour leak that cut was for no longer needs it:49c5c9chanded the mask the whole spot, and heal-colour-pass.mjs measures the repair 1.08 levels off the ground with the fade cut — the same 1.08 it measures with the step.
147 lines
7.3 KiB
TypeScript
147 lines
7.3 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 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.
|
|
//
|
|
// 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];
|
|
|
|
|