fa0b2cb719
A press has to mean what it lands on. A drag is a run of spots and the run has a spine, so a press within half a brush radius of that spine takes the drag and dragging moves it, while a press in the outer half of the spots — where the run is not, and a new repair is wanted — lays a stroke against it. Before, a brush near a repaired one was read as a drag OF it: the second stroke was folded into the first and the first moved under the user. Spots carry the drag they came from (stroke), older recipes still answer to the old radius, and the outline of a repaired run is drawn idle so it can be taken hold of (app.css). A repair already made is part of the photo it was made in. The renderer now solves one drag at a time, in the order they were drawn, each against the frame the drags before it have already left — so a stroke laid beside a repaired one invents its pixels from a frame that no longer holds what the first took away, and the repair the user has already accepted does not move when they brush next to it. Measured (probe-seam.mjs, a second stroke laid 2.5 brush radii from the first, counted in levels over the first drag's own circles): 0 at the worst pixel, 0 of 19189 pixels moved by more than two. The ring the first repair left behind was the fill's and not the model's. The model's pixels were laid through the mask's own ramp, so the outer quarter of every spot — where the mask still weighs the fill in at up to full strength — was the fill's, and the fill is the light of the WHOLE box it was built in: a stroke a third of the frame long is filled by blurs up to sixteen brush radii wide, so what it brings there is the box's average and not the rim's. The model is the repair wherever it has one, so it now owns the spot out to 0.92 of its radius (HEAL_PATCH_STOPS) and the fill only meets the photo in the sliver the mask itself fades over. Measured with the same probe: 0.76 levels of mean change over the repair where it was 1.86, 8.5 at the worst pixel where it was 14.2, and the edge strength at the rim after the repair 0.92 where the photo's own edges around it are 0.5 (1.52 before) — the band that was 8 to 11 levels dark is gone. matchToRim goes with them: the relaxation it ran over every crop measures nothing the band above does not already do, and the probe's numbers are identical with it removed.
165 lines
8.7 KiB
TypeScript
165 lines
8.7 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 75% the mask is solid to completely eliminate defects, and the outer 25%
|
|
// is a smooth Hermite fade (smoothstep) to blend invisibly into the surrounding photo.
|
|
export const HEAL_FEATHER = 0.75;
|
|
|
|
// 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];
|
|
|
|
// The same stops for the model's own pixels, and they are not the mask's. The
|
|
// model is the repair wherever it has one, so its shape is the whole spot and
|
|
// the last fraction of a radius is the only place it gives way. At
|
|
// HEAL_MASK_STOPS instead — the model solid out to 0.75 of the radius and faded
|
|
// out by the rim — the band between was the FILL's, and the fill is built from
|
|
// the frame's own light over the whole of its box (a stroke a third of the frame
|
|
// long is filled by blurs up to sixteen brush radii wide, so what it brings into
|
|
// that band is the box's average and not the rim's).
|
|
//
|
|
// Measured (probe-seam.mjs, one stroke of seven spots across the sky, `d` over
|
|
// the spots' own circles between the frame before the repair and after it): the
|
|
// band came back 8 to 11 levels below the photo it was made in — a ring, on the
|
|
// one line a repair is looked at along. With the model owning the band: 0.71
|
|
// levels of mean change over the whole repair, 8.5 at its worst pixel, and the
|
|
// edge strength at the rim after the repair 0.84 where the photo's own edges
|
|
// around it are 0.5 (1.52 before). Past 0.92 the numbers stop moving (0.73 /
|
|
// 8.7 / 0.88 at 0.995), so the model does own the band and what is left there is
|
|
// the shape of the spot, not the fill under it.
|
|
export const HEAL_PATCH_STOPS: number[] = [0, 0.92, 1];
|