fix(studio): a heal stroke is one mask, and the fill comes from its rim

The dust brush had two faults and the second was hidden behind the first.

A stroke was a row of independent repairs. Every circle the brush laid down
found a patch of its own somewhere else in the frame and pasted it, so a stroke
of twenty spots was twenty pastes with twenty rims to match, and what showed on
the photo was the run of circles itself. The mask was their union on paper but
not on the picture.

And the mask was not a stroke either. The brush only ever dropped a spot where
the pointer's own sample fell, and a fast drag reports a sample every few
centimetres of screen — several brush radii apart. Circles that far apart do not
touch, so their union was the row of dots the tool was complained about. The
segment the pointer travelled is now walked at the brush's own spacing and a spot
is laid at every step, whatever speed the stroke was drawn at.

With the mask one shape, the patch had nothing left to justify it. The fill is
now a push-pull pyramid over the whole mask: the painted pixels are cut out of
the frame as a hole, the remaining pixels are halved and halved again until a
level is small enough that no hole is left in it, and each level is drawn back
over the one below it. What comes up out of that is the photo where the photo
was, and the light of the mask's own rim carried inward where it was not —
nothing is copied from anywhere, and a repair matches the grade, the blur and
the grain around it because those are the pixels it was made from.

Non-destructive as before: the recipe still holds the circles, and the preview
and the full-size export run the two passes in engine/exportEngine.ts, the
shaders and the mask stops in shared/utils/heal.ts. A recipe written by the old
brush still loads; the patch coordinates it stored are simply not read any more.
This commit is contained in:
2026-10-12 16:15:18 +07:00
parent 3ac58826d5
commit 5ac2ffb916
6 changed files with 678 additions and 519 deletions
+154 -43
View File
@@ -45,7 +45,14 @@ import {
} from '../../shared/utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader';
import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve';
import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal';
import {
HEAL_CUT_SKSL,
HEAL_FILL_SKSL,
HEAL_FEATHER,
HEAL_MASK_STOPS,
HEAL_MAX_LEVELS,
readHeal,
} from '../../shared/utils/heal';
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask';
import {
@@ -180,6 +187,8 @@ let clarityBlurEffect: any = null;
let clarityBlendEffect: any = null;
let nrEffect: any = null;
let dehazeEffect: any = null;
let healCutEffect: any = null;
let healFillEffect: any = null;
function effects() {
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(SHARPEN_SKSL);
@@ -193,6 +202,8 @@ function effects() {
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
if (!healCutEffect) healCutEffect = Skia.RuntimeEffect.Make(HEAL_CUT_SKSL);
if (!healFillEffect) healFillEffect = Skia.RuntimeEffect.Make(HEAL_FILL_SKSL);
return {
sharpenEffect,
toneEffect,
@@ -205,23 +216,16 @@ function effects() {
clarityBlendEffect,
nrEffect,
dehazeEffect,
healCutEffect,
healFillEffect,
};
}
// FX's HEAL is the one effect whose shader depends on the recipe rather than on
// constants: it carries exactly the spots the photo has (shared/utils/heal.ts),
// which is what lets the list be uncapped. Every distinct count is therefore a
// shader of its own, kept here so a stroke's worth of spots is paid for once
// and every later render at that count is free.
const healEffects = new Map<number, any>();
function healEffectFor(count: number): any {
let effect = healEffects.get(count);
if (effect === undefined) {
effect = Skia.RuntimeEffect.Make(healSkSL(count)) ?? null;
healEffects.set(count, effect);
}
return effect;
}
// FX's HEAL no longer depends on the recipe: what the brush paints is a mask,
// and the fill behind it is one solve over that mask rather than one block per
// spot, so the two passes are the same for a speck and for a long stroke and are
// built once like the rest. The list is uncapped for the same reason — a hundred
// more spots are a hundred more circles in the mask and not a longer shader.
// FX's MOSAIC is the same kind of shader — one unrolled block per spot the
// recipe holds (shared/utils/mosaic.ts) — so it is cached by count the same way.
@@ -1259,7 +1263,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
// that shape's own alpha, masks in the order they were drawn, so the second
// reads what the first left. It sits here, after the frame-wide grade and
// before HEAL, because a local adjustment is part of the look and not a
// repair: the pixels a repair borrows are meant to carry it already.
// repair: the pixels a repair works from are meant to carry it already.
// Same shape as the passes below — the shader reads the frame it is about to
// overwrite — and, when a mask carries CLARITY or DEHAZE, the frame's own
// blurred reference and the atmospheric light come in exactly as they did for
@@ -1279,38 +1283,145 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
}
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
// that touches the photo's own pixels, and deliberately so: the borrowed
// patch is copied out of the pixels the pipeline has already built, so a
// repair matches the grade, the blur and the grain around it by
// construction instead of being a second render of the same look that has
// to agree. The card, the wall and the watermarks are drawn after it, so a
// healed spot still carries the frame's own edge.
// that touches the photo's own pixels, and deliberately so: the fill is made
// of the pixels the pipeline has already built, so a repair matches the
// grade, the blur and the grain around it by construction instead of being a
// second render of the same look that has to agree. The card, the wall and
// the watermarks are drawn after it, so a healed spot still carries the
// frame's own edge.
//
// Three moves, in this order. The brush's circles become ONE mask — a union,
// which is what makes a stroke a single shape instead of a row of circles.
// The mask is cut out of the frame, leaving a hole with the photo's own
// pixels all around its rim. And the hole is filled from that rim, by the
// pyramid below, so nothing is copied from anywhere: what was under the
// brush is the light of what borders it.
const heal = readHeal(adjustments.heal);
if (heal.length) {
// The shader for this many spots, cached by count — every spot the recipe
// holds is drawn, so no repair is ever dropped to make room for a later
// one.
const effect = healEffectFor(heal.length);
const { healCutEffect: cutEffect, healFillEffect: fillEffect } = effects();
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
// The mask, at the frame's own resolution: one radial gradient per spot,
// solid out to HEAL_FEATHER of its radius and clear at its edge, each laid
// over the last — so two spots of one stroke are one shape with no seam
// between them, and the mask's own border is a fade rather than an outline
// the fill would have to match exactly.
const maskSurface = own(createSurface(width, height));
const maskCanvas = maskSurface.getCanvas();
maskCanvas.clear(Skia.Color('transparent'));
for (const s of heal) {
const cx = s.x * width;
const cy = s.y * height;
const rad = s.r * width;
const shader = own(
Skia.Shader.MakeRadialGradient(
[cx, cy],
rad,
new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]),
HEAL_MASK_STOPS,
Skia.TileMode.Clamp
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(healUniforms(heal, width, height), [child]))
: null;
if (shader) {
// Snapshot, clear, redraw — the pass reads the frame it is about to
// overwrite (see replaceThrough).
canvas.clear(Skia.Color('transparent'));
const healPaint = own(Skia.Paint());
healPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), healPaint);
);
const paint = own(Skia.Paint());
paint.setAntiAlias(true);
paint.setShader(shader);
maskCanvas.drawCircle(cx, cy, rad, paint);
}
flush(maskSurface);
const maskImage = own(maskSurface.makeImageSnapshot());
// The frame with the mask taken out of it: the hole the fill works on.
const cutSurface = own(createSurface(width, height));
if (snap && maskImage && cutEffect) {
const cutShader = own(
cutEffect.makeShaderWithChildren([], [own(imageShaderChild(snap)), own(imageShaderChild(maskImage))])
);
if (cutShader) {
const cutCanvas = cutSurface.getCanvas();
cutCanvas.clear(Skia.Color('transparent'));
const cutPaint = own(Skia.Paint());
cutPaint.setShader(cutShader);
cutCanvas.drawRect(Skia.XYWHRect(0, 0, width, height), cutPaint);
}
}
flush(cutSurface);
const cutImage = own(cutSurface.makeImageSnapshot());
// Down: each level is the one below it halved, drawn with a linear filter
// over the premultiplied pixels the cut left behind, so a halving is a
// weighted mean of the pixels that are still there — the hole weighs
// nothing at all, and a level holds the light of the largest
// neighbourhoods around it.
const levels: { surface: any; w: number; h: number }[] = [];
let carried: any = cutImage;
if (carried) {
for (let k = 1; k <= HEAL_MAX_LEVELS; k++) {
const lw = Math.max(1, width >> k);
const lh = Math.max(1, height >> k);
const level = own(createSurface(lw, lh));
const lc = level.getCanvas();
lc.clear(Skia.Color('transparent'));
lc.drawImageRectOptions(
carried,
Skia.XYWHRect(0, 0, carried.width(), carried.height()),
Skia.XYWHRect(0, 0, lw, lh),
Skia.FilterMode.Linear,
Skia.MipmapMode.None
);
flush(level);
levels.push({ surface: level, w: lw, h: lh });
const smaller = own(level.makeImageSnapshot());
if (!smaller || (lw === 1 && lh === 1)) break;
carried = smaller;
}
}
// Up: the coarsest level is already an answer for the whole frame, so it
// is drawn over the level below it — whose hole only that level could
// reach — that result over the one below it, and so on down to the
// half-size level. What comes back is the photo where the photo was, and
// the rim's own light carried inward where it was not.
let up: any = null;
let fillImage: any = null;
for (let k = levels.length - 1; k >= 0; k--) {
const { surface: level, w: lw, h: lh } = levels[k];
if (up) {
const lc = level.getCanvas();
lc.drawImageRectOptions(
up,
Skia.XYWHRect(0, 0, up.width(), up.height()),
Skia.XYWHRect(0, 0, lw, lh),
Skia.FilterMode.Linear,
Skia.MipmapMode.None
);
}
flush(level);
const pulled = own(level.makeImageSnapshot());
if (!pulled) break;
if (k === 0) {
fillImage = pulled;
break;
}
up = pulled;
}
// And the fill back under the brush, in one draw: the frame, the fill and
// the mask as its three children, with the mask the weight the frame and
// the fill are mixed at — so the rim is the only place the two meet, and
// there they already agree.
if (fillImage && snap && maskImage && fillEffect) {
const shader = own(
fillEffect.makeShaderWithChildren([], [
own(imageShaderChild(snap)),
own(imageShaderChild(fillImage)),
own(imageShaderChild(maskImage)),
])
);
if (shader) {
canvas.clear(Skia.Color('transparent'));
const healPaint = own(Skia.Paint());
healPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), healPaint);
}
}
}