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. */}