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:
+74
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user