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