diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index c388ff1..ceaea9d 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -3,11 +3,12 @@ import type { HealSpot } from '../types'; // FX tab > HEAL — the dust brush, and the patch search behind it. // // A spot is a circle on the rendered photo plus the patch it borrows: the -// renderer copies the pixels at (sx, sy) onto (x, y) and feathers the edge, so -// a repair is a draw of the same picture rather than a blur over the dust. All -// five numbers are fractions of the render — x/y/sx/sy of its width and height, -// r of its width — which is what makes one set of spots survive the preview and -// the export rendering the same photo at two sizes, and keeps the circle round +// renderer copies the pixels at (sx, sy) onto (x, y), feathers the edge, and +// shifts what it pasted onto the light of the place it landed in, so a repair +// is a draw of the same picture rather than a blur over the dust. All five +// numbers are fractions of the render — x/y/sx/sy of its width and height, r of +// its width — which is what makes one set of spots survive the preview and the +// export rendering the same photo at two sizes, and keeps the circle round // whatever the photo's shape. // // There is no ceiling on the list. The shader is built to carry exactly the @@ -120,15 +121,47 @@ export function healUniforms(spots: HealSpot[], width: number, height: number): u.set([s.x, s.y, s.r, 0], i * 4); u.set([s.sx, s.sy, 0, 0], (n + i) * 4); } - u.set([width, height, HEAL_FEATHER, 0], n * 2 * 4); + u.set([width, height, 0, 0], n * 2 * 4); return u; } +// The ring the pasted circle is matched at, and how its taps are laid out. +// Measured just OUTSIDE the brush (RING_R of its radius, the same radius the +// search reads a spot's light at), because that is the only ring with nothing +// of the repair in it: inside the circle is the borrowed patch at one radius and +// the speck being covered at another, and a light read off either of those is +// the dust talking rather than the photo. Outside, both sides are photographs — +// the ground the repair has to sit in, and the ground the patch was borrowed +// from. Eight taps are spread around it, at the SAME eight places on both +// sides, so the grain and the detail that differ between two patches average +// out of the difference and what is left is the light: the shift the patch has +// to be pasted with to carry this photo's colour and lighting instead of the one +// it was borrowed from. Reading it at the same radius the search gates on is +// what makes the two agree: a candidate that passed the gate was already within +// LIGHT_GATE of this ring, so the shift it now gets is bounded by it. +const BLEND_TAPS = 8; + // One unrolled block per spot. SkSL indexes a uniform array by constant only // (see TONE_SKSL's mixer), so the spots are written out rather than looped, and // the shader is built for the count it is handed rather than for a capacity — // that is what lets the list be uncapped. A count costs one RuntimeEffect to // compile, so the renderer caches them by count (exportEngine's healEffectFor). +// +// What is pasted is the patch's own pixels SHIFTED onto the light of the place +// it lands in — the gradient of the borrowed texture, carried at the level of +// the destination: the border is made to match, and everything inside keeps the +// texture it was borrowed for. That is the whole of "seamless" the brush needs: +// at the rim the patch sits within a level or two of the photo around it, +// instead of up to LIGHT_GATE levels off it, so the repair stops reading as a +// soft blotch of its own and the feather has almost nothing left to hide. +// +// ponytail: the shift is ONE number per spot, taken over the rim, so a border +// the two patches disagree about along its length is only matched on average — +// a repair across a hard edge keeps a faint step where the edge crosses its rim. +// The other half of the Poisson solve (a correction that bends inside the +// circle, a Jacobi solve over the spot's own box) is a ping-pong pass per spot +// and buys nothing on the skin, sky and sand this tool is aimed at. It lands +// when a hard edge through a repair shows up as a step the eye can find. const spotBlock = (i: number) => ` { float4 s = spots[${i}]; @@ -137,8 +170,19 @@ const spotBlock = (i: number) => ` float d = distance(pos, s.xy * size.xy); if (d < rad) { float4 t = srcs[${i}]; - half m = half(1.0 - smoothstep(rad * size.z, rad, d)); - c = mix(c, img.eval(pos + (t.xy - s.xy) * size.xy), m); + float2 pd = s.xy * size.xy; + float2 ps = t.xy * size.xy; + float r = rad * ${HEAL_FEATHER}; + float rr = rad * ${RING_R}; + half3 shift = half3(0.0); + for (int k = 0; k < ${BLEND_TAPS}; k++) { + float a = float(k) * 6.283185307 / float(${BLEND_TAPS}); + float2 o = float2(cos(a), sin(a)) * rr; + shift += img.eval(pd + o).rgb - img.eval(ps + o).rgb; + } + shift *= 1.0 / float(${BLEND_TAPS}); + half m = half(1.0 - smoothstep(r, rad, d)); + c = mix(c, img.eval(pos + ps - pd) + half4(shift, 0.0), m); } } }