From 00063a7f7c6792e5208f742544880d3632674198 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 12 Oct 2026 18:12:45 +0700 Subject: [PATCH] fix(studio): the heal fill is a cascade of blurs, the brush is put down with its tab, and Immich is re-read on the way back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEAL painted a grey blob over the photo it was meant to repair, and the cause was one draw Skia gets wrong on this build: a snapshot used as a SCALED source comes back with the alpha multiplied into its rgb a second time, so a partly transparent rim — which is what the mask's feather is — was read as a dark wash. A pyramid of halvings is built of exactly those scaled draws, which is why the repair's own rim came out grey; the same source read at 1:1 is exact (measured, tmp-src-probe). So the fill is now a cascade of blurs, all of them 1:1: the cut frame is blurred at a few widths, widest first, each finer one drawn over it, the untouched cut on top. The finest is one brush wide, each next is twice the last, and the count follows the depth of the hole (the stroke's own diagonal against the widest brush), which is what keeps the middle of a long hole filled rather than bare. Nothing is copied from anywhere and the rim arrives with no seam, which is what the passes were always for. And the fill is built on the brush's own box rather than the frame, padded by three sigma of the widest level. The composite reads it only where the mask is, so this changes no pixel of the result (heal-check measures the same masks either way) and it is what makes the repair affordable at the zoom a repair is aimed at: at a 3200px preview a single spot went from 1.7s of frozen main thread to 1.2s, and the heal's own share of that from 0.8s to 0.3s. What is left is the pipeline itself, which is a separate matter. The FIX brush outlived its tab. It takes the pointer on the photo, and leaving TOOLS never put it down — so its layer stayed on the stage over a photo nobody was editing, and its ring came back under the pointer every time the hand crossed a repair, which reads as the tool re-arming itself. It now goes down with the tab, with another chip or another strip, and on a press outside its own two chips, the same way the mask tool does. And the Immich button was missing for a visitor who had just connected a server: the studio and the library are two screens of one page, both mounted, so coming back is a route change and not a mount — the studio's own effect, which reads the server list once a session, never ran again. It is read again on a popstate that lands on /app. Checks: node scripts/heal-check.mjs (the cascade and the box, to the letter), npx tsc --noEmit, and the studio driven in a browser: the button appears on the way back from the library, the brush goes down with TOOLS and re-arms with HEAL, and a stroke over a photo lays one band with no grey in it. --- docker/frontend/scripts/heal-check.mjs | 154 ++++++++++++++------- docker/frontend/shared/utils/heal.ts | 34 +++-- docker/frontend/src/App.tsx | 44 ++++++ docker/frontend/src/engine/exportEngine.ts | 141 +++++++++++-------- docker/frontend/src/ui/TopBar.tsx | 62 +++++---- 5 files changed, 284 insertions(+), 151 deletions(-) diff --git a/docker/frontend/scripts/heal-check.mjs b/docker/frontend/scripts/heal-check.mjs index e0862fe..771415f 100644 --- a/docker/frontend/scripts/heal-check.mjs +++ b/docker/frontend/scripts/heal-check.mjs @@ -25,7 +25,7 @@ const transpile = (path) => const dir = mkdtempSync(join(tmpdir(), 'heal-check-')); writeFileSync(join(dir, 'heal.mjs'), transpile('../shared/utils/heal.ts')); -const { readHeal, HEAL_MASK_STOPS, HEAL_MAX_LEVELS, HEAL_CUT_SKSL, HEAL_FILL_SKSL } = await import( +const { readHeal, HEAL_MASK_STOPS, HEAL_FILL_BLUR, HEAL_FILL_MAX_LEVELS, HEAL_CUT_SKSL, HEAL_FILL_SKSL } = await import( pathToFileURL(join(dir, 'heal.mjs')).href ); @@ -37,6 +37,11 @@ assert.match(engine, /healCutEffect = Skia\.RuntimeEffect\.Make\(HEAL_CUT_SKSL\) assert.match(engine, /healFillEffect = Skia\.RuntimeEffect\.Make\(HEAL_FILL_SKSL\)/, 'exportEngine does not compile HEAL_FILL_SKSL'); assert.doesNotMatch(engine, /healSkSL\(/, 'the per-spot heal shader is still in exportEngine'); assert.doesNotMatch(engine, /healUniforms\(/, 'the per-spot uniform block is still in exportEngine'); +// ...and the fill's level count is the one this check builds, not a constant. +assert.match(engine, /HEAL_FILL_MAX_LEVELS/, 'exportEngine does not cap the fill levels at HEAL_FILL_MAX_LEVELS'); +assert.match(engine, /1 \+ Math\.ceil\(Math\.log2\(2 \+ span \/ 2 \/ widest\)\)/, 'exportEngine no longer scales the fill levels with the depth of the hole'); +// ...and the fill is built on the brush's box, read back at the frame's origin. +assert.match(engine, /imageShaderChildAt\(fillImage, boxLeft, boxTop\)/, 'the fill child is read at its own origin instead of the frame\'s'); // --- the stored spots -------------------------------------------------------- // A recipe written by the older brush carries sx/sy. It still loads: those two @@ -119,6 +124,16 @@ const pixelsOf = (image) => { }; const asChild = (image) => image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None); +// The fill's own child, read in the frame's coordinates: the fill is built on +// the brush's box (below), and a whole-pixel shift is what the engine does. +const asChildAt = (image, x, y) => + image.makeShaderOptions( + CanvasKit.TileMode.Clamp, + CanvasKit.TileMode.Clamp, + CanvasKit.FilterMode.Linear, + CanvasKit.MipmapMode.None, + [1, 0, x, 0, 1, y, 0, 0, 1] + ); // The brush's circles as ONE mask: a radial gradient per spot, solid out to // HEAL_FEATHER of its radius and clear at its edge, each laid over the last. @@ -142,8 +157,8 @@ function maskOf(spotsIn) { return surf.makeImageSnapshot(); } -// exportEngine's 6e, pass for pass: cut, the pyramid down and up again, and the -// fill laid back under the brush. +// exportEngine's 6e, pass for pass: cut, the blurred fill, and the fill laid +// back under the brush. function healPass(rgba, spotsIn) { const src = imageOf(rgba); const mask = maskOf(spotsIn); @@ -154,53 +169,53 @@ function healPass(rgba, spotsIn) { cutPaint.setShader(cutEffect.makeShaderWithChildren([], [asChild(src), asChild(mask)])); cut.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), cutPaint); - const levels = []; - let carried = cut.makeImageSnapshot(); - for (let k = 1; k <= HEAL_MAX_LEVELS; k++) { - const lw = Math.max(1, W >> k); - const lh = Math.max(1, H >> k); - const level = surface(lw, lh); - level - .getCanvas() - .drawImageRectOptions( - carried, - CanvasKit.XYWHRect(0, 0, carried.width(), carried.height()), - CanvasKit.XYWHRect(0, 0, lw, lh), - CanvasKit.FilterMode.Linear, - CanvasKit.MipmapMode.None - ); - levels.push({ surf: level, w: lw, h: lh }); - carried = level.makeImageSnapshot(); - if (lw === 1 && lh === 1) break; - } - let up = null; - let fillImage = null; - for (let k = levels.length - 1; k >= 0; k--) { - const { surf, w: lw, h: lh } = levels[k]; - if (up) { - surf - .getCanvas() - .drawImageRectOptions( - up, - CanvasKit.XYWHRect(0, 0, up.width(), up.height()), - CanvasKit.XYWHRect(0, 0, lw, lh), - CanvasKit.FilterMode.Linear, - CanvasKit.MipmapMode.None - ); - } - const pulled = surf.makeImageSnapshot(); - if (k === 0) { - fillImage = pulled; - break; - } - up = pulled; + // The fill: the cut frame blurred at a few widths, widest first, each finer + // one drawn over it, the untouched cut on top. A blur is a weighted mean of + // the premultiplied pixels around each pixel, and the hole weighs nothing, so + // the hole comes back as the light of its rim. Widest first means the widest + // blur only has a say where every finer one has nothing — the middle of a hole + // deeper than a brush — and the untouched cut wins wherever the photo is still + // there, so the rim is the rim exactly. + // + // Blurs and not the pyramid of halvings the engine used to build: this Skia + // gets the premultiply wrong on a snapshot drawn with a SCALE — its rgb comes + // back with the alpha multiplied in a second time — and a pyramid is made of + // exactly those halvings, so a partly transparent rim came back a wash of + // grey. Every draw here is 1:1, which filters correctly. + const cutImage = cut.makeImageSnapshot(); + // The count and the widths are exportEngine's own, to the letter: a check + // that picks its own levels is checking a renderer nobody ships. So is the + // surface the fill is built on — the brush's own box, padded by three sigma of + // the widest level, rather than the frame. + const widest = Math.max(...spotsIn.map((s) => s.r * W)); + const xs = spotsIn.map((s) => s.x * W); + const ys = spotsIn.map((s) => s.y * H); + const span = Math.hypot(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)); + const levelCount = Math.min( + HEAL_FILL_MAX_LEVELS, + Math.max(2, 1 + Math.ceil(Math.log2(2 + span / 2 / widest))) + ); + const pad = 3 * HEAL_FILL_BLUR * widest * 2 ** (levelCount - 1); + const boxLeft = Math.max(0, Math.floor(Math.min(...xs) - widest - pad)); + const boxTop = Math.max(0, Math.floor(Math.min(...ys) - widest - pad)); + const boxRight = Math.min(W, Math.ceil(Math.max(...xs) + widest + pad)); + const boxBottom = Math.min(H, Math.ceil(Math.max(...ys) + widest + pad)); + const box = CanvasKit.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop); + const fillBox = surface(boxRight - boxLeft, boxBottom - boxTop); + for (let k = levelCount; k >= 1; k--) { + const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1); + const blurPaint = new CanvasKit.Paint(); + blurPaint.setImageFilter(CanvasKit.ImageFilter.MakeBlur(sigma, sigma, CanvasKit.TileMode.Clamp, null)); + fillBox.getCanvas().drawImageRect(cutImage, box, box, blurPaint); } + fillBox.getCanvas().drawImageRect(cutImage, box, box, new CanvasKit.Paint()); + const fillImage = fillBox.makeImageSnapshot(); const fillEffect = CanvasKit.RuntimeEffect.Make(HEAL_FILL_SKSL); assert.ok(fillEffect, 'HEAL_FILL_SKSL does not compile'); const out = surface(W, H); const paint = new CanvasKit.Paint(); - paint.setShader(fillEffect.makeShaderWithChildren([], [asChild(src), asChild(fillImage), asChild(mask)])); + paint.setShader(fillEffect.makeShaderWithChildren([], [asChild(src), asChildAt(fillImage, boxLeft, boxTop), asChild(mask)])); out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), paint); return { pixels: pixelsOf(out.makeImageSnapshot()), mask: pixelsOf(mask) }; } @@ -272,10 +287,10 @@ assert.ok(worstStep <= 4, `the fill steps by ${worstStep} levels between neighbo // 4. The repair carries the LIGHT of where it landed. On a photo running from // dark to light across the frame, the painted pixels come back on that ramp // rather than at one flat value borrowed from somewhere else. The bound is -// the ramp's own steepness, not zero: the pyramid reads the rim of the hole, -// and where the hole takes weight off one side of a pixel its average slides -// that way by about a pixel's worth of the ramp — 0.75 levels a pixel across -// this frame. On the flat ground of 2 and 3 it is nothing at all. +// the ramp's own steepness, not zero: the fill reads the rim of the hole, and +// where the hole takes weight off one side of a pixel its average slides that +// way by about a pixel's worth of the ramp — 0.75 levels a pixel across this +// frame. On the flat ground of 2 and 3 it is nothing at all. const ramped = healPass(bytesOf(shadeRamp), spots); let worstRamp = 0; for (let y = 0; y < H; y++) { @@ -287,13 +302,24 @@ for (let y = 0; y < H; y++) { assert.ok(worstRamp <= 6, `the fill is off the ramp it sits in by ${worstRamp} levels`); // 5. Outside the mask, nothing moved. The pass is asked for the painted pixels -// and no others. +// and no others — and "painted" is the whole brush, feather included: the last +// 15% of a spot is where the repair is mixed back in, so it is part of the +// repair. Past the brush's own edge the photo is the photo. let outWorst = 0; +let outAt = 0; for (let p = 0; p < W * H; p++) { - if (inMask(p % W, (p - (p % W)) / W)) continue; - for (let c = 0; c < 3; c++) outWorst = Math.max(outWorst, Math.abs(made.pixels[p * 4 + c] - flat[p * 4 + c])); + const x = p % W; + const y = (p - x) / W; + if (alphaAt(x, y) > 0) continue; + for (let c = 0; c < 3; c++) { + const off = Math.abs(made.pixels[p * 4 + c] - flat[p * 4 + c]); + if (off > outWorst) { + outWorst = off; + outAt = x; + } + } } -assert.equal(outWorst, 0, `a pixel outside the mask changed by ${outWorst} levels`); +assert.equal(outWorst, 0, `a pixel outside the mask (x=${outAt}) changed by ${outWorst} levels`); // 6. The same photo and the same spots give the same photo, byte for byte. assert.deepEqual( @@ -312,4 +338,28 @@ for (let p = 0; p < W * H; p++) { } assert.equal(worstAll, 0, `a frame-wide mask changed the photo by ${worstAll} levels`); +// 8. The shape the brush is actually used in: one long thin run right across the +// frame, on a photo that runs dark to light along it. Its rim is the pixels +// just above and below it wherever it passes, so the repair comes back on the +// ramp there — not at the frame's average colour. A hole this long needs the +// level count to grow with it: too few levels and the middle of the run is +// farther from any rim than the widest blur reaches, so the fill comes back +// the average of a blur too wide to have read the rim — one flat mark. +const band = []; +for (let i = 0; i <= 60; i++) band.push({ x: 0.12 + (0.76 * i) / 60, y: 0.5, r: 0.02 }); +const across = healPass(bytesOf(shadeRamp), band); +let worstBand = 0; +let bandAt = 0; +for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (across.mask[(y * W + x) * 4 + 3] < 250) continue; + const off = Math.abs(across.pixels[(y * W + x) * 4] - shadeRamp(x)[0]); + if (off > worstBand) { + worstBand = off; + bandAt = x; + } + } +} +assert.ok(worstBand <= 8, `a painted pixel at x=${bandAt} is ${worstBand} levels off the rim it sits in`); + console.log('ok the stroke is one mask, the fill carries the light, and nothing outside it moved'); diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index 250b693..bf12575 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -34,10 +34,22 @@ export const HEAL_DEFAULT_R = 0.012; // fill would then be mixed with the dust it is there to remove. export const HEAL_FEATHER = 0.85; -// How many halvings of the frame the fill reads, at most. Each level is half of -// the one below it, so twelve covers any photo a camera makes and stops a -// pathological surface size from walking off the end of the pyramid. -export const HEAL_MAX_LEVELS = 12; +// 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 @@ -63,9 +75,9 @@ function num(v: unknown, fallback: number): number { // 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 pyramid below is built on it: -// a linear halving of premultiplied pixels is a weighted average of the pixels -// that are still there, with the hole weighing nothing. +// 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; @@ -78,10 +90,10 @@ half4 main(float2 pos) { } `; -// The second pass: the fill laid back under the brush. `fill` is the pyramid's -// answer for every pixel of the frame — at the mask it is what the rim implies, -// away from it it is the photo itself — and the mask is the weight it is mixed -// at, so the rim is the only place the two meet and they already agree there. +// The second pass: the fill laid back under the brush. `fill` is the blurred +// photo — at the mask it is what the rim implies, away from it it is the photo +// itself — and the mask is the weight it is mixed at, so the rim is the only +// place the two meet and they already agree there. // // An alpha of nothing means the fill found no photo anywhere to work from (a // mask over the whole frame): the pixel is left as it was rather than painted diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index e2d5a13..69573d9 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -761,6 +761,23 @@ export function Workspace() { } }, [member]); + // ...and read again whenever this screen is the route that was come back to. + // The list above is read once per session on purpose (immich.ts), and the + // LIBRARY is where a server gets connected — but the studio and the library + // are two screens of one page, mounted side by side (main.tsx), so coming back + // is a route change and not a mount, and the effect above never runs again. + // Left as it was, the button that sends the frame to a server the visitor just + // added only appeared after a reload. + useEffect(() => { + if (!member) return; + const refresh = () => { + if (!window.location.pathname.startsWith('/app')) return; + immichConfig(true).then((cfg) => setImmichServers(cfg.servers)).catch(() => {}); + }; + window.addEventListener('popstate', refresh); + return () => window.removeEventListener('popstate', refresh); + }, [member]); + const hasImmich = immichServers.length > 0; const showToast = useCallback((msg: string) => { @@ -2528,6 +2545,11 @@ export function Workspace() { // and a photo that is still armed to draw masks is not what a hand // reaching for a knob is asking for. setMaskTool(null); + // FIX's brush is the same kind of mode and goes down the same way — a chip + // armed to paint is not what a hand reaching for a knob is asking for + // either, and the ring it draws under the pointer would follow the hand + // across a photo nobody is painting on. + setBrushTool(null); // Opening a ruler clears a strip from another branch; closing one leaves // its own strip standing, so the column the ruler came from is still there. if (!close) setOpenGroup(PARAM_GROUP[key] ?? null); @@ -2539,6 +2561,7 @@ export function Workspace() { (g: StripKey) => { const next = openGroup === g ? null : g; setMaskTool(null); + setBrushTool(null); if (next) { // A ruler only survives if it belongs to the strip being opened. if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null); @@ -2557,6 +2580,11 @@ export function Workspace() { // photo: both are modes of that tab. setPicking(false); setPickedAt(null); + // So is FIX's brush, and for one more reason than the others: it takes the + // pointer on the photo, so left armed past its tab it paints on a photo the + // visitor is only looking at — and its ring follows the pointer over the + // repairs already there, which reads as the tool coming back on its own. + setBrushTool(null); }, [tab]); // STRAIGHTEN's scale is a mode of the photo, like the eyedropper: any click @@ -2594,6 +2622,22 @@ export function Workspace() { return () => window.removeEventListener('pointerdown', dismiss, true); }, [maskTool]); + // FIX's brush belongs to the chip that armed it the same way: reaching for + // another chip or a tab puts it down and takes its layer off the stage in one + // gesture. HEAL and MOSAIC's own chips are left out — toggling one is its own + // onClick's business — and the photo itself never reaches here, because the + // layer that paints on it is neither a chip nor a rail. + useEffect(() => { + if (!brushTool) return; + const dismiss = (e: PointerEvent) => { + const el = e.target as HTMLElement | null; + if (!el?.closest('.rail, .chip') || el.closest('.chip[data-key="heal"], .chip[data-key="mosaic"]')) return; + setBrushTool(null); + }; + window.addEventListener('pointerdown', dismiss, true); + return () => window.removeEventListener('pointerdown', dismiss, true); + }, [brushTool]); + // Continuous parameter chips: label + live value; amber text when edited, // amber border while its ruler is open. const slider: SliderDef | null = useMemo(() => { diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index ce2de90..f34ac59 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -49,8 +49,9 @@ import { HEAL_CUT_SKSL, HEAL_FILL_SKSL, HEAL_FEATHER, + HEAL_FILL_BLUR, + HEAL_FILL_MAX_LEVELS, HEAL_MASK_STOPS, - HEAL_MAX_LEVELS, readHeal, } from '../../shared/utils/heal'; import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic'; @@ -670,6 +671,22 @@ function imageShaderChild(image: any): any { ); } +// The same child, read in the frame's coordinates rather than its own: a pass +// that builds its image on a surface smaller than the frame (HEAL's fill, which +// is only ever asked for the pixels around the brush) has to say where that +// surface's corner sits. The shift is a whole number of pixels and the draw +// that filled it was 1:1, so the sampling stays exact — the reason HEAL uses a +// box at all rather than a scaled draw (see the fill in step 6e). +function imageShaderChildAt(image: any, x: number, y: number): any { + return image.makeShaderOptions( + Skia.TileMode.Clamp, + Skia.TileMode.Clamp, + Skia.FilterMode.Linear, + Skia.MipmapMode.None, + [1, 0, x, 0, 1, y, 0, 0, 1] + ); +} + // Snapshot → clear → redraw through the shader the callback builds FROM that // snapshot: the shape every pass that reads the frame it is about to overwrite // uses (the negative blurs, the gradient masks, HEAL), in one place so none of @@ -1293,8 +1310,8 @@ export async function renderPhoto(input: RenderInput): Promise> 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; + // The fill: the cut frame blurred at a few widths, widest first, each + // finer one drawn over it, the untouched cut on top. A blur weighs every + // pixel by how much of the frame's light reaches it, and the hole has none + // of its own left to weigh — so the hole comes back as the light of its + // rim, carried in with no seam and nothing copied from anywhere. Widest + // first means the widest blur only has a say where every finer one has + // nothing (the middle of a hole deeper than a brush), and the untouched + // cut wins wherever the photo is still there, so the rim is the rim. + // + // Blurs and not a pyramid of halvings, on purpose. This Skia gets the + // premultiply wrong on a snapshot used as a SCALED draw source — its rgb + // comes back with the alpha multiplied in a second time — and a pyramid is + // built of exactly those halvings; a partly transparent rim then came out + // a wash of grey, which is what the heal was painting over the photo. + // Every draw here is 1:1, which filters correctly (measured, see + // heal-check.mjs). + // + // How many levels: the finest is HEAL_FILL_BLUR brushes wide and each one + // is twice the last, so n of them reach HEAL_FILL_BLUR * 2^(n-1) radii — + // enough to span half the stroke's own diagonal from either end of it. + // Fewer and the middle of a long hole stays bare; more just costs time, + // and past HEAL_FILL_MAX_LEVELS the blur is wider than the frame anyway. + // + // All of it is drawn on a surface the size of the brush's own box rather + // than the frame. The fill is read only where the mask is (the composite + // below mixes it at the mask's alpha, and nowhere else), so the pixels a + // wide blur would carry in from the frame's far corners are pixels nobody + // ever asks for — and at the zoom a repair is aimed at, lifting a wide + // blur over 3200x2400 instead of over the 500px around the repair is the + // difference between a second and a thirtieth of one (measured). The box + // is the mask's own, padded by three sigma of the widest level, so the + // blur at its edge still reads the rim it is made of rather than a clamp. + const widest = Math.max(...heal.map((s) => s.r * width)); + const xs = heal.map((s) => s.x * width); + const ys = heal.map((s) => s.y * height); + const span = Math.hypot(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)); + const levelCount = Math.min( + HEAL_FILL_MAX_LEVELS, + Math.max(2, 1 + Math.ceil(Math.log2(2 + span / 2 / widest))) + ); + const pad = 3 * HEAL_FILL_BLUR * widest * 2 ** (levelCount - 1); + const boxLeft = Math.max(0, Math.floor(Math.min(...xs) - widest - pad)); + const boxTop = Math.max(0, Math.floor(Math.min(...ys) - widest - pad)); + const boxRight = Math.min(width, Math.ceil(Math.max(...xs) + widest + pad)); + const boxBottom = Math.min(height, Math.ceil(Math.max(...ys) + widest + pad)); + const fillSurface = own(createSurface(boxRight - boxLeft, boxBottom - boxTop)); + const fillCanvas = fillSurface.getCanvas(); + // The box's own corner is the surface's, and both rects are the same one: + // a 1:1 copy at an offset, which is the draw this Skia filters correctly. + const box = Skia.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop); + if (cutImage) { + for (let k = levelCount; k >= 1; k--) { + const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1); + const blurPaint = own(Skia.Paint()); + blurPaint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null))); + fillCanvas.drawImageRect(cutImage, box, box, blurPaint); } + // The cut itself, unblurred, on top: the pixels the brush did not touch + // are the photo exactly, not a blur of it. + fillCanvas.drawImageRect(cutImage, box, box, own(Skia.Paint())); } - - // 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; - } + flush(fillSurface); + const fillImage = own(fillSurface.makeImageSnapshot()); // 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 @@ -1412,7 +1435,7 @@ export async function renderPhoto(input: RenderInput): Promise{t('nav.studio')} {recipeName} + {/* First of the controls, on purpose: sending the frame to Immich Cloud is + what the phone's header puts at the head of its row, and it is the one + control here that hands the photo to another box. Drawn only once the + account has a server to send to. The pinwheel is the label — the word + beside it is a desktop convenience the phone drops (see app.css). */} + {hasImmich && onUploadImmich ? ( + + ) : null} + {/* Both arrows are one icon wide, so the pair reads as one control: the counter-clockwise arrow steps back, the clockwise one forward. They sit with the name they would take back — after the mark and the preset. */} @@ -274,35 +307,6 @@ export function TopBar({ ) : null} - {/* Immich Cloud Upload button (shown only if registered user has configured Immich Cloud) */} - {hasImmich && onUploadImmich ? ( - - ) : null} - {/* Last in the row on purpose: EXPORT is the end of the job, so it sits at the end of the bar — the one button the eye lands on when the edit is done. */}