HEAL came back a stain larger and softer than the stain it was asked to
remove, and the model's half of it answered in a colour that appears nowhere
near the repair. Three causes, all inside `engine/inpaint.ts`:
- The fallback's reading ring was the hole dilated by two pixels whatever the
brush, so the boundary of the fill was the dirt's own penumbra and the fill
came back as the dirt. The pad is now proportional to the hole's own radius
(`rimPad`), the fill is an inverse-square average of the clean ring relaxed
into one field by 36 Jacobi rounds, and the hole's grain is read against a
2px box of the clean pixels and taken from the pixel it mirrors through its
own middle. The texture half before this was a no-op — `detail = rgba[p] -
fR[idx]` with `fR[idx]` the same pixel's own colour, so every donor handed
over zero and the hole was the smooth field alone (15.6% of the ground's
detail). Measured on a stain of radius 10 with a halo to 16 under a 12px
brush (`probe-fallback.mjs`): the repair landed 22.3 levels below the ground
it sits in, and comes back 0.06 off it.
- The plate the model reads under the mask was the mean of every pixel of the
crop, so the network met a hard step at the mask's own edge and answered in
that colour. It is now the rim's own light spread inward (`cropPlate`), and
the mask is downsampled by "any hole pixel in this cell" rather than by a
corner sample. Step across the mask's edge: 69.3 levels before, 9.5 after.
- The same repair came back different every time the reader zoomed out of it:
the crop was sized from the stage's own pixels on the zoom ladder
(`PREVIEW_QUANTUM`), so each zoom handed the model a different grid, and
every grid invents its own pixels. The crop's box is now its share of the
FRAME (`INPAINT_REF_LONG`, `exportEngine` 6e), so one spot is one grid at
every zoom and at the export's own size.
The patch is deliberately not baked into the photo: a recipe stays reusable on
other frames and the repair stays a pass. ponytail: the pixels handed to the
model are still read from whatever the stage rendered at, so the answer is the
same repair to within the resampling of its input; a patch cache keyed on the
frame's identity and the spots is the rung that makes it identical, and the one
to take if a zoom still shows a repair move.
VIGNETTING now runs both ways (-10..10): down darkens the four corners, up
lifts the lens' own falloff out of them with a white ink — the same curve and
the same weight. EV was defined and live on the phone but absent from the wide
panel's own Light row, which is `DevelopPanels.tsx` and nothing else.
`npx tsc --noEmit`, `vite build`, `heal-check`, `studio-drag` and the other
suites pass. `studio-export-check` fails one assertion here on the code at HEAD
as well — a frame left behind files its waiting look 9s after the pick — so it
is not this change.
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.
The two rounds were meant to answer the stroke sooner, but a preview cannot be
half a repair: the blurred fill is what a machine without the runtime gets, and
with it on the stage a spot of dirt came back smeared rather than gone — worst of
all on a run of strokes, where each new one cancels the model's round that was
still on its way and leaves the blur standing longest. The repair that works is
the one that waits for the model, and the wait is worth it.
The worker stays available but is switched off with the rest: this reverts the
runtime's proxy along with the second round, so the stage is exactly what it was
before, lag and all.
Reverts 5489114.
The graph is seconds of solid arithmetic however large the crop is, and it ran
where the pointer lives: the brush lagged behind the hand for exactly as long as
the model took. The runtime's wasm provider now works in a worker of its own, so
the page keeps drawing while the repair is thought through.
The wait itself stays — it is the model's, not the page's — so a preview paints
the blurred fill first and lets the model's version of the same mask replace it.
Filing and export still wait for the model: the faster answer is for the eye
waiting on the brush and for nothing else.
Measured on the same photo and stroke, worst main-thread gap 3703 ms before and
487 ms after; previews land at 1.0 s and 5.3 s, and the model's render is the one
left on the stage.
The dust brush filled its hole with a blur of the hole's own rim — the light
beside it, carried in. Right for a speck on a wall, and visibly thin over
anything with structure: a stroke across a shirt came back as the average of a
shirt. The fill is now a learned one.
engine/inpaint.ts runs MI-GAN (andraniksargsyan/migan, the same
`migan_pipeline_v2.onnx` inpaint-web ships) through onnxruntime-web on the
visitor's own machine: 27MB of weights in public/models, WebGPU where the graph
fits the provider, the processor otherwise, one warm-up pass so the first repair
is not the one that pays for the kernels. It is handed the masked crop and
nothing else — the model's mask polarity is 255 KEEP / 0 HOLE, the opposite of
the obvious guess, and the other way round repaints the frame and leaves the
hole (measured).
Step 6e of the renderer cuts the crop at the resolution the model will actually
run at (512 on its longest side — the graph is fully convolutional and would
take a whole frame, and ten seconds over one), builds the mask from the brush's
own circles, and draws the answer into the fill surface at its own place in the
box. The composite, the feathered mask and the untouched frame around it are
unchanged, so nothing outside the mask moves — and when the model cannot be had
the blurred fill is still built first and still stands. The preview gets this
for free: renderPhoto was already async and the preview was already debounced.
Node probe: the model + polarity + read/write path run; heal-check.mjs keeps
measuring the fallback and now asserts the wiring and the polarity. Browser:
engine/inpaint.ts in a real Chromium returns a patch (hole moves, kept pixels
within 0.02 of a level), and the built app renders a 42-spot stroke in 4.7s on
single-threaded wasm with no fallback warning — 1.15s and the warning when the
model is taken away.