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; } `; }