web: hide what the brush paints, in cells, and never in a blur

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.
This commit is contained in:
2026-09-23 22:03:33 +07:00
parent b795517d9f
commit f1385d8a08
8 changed files with 391 additions and 145 deletions
+74 -19
View File
@@ -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<HslBandId>('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
@@ -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<number, any>();
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<RenderResult | nu
}
}
// 6e. MOSAIC — the FX tab's hiding brush (see mosaic.ts). It comes after
// HEAL because hiding is the last thing the photo's own pixels are asked
// for: a repair under a hidden patch is work nobody will ever see, and the
// snapshot this pass reads already carries the repairs. It is a hard-edged
// circle of cells, so nothing of what it covers is left legible at its rim.
const mosaic = readMosaic(adjustments.mosaic);
if (mosaic.length) {
const effect = mosaicEffectFor(mosaic.length);
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(mosaicUniforms(mosaic, width, height), [child]))
: null;
if (shader) {
canvas.clear(Skia.Color('transparent'));
const mosaicPaint = own(Skia.Paint());
mosaicPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), mosaicPaint);
}
}
release(owned, skImage);
// 7. Frame.
+17 -15
View File
@@ -421,33 +421,35 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
cursor: crosshair;
touch-action: none;
}
/* FX's HEAL brush. The layer takes the pointer the way the eyedropper's does,
so a click repairs and a drag paints instead of panning; the circle under the
cursor is the size the wheel has set, the trail is the stroke being drawn,
and every spot on the photo keeps the pair the renderer works with — the hole
it fills, and the patch it borrowed. */
.heal-layer {
/* FX's brush — HEAL and MOSAIC, one layer. It takes the pointer the way the
eyedropper's does, so a click paints and a drag paints a run instead of
panning; the circle under the cursor is the size the wheel has set, the trail
is the stroke being drawn, and every spot on the photo keeps its own circle.
A HEAL spot shows the pair the renderer works with — the hole it fills, and
the patch it borrowed; a MOSAIC spot has nothing beside it, since what it
covers is gone. */
.brush-layer {
position: absolute;
border-radius: var(--radius-sm);
cursor: none;
touch-action: none;
}
.heal-set { position: absolute; inset: 0; pointer-events: none; }
.heal-cursor,
.heal-spot,
.heal-source {
.brush-set { position: absolute; inset: 0; pointer-events: none; }
.brush-cursor,
.brush-spot,
.brush-source {
position: absolute;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.heal-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); }
.brush-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); }
/* The stroke in progress is one band of the brush's own width — a path with
round ends and round joins — so a drag reads as a brush being drawn across
the photo rather than as the beads a row of overlapping circles looks like.
It wears the accent, so the run reads as something about to happen rather
than a spot already laid. */
.heal-trail {
.brush-trail {
position: absolute;
inset: 0;
width: 100%;
@@ -455,15 +457,15 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
overflow: visible;
pointer-events: none;
}
.heal-trail path {
.brush-trail path {
fill: none;
stroke: var(--accent);
stroke-linecap: round;
stroke-linejoin: round;
stroke-opacity: 0.45;
}
.heal-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
.heal-source { border: 1px dashed rgba(255, 255, 255, 0.7); }
.brush-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
.brush-source { border: 1px dashed rgba(255, 255, 255, 0.7); }
/* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the
ruler is pointed at. */
.hsl-readout {
+111 -93
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect, type HealSpot } from '../../shared/types';
import { MIN_CROP_FRAC, type CropRect, type HealSpot, type MosaicSpot } from '../../shared/types';
import { bitmapReader, samplePixel } from '../engine/imageOps';
import { HEAL_SPACING, findHealSource, wheelHealR } from '../../shared/utils/heal';
import { findHealSource } from '../../shared/utils/heal';
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
import { measureMark } from '../engine/exportEngine';
import { Histogram } from './Histogram';
@@ -105,11 +106,16 @@ export function ImageStage({
marks = [],
straightenRuler = null,
curvePanel = null,
healing = false,
healSpots = [],
healR = 0.02,
onHealR,
onHealSpots,
// FX's brush: the tool that takes the pointer on the photo, and the size a
// spot is laid down at. One spot is a circle and, for HEAL, the patch it
// borrowed; MOSAIC's spots carry no patch. Either way they are fractions of
// the render, which is why one set of spots lands on the preview and on the
// export alike.
brushTool = null,
brushSpots = [],
brushR = 0.02,
onBrushR,
onBrushSpots,
dims = null,
}: {
previewUrl: string | null;
@@ -161,17 +167,17 @@ export function ImageStage({
// rather than in a column beside it. It is anchored, not draggable — the graph
// is a fixed square and the photo moves under it.
curvePanel?: ReactNode;
// FX's HEAL brush: the circle under the cursor is the size of the spot a
// gesture lays down (the wheel sets it), and the patch each spot borrows is
// searched for at the end of the gesture (shared/utils/heal.ts) — off the
// render on the stage, which is the picture the repair is being judged
// against. One gesture reports its spots together, and one gesture is one
// undo step however many spots it laid.
healing?: boolean;
healSpots?: HealSpot[];
healR?: number;
onHealR?: (r: number) => void;
onHealSpots?: (spots: HealSpot[]) => void;
// FX's brush, HEAL or MOSAIC: the circle under the cursor is the size of the
// spot a gesture lays down (the wheel sets it), and one gesture reports its
// spots together — one gesture is one undo step however many spots it laid.
// HEAL's spots carry the patch each one borrows, searched for at the end of the
// gesture off the render on the stage (shared/utils/heal.ts), which is the
// picture the repair is being judged against.
brushTool?: 'heal' | 'mosaic' | null;
brushSpots?: (HealSpot | MosaicSpot)[];
brushR?: number;
onBrushR?: (r: number) => void;
onBrushSpots?: (spots: (HealSpot | MosaicSpot)[]) => void;
// The photo's own pixel size, as the stage stands: the file's shape turned by
// the quarter turn and cut by a committed crop, so the number in the row under
// the photo is the resolution an export at the photo's own size renders.
@@ -210,9 +216,9 @@ export function ImageStage({
// which is what the layer is, so the stage's zoom cannot scale the offset a
// second time and leave the icon drifting away from the cursor.
const [pickPos, setPickPos] = useState<{ fx: number; fy: number } | null>(null);
// Where the heal brush's circle is drawing itself, in the same fraction: the
// ring follows the pointer at every zoom instead of sliding by the zoom factor.
const [healPos, setHealPos] = useState<{ fx: number; fy: number } | null>(null);
// Where the brush's circle is drawing itself, in the same fraction: the ring
// follows the pointer at every zoom instead of sliding by the zoom factor.
const [brushPos, setBrushPos] = useState<{ fx: number; fy: number } | null>(null);
// The colour that was read is rarely a clear patch of photo, so the mixer's
// card can be dragged off the point it hangs on. The offset is a fraction of
// the photo — which is what the layer is — so a zoom keeps the card where it
@@ -270,7 +276,7 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve && !healing) {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve && !brushTool) {
setBox(null);
return;
}
@@ -291,7 +297,7 @@ export function ImageStage({
ro?.disconnect();
window.removeEventListener('resize', measure);
};
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, healing, measure, previewUrl]);
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, brushTool, measure, previewUrl]);
const grabPanel = (e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation(); // the photo must not pan under the card
@@ -352,20 +358,20 @@ export function ImageStage({
[previewUrl, onPickColor]
);
// FX's HEAL brush. The pointer paints: a click is a stroke of one spot, and a
// drag lays a run of them, one every HEAL_SPACING radii of travel, so a
// scratch of dust is one gesture rather than twenty clicks. The run is turned
// into spots when the pointer comes up and reported in one go — the repair
// lands with the gesture rather than under it, and that is what keeps one
// FX's brush. The pointer paints: a click is a stroke of one spot, and a drag
// lays a run of them, one every BRUSH_SPACING radii of travel, so a scratch of
// dust — or a face — is one gesture rather than twenty clicks. The run is
// turned into spots when the pointer comes up and reported in one go — the
// spots land with the gesture rather than under it, and that is what keeps one
// stroke to one undo step however long it was — while the trail being painted
// is drawn from the pointer track as it moves.
type HealPoint = { fx: number; fy: number; px: number; py: number };
const strokeRef = useRef<{ pts: HealPoint[]; w: number } | null>(null);
const [healTrail, setHealTrail] = useState<HealPoint[]>([]);
type BrushPoint = { fx: number; fy: number; px: number; py: number };
const strokeRef = useRef<{ pts: BrushPoint[]; w: number } | null>(null);
const [brushTrail, setBrushTrail] = useState<BrushPoint[]>([]);
// The same decode the eyedropper keeps, under the same rule: one bitmap per
// URL, and the search reads it a few hundred times rather than once.
const healReader = useCallback(async () => {
// URL, and HEAL's search reads it a few hundred times rather than once.
const previewReader = useCallback(async () => {
if (!previewUrl) return null;
if (!pickCache.current || pickCache.current.url !== previewUrl) {
const blob = await (await fetch(previewUrl)).blob();
@@ -379,7 +385,7 @@ export function ImageStage({
// and a pixel offset in the photo's box (what the spacing is measured in).
// Off the photo there is no point: a stroke may run out over the frame and
// come back, and the part outside lays nothing down.
const healPoint = useCallback((clientX: number, clientY: number): HealPoint | null => {
const brushPoint = useCallback((clientX: number, clientY: number): BrushPoint | null => {
const img = imgRef.current;
if (!img) return null;
const rect = img.getBoundingClientRect();
@@ -390,36 +396,44 @@ export function ImageStage({
return { fx, fy, px: clientX - rect.left, py: clientY - rect.top };
}, []);
const healDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!onHealSpots) return;
const p = healPoint(e.clientX, e.clientY);
const brushDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!onBrushSpots) return;
const p = brushPoint(e.clientX, e.clientY);
if (!p) return;
// Capture, so a stroke that runs off the photo still ends where the
// pointer does instead of leaving a spot hanging at the edge.
e.currentTarget.setPointerCapture(e.pointerId);
strokeRef.current = { pts: [p], w: imgRef.current?.getBoundingClientRect().width ?? 0 };
setHealTrail([p]);
setBrushTrail([p]);
};
const healMove = (e: React.PointerEvent<HTMLDivElement>) => {
const p = healPoint(e.clientX, e.clientY);
setHealPos(p ? { fx: p.fx, fy: p.fy } : null);
const brushMove = (e: React.PointerEvent<HTMLDivElement>) => {
const p = brushPoint(e.clientX, e.clientY);
setBrushPos(p ? { fx: p.fx, fy: p.fy } : null);
const stroke = strokeRef.current;
if (!stroke || !p) return;
const last = stroke.pts[stroke.pts.length - 1];
// The spacing is a fraction of the brush, measured in the photo's own
// pixels: any closer and the run is the same repair written twice.
if (Math.hypot(p.px - last.px, p.py - last.py) < Math.max(1, healR * stroke.w * HEAL_SPACING)) return;
// pixels: any closer and the run is the same spot written twice.
if (Math.hypot(p.px - last.px, p.py - last.py) < Math.max(1, brushR * stroke.w * BRUSH_SPACING)) return;
stroke.pts.push(p);
setHealTrail(stroke.pts.slice());
setBrushTrail(stroke.pts.slice());
};
const healUp = useCallback(async () => {
const brushUp = useCallback(async () => {
const stroke = strokeRef.current;
strokeRef.current = null;
setHealTrail([]);
if (!stroke || !onHealSpots) return;
const reader = await healReader();
setBrushTrail([]);
if (!stroke || !onBrushSpots) return;
// MOSAIC has nothing to read off the photo: a cell takes the colour the
// shader finds at the cell's own middle, and every spot is the same circle
// the brush drew. HEAL does — its patch is searched for in the pixels under
// the stroke.
if (brushTool === 'mosaic') {
onBrushSpots(stroke.pts.map((p) => ({ x: p.fx, y: p.fy, r: brushR })));
return;
}
const reader = await previewReader();
if (!reader) return;
// A spot whose patch the search refuses is not laid down at all: borrowing
// the spot's own pixels would be a repair that changes nothing, and pasting
@@ -428,11 +442,11 @@ export function ImageStage({
// spot. A stroke that is refused end to end reports nothing, and the caller
// records no step for it.
const spots = stroke.pts.flatMap((p) => {
const src = findHealSource(reader, p.fx, p.fy, healR);
return src ? [{ x: p.fx, y: p.fy, r: healR, sx: src.sx, sy: src.sy }] : [];
const src = findHealSource(reader, p.fx, p.fy, brushR);
return src ? [{ x: p.fx, y: p.fy, r: brushR, sx: src.sx, sy: src.sy }] : [];
});
onHealSpots(spots);
}, [healR, healReader, onHealSpots]);
onBrushSpots(spots);
}, [brushR, brushTool, previewReader, onBrushSpots]);
// The wheel is the brush's size. It is bound natively rather than through
// React's own onWheel because that one is passive: the wheel would scroll the
@@ -440,18 +454,18 @@ export function ImageStage({
// stage that also listens for the wheel (its own zoom), so the event is
// stopped here as well: while the brush is up the wheel sizes the brush and
// nothing else.
const healLayerRef = useRef<HTMLDivElement | null>(null);
const brushLayerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = healLayerRef.current;
if (!el || !onHealR) return;
const el = brushLayerRef.current;
if (!el || !onBrushR) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
onHealR(wheelHealR(healR, e.deltaY));
onBrushR(wheelBrushR(brushR, e.deltaY));
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onHealR, healR, healing]);
}, [onBrushR, brushR, brushTool]);
// The img's box at scale 1 — the natural size fitted into the wrap, never
// enlarged. Computed rather than measured so the transform cannot feed back
@@ -871,8 +885,8 @@ export function ImageStage({
}}
onPointerDown={(e) => e.stopPropagation()}
onPointerMove={(e) => {
// A fraction of the photo's own box, for the reason the heal
// ring is drawn that way: the layer carries the stage's zoom.
// A fraction of the photo's own box, for the reason the brush's
// circle is drawn that way: the layer carries the stage's zoom.
const img = imgRef.current;
const r = img?.getBoundingClientRect();
setPickPos(
@@ -904,18 +918,20 @@ export function ImageStage({
</span>
</div>
) : null}
{/* FX's HEAL brush. The layer swallows the pointer the way the
eyedropper's does, so a click repairs instead of panning the
photo and a drag paints instead; the circle rides the cursor at
the size a spot will be, the wheel changes that size, and every
spot already laid down keeps its own circle on the picture — the
drawn circle and the borrowed patch, the pair the renderer works
with. */}
{healing && !cropEditing && box ? (
{/* FX's brush — HEAL or MOSAIC. The layer swallows the pointer the way
the eyedropper's does, so a click paints instead of panning the
photo and a drag paints a run; the circle rides the cursor at the
size a spot will be, the wheel changes that size, and every spot
already laid down keeps its own circle on the picture. A HEAL spot
shows the pair the renderer works with — the circle it fills and
the patch it borrowed; a MOSAIC spot has nothing to show beside
itself, since what is under it is gone. */}
{brushTool && !cropEditing && box ? (
<div
className="heal-layer"
data-key="heal-layer"
ref={healLayerRef}
className="brush-layer"
data-key="brush-layer"
data-tool={brushTool}
ref={brushLayerRef}
style={{
left: box.left,
top: box.top,
@@ -926,35 +942,35 @@ export function ImageStage({
}}
onPointerDown={(e) => {
e.stopPropagation();
healDown(e);
brushDown(e);
}}
onPointerMove={healMove}
onPointerLeave={() => setHealPos(null)}
onPointerUp={healUp}
onPointerMove={brushMove}
onPointerLeave={() => setBrushPos(null)}
onPointerUp={brushUp}
onPointerCancel={() => {
strokeRef.current = null;
setHealTrail([]);
setBrushTrail([]);
}}
>
{/* The stroke being painted: the run the pointer has drawn so
far, as the brush's own band rather than a row of circles —
what is being painted is one mark of the brush's width, and
the circles are what the repairs are once they are laid. */}
{healTrail.length > 1 ? (
the circles are what the spots are once they are laid. */}
{brushTrail.length > 1 ? (
<svg
className="heal-trail"
data-key="heal-trail"
data-points={healTrail.length}
className="brush-trail"
data-key="brush-trail"
data-points={brushTrail.length}
viewBox={`0 0 ${box.width} ${box.height}`}
aria-hidden="true"
>
<path
d={healTrail.map((t, i) => `${i ? 'L' : 'M'} ${t.fx * box.width} ${t.fy * box.height}`).join(' ')}
style={{ strokeWidth: 2 * healR * box.width }}
d={brushTrail.map((t, i) => `${i ? 'L' : 'M'} ${t.fx * box.width} ${t.fy * box.height}`).join(' ')}
style={{ strokeWidth: 2 * brushR * box.width }}
/>
</svg>
) : null}
{healSpots.map((s, i) => {
{brushSpots.map((s, i) => {
// r is a fraction of the photo's WIDTH, so the circle is round
// whatever the picture's shape is — the same measurement the
// shader makes against the render's own pixels.
@@ -966,21 +982,23 @@ export function ImageStage({
height: d,
});
return (
<span className="heal-set" data-key="heal-set" data-i={i} key={i}>
<span className="heal-source" data-key="heal-source" style={at(s.sx, s.sy)} />
<span className="heal-spot" data-key="heal-spot" style={at(s.x, s.y)} />
<span className="brush-set" data-key="brush-set" data-i={i} key={i}>
{'sx' in s ? (
<span className="brush-source" data-key="brush-source" style={at(s.sx, s.sy)} />
) : null}
<span className="brush-spot" data-key="brush-spot" style={at(s.x, s.y)} />
</span>
);
})}
{healPos ? (
{brushPos ? (
<span
className="heal-cursor"
data-key="heal-cursor"
className="brush-cursor"
data-key="brush-cursor"
style={{
left: `${healPos.fx * 100}%`,
top: `${healPos.fy * 100}%`,
width: 2 * healR * box.width,
height: 2 * healR * box.width,
left: `${brushPos.fx * 100}%`,
top: `${brushPos.fy * 100}%`,
width: 2 * brushR * box.width,
height: 2 * brushR * box.width,
}}
/>
) : null}