From f1385d8a085b2a8f238862a823993d93245e0d29 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 22:03:33 +0700 Subject: [PATCH] web: hide what the brush paints, in cells, and never in a blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEAL borrows a patch of the photo and pastes it over what the brush covers. The other half of the same gesture is the opposite thing — a patch of the photo the user does not want shown to anyone, a face at a table, a plate, a badge, the number on a note at the edge of the frame — and hiding it is the second tool on the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools share was already shared by the time this landed: one layer, one circle riding the pointer, one wheel, one gesture that is one undo step, spots stored as fractions of the render so the preview and the export draw the same circle. Only what a spot MEANS split, and it split into two files over the piece of physics both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under them for the size and the spacing of the circle they both lay. What a mosaic spot does is destroy what it covers rather than replace it. The frame is cut into square cells of MOSAIC_CELL (0.02 of the width — 5.12px on the probe's 256px photo, 40px on a 2048px one) and every pixel of a cell takes the colour found at that cell's own middle, read with img.eval so the block is the snapshot's bilinear tap and not a neighbour's cell. What is under the circle is still a picture of that place, at a resolution nothing can be read out of. A blur was never in the running: it leaves the SHAPE of what it hides — a face under a blur is still a face, a plate still a plate — and the arrangement is exactly what the user is asking to keep to themselves. Cells coarse enough to lose the arrangement are what "do not show this to anyone" needs, and the blockiness is the price of it. The cells are one grid over the whole frame, not one grid per spot: a pixel's cell comes from its own position, and every block reads the snapshot rather than the output, so two overlapping spots never pixelate a pixelation and a run lays one band with no seam where its circles cross. The rim is hard for the same reason in reverse — a feather would mix the cells back into the sharp photo along the edge, which is a half-hidden thing leaking the arrangement it exists to hide. A mosaic spot borrows nothing, so the layer draws no donor circle beside the cursor: the second circle appears only when a spot has a source ('sx' in it), which is the one place the two tools' DOM parts company. Each tool keeps its own brush size, and each CLEAR chip clears only its own list, because the size a dust speck is healed at is never the size a face is hidden at. The recipe carries the list as adjustments.mosaic — x, y, r, the same fractions HEAL stores, and readMosaic guards them the same way — and the renderer builds one RuntimeEffect per count exactly as it does for HEAL (mosaicEffectFor), the pass sitting right after the heal pass so a repair made on the same photo ends up underneath the cells that hide the rest of it. The backend needed nothing: a recipe is spread through as it stands, so a saved photo keeps its mosaic and a shared one opens with it. Verified: mosaic-skia-lab.cjs (scratchpad, CanvasKit against the bundled mosaic.ts) — 27 passed, 0 failed: the cell rides in the frame block in the render's own pixels and is a fraction of the WIDTH, so it is square on any shape; 4912 cells inside a spot each carry one colour, and 164/164 of them carry the colour at their own middle; the 2px white dot on the dark square reads 250 -> 20; nothing outside the circle changed (0 stray pixels) while the cells reach the rim (852 pixels at the edge); a spot wider than the frame still runs; overlapping spots share one grid over 6335 pixels with 0 differing between them (no cascade); readMosaic refuses a zero radius, an off-photo spot, junk and a missing list, and keeps a forty-spot list whole. mosaic-probe.cjs (the rebuilt app at http://localhost:8090) — 51 PASS, 0 FAIL, no page errors: FX offers a MOSAIC chip that arms the same brush layer and says which tool it is painting for; the wheel sizes each tool on its own (8.0% up, 5.0% back) and the circle follows it; a click lays exactly one spot with no borrowed patch beside it; the pixels of the cell are one colour (0 levels across, cell 5.12px); the dot is unreadable (250 -> 15); nothing outside the circle changed (0 pixels, worst 0) and the cells are not the photo that was there (221/509 pixels changed); UNDO gives the photo back exactly and REDO hides it again; a drag paints ONE band 25.6px wide, as wide as the brush, standing for 5 points of travel and laying 5 spots that leave 0 pixels outside them changed, with the step within a cell 3.43 levels against 21.25 between cells (635 + 157 pairs) — the cells are flat and their borders jump; one gesture is one undo step; arming HEAL and arming MOSAIC hand the pointer over and back with each tool's spots intact; CLEAR hands the photo back pixel for pixel and leaves no chip behind. The probe's own reading is deliberately a shape, not a colour: the app's preview is the engine's render at preview scale with a JPEG on top (and its auto dynamic range), so a cell's colour read back from the base would be two encodings apart. The exact cell colour is the Skia lab's claim, where no encoder sits between the shader and the reading. heal-probe.cjs 49 PASS / 0 FAIL against the same build, heal-search-lab.cjs 15, heal-skia-lab.cjs 27, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8 — the brush HEAL paints with is the one MOSAIC now paints with. Regressions against the rebuilt app, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10, tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend tsc --noEmit clean. ponytail: the cell is a fixed fraction of the width, not a fraction of the brush, so a brush smaller than one cell paints a single block's colour; tying the cell to the radius would mean a cell size per spot in the recipe, which is a recipe change this tool does not need yet. The grid is one grid for the whole frame, so a run of overlapping spots and one wide spot give the same blocks, and the run's circles are laid spot by spot — drawing a run as one region wants a stroke id in the recipe, the same change HEAL's own run is waiting on. A spot is in the recipe by its fractions alone, so what the export prints is the mosaic the user saw, and the original pixels under it are gone from the record on purpose. --- docker/frontend/shared/types/index.ts | 13 ++ docker/frontend/shared/utils/brush.ts | 26 +++ docker/frontend/shared/utils/heal.ts | 22 +-- docker/frontend/shared/utils/mosaic.ts | 104 +++++++++++ docker/frontend/src/App.tsx | 93 ++++++++-- docker/frontend/src/engine/exportEngine.ts | 42 +++++ docker/frontend/src/styles/app.css | 32 ++-- docker/frontend/src/ui/ImageStage.tsx | 204 +++++++++++---------- 8 files changed, 391 insertions(+), 145 deletions(-) create mode 100644 docker/frontend/shared/utils/brush.ts create mode 100644 docker/frontend/shared/utils/mosaic.ts diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index d33596e..f30f117 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -110,6 +110,16 @@ export interface HealSpot { sy: number; } +// FX tab > MOSAIC. One hidden spot: the circle the brush painted on the photo, +// as fractions of the rendered photo — x/y of its width and height, r of its +// width. The same measurement HEAL stores, minus the patch: a mosaic shows +// nothing of what is under it, so there is nothing borrowed to record. +export interface MosaicSpot { + x: number; + y: number; + r: number; +} + export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) contrast: number; // -10 to +10 @@ -148,6 +158,9 @@ export interface ColorAdjustments { // FX tab > HEAL. The spots the dust brush repaired, in the order they were // laid down. Absent = nothing was healed, and the pass is not built at all. heal?: HealSpot[]; + // FX tab > MOSAIC. The spots the hiding brush covered, in the order they were + // laid down. Absent = nothing was hidden, and the pass is not built at all. + mosaic?: MosaicSpot[]; exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. } diff --git a/docker/frontend/shared/utils/brush.ts b/docker/frontend/shared/utils/brush.ts new file mode 100644 index 0000000..ad0d555 --- /dev/null +++ b/docker/frontend/shared/utils/brush.ts @@ -0,0 +1,26 @@ +// The brush's own physics, shared by FX's two tools: HEAL copies a patch of the +// photo over what it paints, MOSAIC hides what it paints, and both lay circular +// spots from the same gesture — a circle that rides the cursor, a stroke laid +// down as the pointer travels, and a wheel that sizes it. What a spot MEANS is +// each tool's own business (heal.ts, mosaic.ts); how big it is and how far apart +// a stroke lays them is this file's. +// +// The bounds are fractions of the photo's width. Past BRUSH_MAX_R a spot is a +// quarter of the frame — a repair would be borrowing a patch from off the +// photo, and a mosaic block would be enormous — and below BRUSH_MIN_R the circle +// is finer than the pixels it is drawn on at preview scale, which is a size the +// user cannot see well enough to aim. +export const BRUSH_MIN_R = 0.003; +export const BRUSH_MAX_R = 0.25; +// How far apart a drag lays its spots, in radii: any closer and the run is the +// same spot written twice, which is work for nothing. +export const BRUSH_SPACING = 0.6; + +// The brush radius after a wheel notch. Multiplicative, so the brush grows by +// the same proportion wherever it starts, and bounded as above — a trackpad's +// small deltas and a mouse's 100px notch are then the same gesture at two +// speeds. +export function wheelBrushR(r: number, deltaY: number): number { + const next = r * Math.exp(-deltaY * 0.0015); + return next < BRUSH_MIN_R ? BRUSH_MIN_R : next > BRUSH_MAX_R ? BRUSH_MAX_R : next; +} diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index 1daefd3..c388ff1 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -26,16 +26,11 @@ import type { HealSpot } from '../types'; // as a faint ring inside the circle, which is a blur of the dust rather than a // repair. sub-pixel at the default brush, still a soft edge at a big one. export const HEAL_FEATHER = 0.85; -// The brush's radius, as a fraction of the photo's width. The wheel walks -// between the bounds multiplicatively, so a trackpad's small deltas and a -// mouse's 100px notch are the same gesture at two speeds; the default is the -// sensor-dust end, where a speck is a few thousandths of the frame. +// The brush's radius, as a fraction of the photo's width — where this tool +// starts, at the sensor-dust end, where a speck is a few thousandths of the +// frame. The bounds and the wheel that moves it between them belong to the +// brush both FX tools share (brush.ts). export const HEAL_DEFAULT_R = 0.012; -export const HEAL_MIN_R = 0.003; -export const HEAL_MAX_R = 0.25; -// How far apart a drag lays its spots, in radii: any closer and the run is the -// same repair written twice, which is work for nothing. -export const HEAL_SPACING = 0.6; // How far the search looks, in radii, and how many directions it looks in. const SEARCH_DISTANCES = [2.6, 4.2, 6.5]; const SEARCH_DIRS = 8; @@ -95,15 +90,6 @@ const num = (v: unknown, fallback: number) => { }; const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); -// The brush radius after a wheel notch. Multiplicative, so the brush grows by -// the same proportion wherever it starts, and bounded — past HEAL_MAX_R a spot -// would borrow a patch from off the photo, and below HEAL_MIN_R it would be -// finer than the pixels it is drawn on at preview scale. -export function wheelHealR(r: number, deltaY: number): number { - const next = r * Math.exp(-deltaY * 0.0015); - return next < HEAL_MIN_R ? HEAL_MIN_R : next > HEAL_MAX_R ? HEAL_MAX_R : next; -} - // 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 // spot the brush and the renderer disagree about. diff --git a/docker/frontend/shared/utils/mosaic.ts b/docker/frontend/shared/utils/mosaic.ts new file mode 100644 index 0000000..ca2e3f4 --- /dev/null +++ b/docker/frontend/shared/utils/mosaic.ts @@ -0,0 +1,104 @@ +import type { MosaicSpot } from '../types'; + +// FX tab > MOSAIC — the brush that hides part of a photo. +// +// The gesture is HEAL's, from the same layer and the same wheel: a circle rides +// the cursor, a click or a drag lays spots down, and one gesture is one undo +// step. What a spot DOES is the opposite of a repair — instead of borrowing +// clean pixels it destroys them. Inside the circle the frame is cut into square +// cells and every pixel of a cell takes the colour found at the cell's own +// middle, so what is under the circle is still a picture of that place, at a +// resolution no one can read a face, a plate or a note out of. +// +// A blur is the wrong tool for this. A blur leaves the SHAPE of what it hides: +// a face under a blur is still a face, a plate still a plate — the thing the +// user wanted kept to themselves is exactly the arrangement, and blur keeps it. +// Cells coarse enough to lose the arrangement are what "do not show this to +// anyone" actually needs. +// +// x/y are fractions of the render's width and height, r and the cell of its +// width — the same fractions HEAL stores, for the same reason: the preview and +// the export render the same photo at two sizes, and only a fraction means the +// same circle and the same block at both. +export const MOSAIC_CELL = 0.02; +// Where the brush starts: a twentieth of the width, the size of the things a +// photo gets hidden for — a face, a plate, a badge. The wheel takes it from +// there, bounded by BRUSH_MIN_R/BRUSH_MAX_R like the dust brush's. +export const MOSAIC_DEFAULT_R = 0.05; + +const num = (v: unknown, fallback: number) => { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +}; +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +// The stored spots, made readable: numbers, inside the frame — the same guard +// HEAL's list gets, so a hand-written or older file cannot produce a spot the +// brush and the renderer disagree about. +export function readMosaic(mosaic: MosaicSpot[] | undefined): MosaicSpot[] { + if (!Array.isArray(mosaic)) return []; + return mosaic + .map((s) => ({ + x: clamp01(num(s?.x, 0)), + y: clamp01(num(s?.y, 0)), + r: Math.max(0, num(s?.r, 0)), + })) + .filter((s) => s.r > 0); +} + +// The uniform block the shader for `n` spots reads: the circles, then the frame +// the fractions are of — with the cell already turned into the render's own +// pixels, so the blocks are square whatever the frame's shape, the same size on +// the preview and on the export, and one buffer is one upload per render. Its +// length is a function of the list, not a fixed capacity, because the shader +// carries exactly the spots the recipe holds. +export function mosaicUniforms(spots: MosaicSpot[], width: number, height: number): Float32Array { + const list = readMosaic(spots); + const n = list.length; + const u = new Float32Array((n + 1) * 4); + for (let i = 0; i < n; i++) { + const s = list[i]; + u.set([s.x, s.y, s.r, 0], i * 4); + } + u.set([width, height, Math.max(1, MOSAIC_CELL * width), 0], n * 4); + return u; +} + +// One unrolled block per spot, for the reason heal.ts unrolls its own: SkSL +// indexes a uniform array by constant only, so the shader is built for the count +// it is handed rather than for a capacity, and the renderer caches them by count +// (exportEngine's mosaicEffectFor). A spot is a hard-edged circle: the pixelated +// cells stop dead at its rim, because a feather would mix the cells back into +// the sharp photo along the edge — a half-hidden thing, leaking the very +// arrangement the tool exists to hide. +const spotBlock = (i: number) => ` + { + float4 s = spots[${i}]; + if (s.z > 0.0) { + float rad = s.z * size.x; + if (distance(pos, s.xy * size.xy) < rad) { + float2 cell = size.zz; + c = img.eval((floor(pos / cell) + 0.5) * cell); + } + } + } +`; + +// The pass. It reads the pixels the pipeline has already built — HEAL's repairs +// included, since they are the same picture's pixels — and every destination +// pixel of a cell takes the one colour at the cell's middle, so what leaves is +// the photo with a grid in it rather than a photo of a grid. The source is the +// snapshot, never the output, so two overlapping spots never pixelate a +// pixelation: each block is the original block's colour. +export function mosaicSkSL(count: number): string { + return ` +uniform shader img; +uniform float4 spots[${count}]; +uniform float4 size; + +half4 main(float2 pos) { + half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => spotBlock(i)).join('')} + return c; +} +`; +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index e03d12c..f09de44 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -33,12 +33,14 @@ import { type HealSpot, type HslBand, type HslBandId, + type MosaicSpot, type Recipe, } from '../shared/types'; import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; import { grainPerInch, grainStockFor } from '../shared/utils/grainShader'; import { curveIsActive } from '../shared/utils/toneCurve'; import { HEAL_DEFAULT_R } from '../shared/utils/heal'; +import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -290,12 +292,16 @@ export function Workspace() { // armed, and the last colour it read off the photo. const [hslBand, setHslBand] = useState('red'); const [picking, setPicking] = useState(false); - // FX's HEAL brush: whether it is armed, the size a click lays down, and — read - // back off the recipe — the spots already repaired, so the photo, UNDO and the - // saved look all speak about the same list. - const [healing, setHealing] = useState(false); + // FX's brush: which of its two tools holds the pointer on the photo (null for + // neither), the size each one lays spots down at — each keeps its own, since + // a speck of dust and a face are nothing like the same circle — and, read back + // off the recipe, the spots already laid, so the photo, UNDO and the saved + // look all speak about the same lists. + const [brushTool, setBrushTool] = useState<'heal' | 'mosaic' | null>(null); const [healR, setHealR] = useState(HEAL_DEFAULT_R); + const [mosaicR, setMosaicR] = useState(MOSAIC_DEFAULT_R); const healSpots = recipe.adjustments.heal ?? []; + const mosaicSpots = recipe.adjustments.mosaic ?? []; const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null); // Where on the photo that colour was read: the mixer's panel hangs there. const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null); @@ -922,6 +928,40 @@ export function Workspace() { setAdjustment({ heal: undefined }); }, [remember, setAdjustment]); + // FX's MOSAIC brush writes its spots the same way, one gesture to one step. + // Nothing is capped here either (shared/utils/mosaic.ts), and every spot is + // laid down: unlike a repair, a hidden patch cannot be refused — there is no + // wrong light to borrow from, only cells. + const addMosaicSpots = useCallback( + (spots: MosaicSpot[]) => { + if (!spots.length) return; + remember(); + const list = lookRef.current?.recipe.adjustments.mosaic ?? []; + setAdjustment({ mosaic: [...list, ...spots] }); + }, + [remember, setAdjustment] + ); + const clearMosaicSpots = useCallback(() => { + remember(); + // Same as HEAL's CLEAR: dropping the field is what "nothing is hidden" + // means, so no empty list is left behind. + setAdjustment({ mosaic: undefined }); + }, [remember, setAdjustment]); + + // Whichever tool is armed owns the pointer, its own size and its own list — + // one callback pair for the stage, chosen here, so the layer below never has + // to know which tool it is painting for. + const brushSpots: (HealSpot | MosaicSpot)[] = brushTool === 'mosaic' ? mosaicSpots : healSpots; + const brushR = brushTool === 'mosaic' ? mosaicR : healR; + const setBrushR = brushTool === 'mosaic' ? setMosaicR : setHealR; + const addBrushSpots = useCallback( + (spots: (HealSpot | MosaicSpot)[]) => { + if (brushTool === 'mosaic') addMosaicSpots(spots as MosaicSpot[]); + else addHealSpots(spots as HealSpot[]); + }, + [brushTool, addMosaicSpots, addHealSpots] + ); + // FRAME's STRAIGHTEN rides the same one-edit-per-gesture rule as a knob, so // dragging the ruler is one undo step instead of one per degree. const setStraightenOnce = useCallback( @@ -2275,27 +2315,42 @@ export function Workspace() { groupChip('cxb'), ]; case 'fx': { - // FX's HEAL is the tab's one tool that is not a look: the chip arms the - // dust brush, the wheel sizes it — the chip's own readout is that size, - // since the brush has no size buttons to point at — and CLEAR hands the - // whole photo back. It sits above the look chips because it repairs the - // file rather than grading it. + // FX's two brushes are the tab's tools that are not a look: a chip arms + // one, the wheel sizes it — the chip's own readout is that size, since + // the brush has no size buttons to point at — and CLEAR hands the whole + // photo back. HEAL repairs a speck of dust, MOSAIC hides a patch of the + // photo from whoever looks at it; they sit above the look chips because + // they change the file rather than grade it. return [ { key: 'heal', label: 'HEAL', - value: healing ? `${(healR * 100).toFixed(1)}%` : undefined, - active: healing, + value: brushTool === 'heal' ? `${(healR * 100).toFixed(1)}%` : undefined, + active: brushTool === 'heal', amberValue: healSpots.length > 0, onClick: () => { - // The two tools that take the pointer on the photo never share it. + // The tools that take the pointer on the photo never share it. setPicking(false); - setHealing((v) => !v); + setBrushTool((v) => (v === 'heal' ? null : 'heal')); }, }, ...(healSpots.length ? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }] : []), + { + key: 'mosaic', + label: 'MOSAIC', + value: brushTool === 'mosaic' ? `${(mosaicR * 100).toFixed(1)}%` : undefined, + active: brushTool === 'mosaic', + amberValue: mosaicSpots.length > 0, + onClick: () => { + setPicking(false); + setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic')); + }, + }, + ...(mosaicSpots.length + ? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }] + : []), { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, // GRAIN is a strip of its own — amount, size, and the count they add up // to — so it is one chip here and its two knobs live inside it; the @@ -2325,7 +2380,7 @@ export function Workspace() { onClick: () => toggleParam(key), }); return [ - { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setHealing(false); setPicking((v) => !v); } }, + { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setBrushTool(null); setPicking((v) => !v); } }, ...HSL_BANDS.map((b): ChipDef => { const band = recipe.adjustments.hslBands?.[b.id]; const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0); @@ -2788,11 +2843,11 @@ export function Workspace() { onClear={() => setClearing(true)} picking={picking} onPickColor={pickColor} - healing={healing} - healSpots={healSpots} - healR={healR} - onHealR={setHealR} - onHealSpots={addHealSpots} + brushTool={brushTool} + brushSpots={brushSpots} + brushR={brushR} + onBrushR={setBrushR} + onBrushSpots={addBrushSpots} pickPanel={pickPanel} pickPanelAt={pickedAt} // FRAME's custom mark owns a box on the photo while its panel is diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 3145db5..0eeeebf 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -35,6 +35,7 @@ import { 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 { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic'; import { GRAIN_SKSL, HALATION_SKSL, @@ -167,6 +168,18 @@ function healEffectFor(count: number): any { return effect; } +// 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. +const mosaicEffects = new Map(); +function mosaicEffectFor(count: number): any { + let effect = mosaicEffects.get(count); + if (effect === undefined) { + effect = Skia.RuntimeEffect.Make(mosaicSkSL(count)) ?? null; + mosaicEffects.set(count, effect); + } + return effect; +} + // CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on // a 1:1 export canvas, so the radius matches what the preview tuned. function convolvePaint(srcImage: any, amount: number): any { @@ -675,6 +688,35 @@ export async function renderPhoto(input: RenderInput): Promise