fix(studio): a heal stroke is one mask, and the fill comes from its rim
The dust brush had two faults and the second was hidden behind the first. A stroke was a row of independent repairs. Every circle the brush laid down found a patch of its own somewhere else in the frame and pasted it, so a stroke of twenty spots was twenty pastes with twenty rims to match, and what showed on the photo was the run of circles itself. The mask was their union on paper but not on the picture. And the mask was not a stroke either. The brush only ever dropped a spot where the pointer's own sample fell, and a fast drag reports a sample every few centimetres of screen — several brush radii apart. Circles that far apart do not touch, so their union was the row of dots the tool was complained about. The segment the pointer travelled is now walked at the brush's own spacing and a spot is laid at every step, whatever speed the stroke was drawn at. With the mask one shape, the patch had nothing left to justify it. The fill is now a push-pull pyramid over the whole mask: the painted pixels are cut out of the frame as a hole, the remaining pixels are halved and halved again until a level is small enough that no hole is left in it, and each level is drawn back over the one below it. What comes up out of that is the photo where the photo was, and the light of the mask's own rim carried inward where it was not — nothing is copied from anywhere, and a repair matches the grade, the blur and the grain around it because those are the pixels it was made from. Non-destructive as before: the recipe still holds the circles, and the preview and the full-size export run the two passes in engine/exportEngine.ts, the shaders and the mask stops in shared/utils/heal.ts. A recipe written by the old brush still loads; the patch coordinates it stored are simply not read any more.
This commit is contained in:
@@ -45,7 +45,14 @@ import {
|
||||
} from '../../shared/utils/toneShader';
|
||||
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 {
|
||||
HEAL_CUT_SKSL,
|
||||
HEAL_FILL_SKSL,
|
||||
HEAL_FEATHER,
|
||||
HEAL_MASK_STOPS,
|
||||
HEAL_MAX_LEVELS,
|
||||
readHeal,
|
||||
} from '../../shared/utils/heal';
|
||||
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
|
||||
import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask';
|
||||
import {
|
||||
@@ -180,6 +187,8 @@ let clarityBlurEffect: any = null;
|
||||
let clarityBlendEffect: any = null;
|
||||
let nrEffect: any = null;
|
||||
let dehazeEffect: any = null;
|
||||
let healCutEffect: any = null;
|
||||
let healFillEffect: any = null;
|
||||
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(SHARPEN_SKSL);
|
||||
@@ -193,6 +202,8 @@ function effects() {
|
||||
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
|
||||
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
|
||||
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
|
||||
if (!healCutEffect) healCutEffect = Skia.RuntimeEffect.Make(HEAL_CUT_SKSL);
|
||||
if (!healFillEffect) healFillEffect = Skia.RuntimeEffect.Make(HEAL_FILL_SKSL);
|
||||
return {
|
||||
sharpenEffect,
|
||||
toneEffect,
|
||||
@@ -205,23 +216,16 @@ function effects() {
|
||||
clarityBlendEffect,
|
||||
nrEffect,
|
||||
dehazeEffect,
|
||||
healCutEffect,
|
||||
healFillEffect,
|
||||
};
|
||||
}
|
||||
|
||||
// FX's HEAL is the one effect whose shader depends on the recipe rather than on
|
||||
// constants: it carries exactly the spots the photo has (shared/utils/heal.ts),
|
||||
// which is what lets the list be uncapped. Every distinct count is therefore a
|
||||
// shader of its own, kept here so a stroke's worth of spots is paid for once
|
||||
// and every later render at that count is free.
|
||||
const healEffects = new Map<number, any>();
|
||||
function healEffectFor(count: number): any {
|
||||
let effect = healEffects.get(count);
|
||||
if (effect === undefined) {
|
||||
effect = Skia.RuntimeEffect.Make(healSkSL(count)) ?? null;
|
||||
healEffects.set(count, effect);
|
||||
}
|
||||
return effect;
|
||||
}
|
||||
// FX's HEAL no longer depends on the recipe: what the brush paints is a mask,
|
||||
// and the fill behind it is one solve over that mask rather than one block per
|
||||
// spot, so the two passes are the same for a speck and for a long stroke and are
|
||||
// built once like the rest. The list is uncapped for the same reason — a hundred
|
||||
// more spots are a hundred more circles in the mask and not a longer shader.
|
||||
|
||||
// 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.
|
||||
@@ -1259,7 +1263,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
// that shape's own alpha, masks in the order they were drawn, so the second
|
||||
// reads what the first left. It sits here, after the frame-wide grade and
|
||||
// before HEAL, because a local adjustment is part of the look and not a
|
||||
// repair: the pixels a repair borrows are meant to carry it already.
|
||||
// repair: the pixels a repair works from are meant to carry it already.
|
||||
// Same shape as the passes below — the shader reads the frame it is about to
|
||||
// overwrite — and, when a mask carries CLARITY or DEHAZE, the frame's own
|
||||
// blurred reference and the atmospheric light come in exactly as they did for
|
||||
@@ -1279,38 +1283,145 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
}
|
||||
|
||||
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
|
||||
// that touches the photo's own pixels, and deliberately so: the borrowed
|
||||
// patch is copied out of the pixels the pipeline has already built, so a
|
||||
// repair matches the grade, the blur and the grain around it by
|
||||
// construction instead of being a second render of the same look that has
|
||||
// to agree. The card, the wall and the watermarks are drawn after it, so a
|
||||
// healed spot still carries the frame's own edge.
|
||||
// that touches the photo's own pixels, and deliberately so: the fill is made
|
||||
// of the pixels the pipeline has already built, so a repair matches the
|
||||
// grade, the blur and the grain around it by construction instead of being a
|
||||
// second render of the same look that has to agree. The card, the wall and
|
||||
// the watermarks are drawn after it, so a healed spot still carries the
|
||||
// frame's own edge.
|
||||
//
|
||||
// Three moves, in this order. The brush's circles become ONE mask — a union,
|
||||
// which is what makes a stroke a single shape instead of a row of circles.
|
||||
// The mask is cut out of the frame, leaving a hole with the photo's own
|
||||
// pixels all around its rim. And the hole is filled from that rim, by the
|
||||
// pyramid below, so nothing is copied from anywhere: what was under the
|
||||
// brush is the light of what borders it.
|
||||
const heal = readHeal(adjustments.heal);
|
||||
if (heal.length) {
|
||||
// The shader for this many spots, cached by count — every spot the recipe
|
||||
// holds is drawn, so no repair is ever dropped to make room for a later
|
||||
// one.
|
||||
const effect = healEffectFor(heal.length);
|
||||
const { healCutEffect: cutEffect, healFillEffect: fillEffect } = effects();
|
||||
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
|
||||
)
|
||||
// The mask, at the frame's own resolution: one radial gradient per spot,
|
||||
// solid out to HEAL_FEATHER of its radius and clear at its edge, each laid
|
||||
// over the last — so two spots of one stroke are one shape with no seam
|
||||
// between them, and the mask's own border is a fade rather than an outline
|
||||
// the fill would have to match exactly.
|
||||
const maskSurface = own(createSurface(width, height));
|
||||
const maskCanvas = maskSurface.getCanvas();
|
||||
maskCanvas.clear(Skia.Color('transparent'));
|
||||
for (const s of heal) {
|
||||
const cx = s.x * width;
|
||||
const cy = s.y * height;
|
||||
const rad = s.r * width;
|
||||
const shader = own(
|
||||
Skia.Shader.MakeRadialGradient(
|
||||
[cx, cy],
|
||||
rad,
|
||||
new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]),
|
||||
HEAL_MASK_STOPS,
|
||||
Skia.TileMode.Clamp
|
||||
)
|
||||
: null;
|
||||
const shader =
|
||||
effect && child
|
||||
? own(effect.makeShaderWithChildren(healUniforms(heal, width, height), [child]))
|
||||
: null;
|
||||
if (shader) {
|
||||
// Snapshot, clear, redraw — the pass reads the frame it is about to
|
||||
// overwrite (see replaceThrough).
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
const healPaint = own(Skia.Paint());
|
||||
healPaint.setShader(shader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), healPaint);
|
||||
);
|
||||
const paint = own(Skia.Paint());
|
||||
paint.setAntiAlias(true);
|
||||
paint.setShader(shader);
|
||||
maskCanvas.drawCircle(cx, cy, rad, paint);
|
||||
}
|
||||
flush(maskSurface);
|
||||
const maskImage = own(maskSurface.makeImageSnapshot());
|
||||
// The frame with the mask taken out of it: the hole the fill works on.
|
||||
const cutSurface = own(createSurface(width, height));
|
||||
if (snap && maskImage && cutEffect) {
|
||||
const cutShader = own(
|
||||
cutEffect.makeShaderWithChildren([], [own(imageShaderChild(snap)), own(imageShaderChild(maskImage))])
|
||||
);
|
||||
if (cutShader) {
|
||||
const cutCanvas = cutSurface.getCanvas();
|
||||
cutCanvas.clear(Skia.Color('transparent'));
|
||||
const cutPaint = own(Skia.Paint());
|
||||
cutPaint.setShader(cutShader);
|
||||
cutCanvas.drawRect(Skia.XYWHRect(0, 0, width, height), cutPaint);
|
||||
}
|
||||
}
|
||||
flush(cutSurface);
|
||||
const cutImage = own(cutSurface.makeImageSnapshot());
|
||||
|
||||
// Down: each level is the one below it halved, drawn with a linear filter
|
||||
// over the premultiplied pixels the cut left behind, so a halving is a
|
||||
// weighted mean of the pixels that are still there — the hole weighs
|
||||
// nothing at all, and a level holds the light of the largest
|
||||
// neighbourhoods around it.
|
||||
const levels: { surface: any; w: number; h: number }[] = [];
|
||||
let carried: any = cutImage;
|
||||
if (carried) {
|
||||
for (let k = 1; k <= HEAL_MAX_LEVELS; k++) {
|
||||
const lw = Math.max(1, width >> k);
|
||||
const lh = Math.max(1, height >> k);
|
||||
const level = own(createSurface(lw, lh));
|
||||
const lc = level.getCanvas();
|
||||
lc.clear(Skia.Color('transparent'));
|
||||
lc.drawImageRectOptions(
|
||||
carried,
|
||||
Skia.XYWHRect(0, 0, carried.width(), carried.height()),
|
||||
Skia.XYWHRect(0, 0, lw, lh),
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None
|
||||
);
|
||||
flush(level);
|
||||
levels.push({ surface: level, w: lw, h: lh });
|
||||
const smaller = own(level.makeImageSnapshot());
|
||||
if (!smaller || (lw === 1 && lh === 1)) break;
|
||||
carried = smaller;
|
||||
}
|
||||
}
|
||||
|
||||
// Up: the coarsest level is already an answer for the whole frame, so it
|
||||
// is drawn over the level below it — whose hole only that level could
|
||||
// reach — that result over the one below it, and so on down to the
|
||||
// half-size level. What comes back is the photo where the photo was, and
|
||||
// the rim's own light carried inward where it was not.
|
||||
let up: any = null;
|
||||
let fillImage: any = null;
|
||||
for (let k = levels.length - 1; k >= 0; k--) {
|
||||
const { surface: level, w: lw, h: lh } = levels[k];
|
||||
if (up) {
|
||||
const lc = level.getCanvas();
|
||||
lc.drawImageRectOptions(
|
||||
up,
|
||||
Skia.XYWHRect(0, 0, up.width(), up.height()),
|
||||
Skia.XYWHRect(0, 0, lw, lh),
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None
|
||||
);
|
||||
}
|
||||
flush(level);
|
||||
const pulled = own(level.makeImageSnapshot());
|
||||
if (!pulled) break;
|
||||
if (k === 0) {
|
||||
fillImage = pulled;
|
||||
break;
|
||||
}
|
||||
up = pulled;
|
||||
}
|
||||
|
||||
// And the fill back under the brush, in one draw: the frame, the fill and
|
||||
// the mask as its three children, with the mask the weight the frame and
|
||||
// the fill are mixed at — so the rim is the only place the two meet, and
|
||||
// there they already agree.
|
||||
if (fillImage && snap && maskImage && fillEffect) {
|
||||
const shader = own(
|
||||
fillEffect.makeShaderWithChildren([], [
|
||||
own(imageShaderChild(snap)),
|
||||
own(imageShaderChild(fillImage)),
|
||||
own(imageShaderChild(maskImage)),
|
||||
])
|
||||
);
|
||||
if (shader) {
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
const healPaint = own(Skia.Paint());
|
||||
healPaint.setShader(shader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), healPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -698,10 +698,9 @@ button.hint:disabled { cursor: default; }
|
||||
/* 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. */
|
||||
is the stroke being drawn, and every spot on the photo keeps a print of its
|
||||
own. A repair and a hidden patch look the same here — what a spot means is
|
||||
the renderer's business, and neither of them borrows a second circle. */
|
||||
.brush-layer {
|
||||
position: absolute;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -710,8 +709,7 @@ button.hint:disabled { cursor: default; }
|
||||
}
|
||||
.brush-set { position: absolute; inset: 0; pointer-events: none; }
|
||||
.brush-cursor,
|
||||
.brush-spot,
|
||||
.brush-source {
|
||||
.brush-spot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
@@ -739,14 +737,14 @@ button.hint:disabled { cursor: default; }
|
||||
stroke-opacity: 0.45;
|
||||
}
|
||||
.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 spots a stroke left are drawn as the one mark it was painted as: a band
|
||||
of the brush's own width through them, in the same translucent white with the
|
||||
same dark hairline the circles wear, so the band reads on a white photo too.
|
||||
The dashed one is the patch HEAL borrowed, which is a band of its own and is
|
||||
meant to look borrowed. A stroke is one path, so where it crosses itself its
|
||||
translucency is laid once rather than twice — that doubling-up is exactly
|
||||
what a row of overlapping circles looks like. */
|
||||
of the brush's own width through them, in the same white with the same dark
|
||||
hairline the circles wear, so the band reads on a white photo too. The wash
|
||||
stays faint — the hairline is what carries it on a white photo, and a heavier
|
||||
wash turns into an opaque stripe over a warm one.
|
||||
A stroke is one path, so where it crosses itself its translucency is laid once
|
||||
rather than twice — that doubling-up is exactly what a row of overlapping
|
||||
circles looks like. */
|
||||
.brush-band {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -758,21 +756,18 @@ button.hint:disabled { cursor: default; }
|
||||
}
|
||||
.brush-band path {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.18);
|
||||
stroke: rgba(255, 255, 255, 0.08);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.brush-band.band-src path { stroke: rgba(255, 255, 255, 0.12); stroke-dasharray: 5 5; }
|
||||
/* A spot the band stands for: its box on the photo is still the spot's, its own
|
||||
edge is the band's now. */
|
||||
.brush-spot[data-band],
|
||||
.brush-source[data-band] { border-color: transparent; box-shadow: none; }
|
||||
.brush-spot[data-band] { border-color: transparent; box-shadow: none; }
|
||||
/* A repair the pointer is not on and no press has chosen: the ring would be one
|
||||
more circle on a photo that already carries as many repairs as the user laid,
|
||||
so an idle spot is the patch it borrowed and a faint print of the place it
|
||||
mended — soft, because it is a record of an edit rather than something to
|
||||
aim at, and it is the ring under the pointer that says the spot can be taken
|
||||
hold of again. */
|
||||
so an idle spot is a faint print of the mark it made — soft, because it is a
|
||||
record of an edit rather than something to aim at, and it is the ring under
|
||||
the pointer that says the spot can be taken hold of again. */
|
||||
.brush-spot[data-idle] {
|
||||
border-color: transparent;
|
||||
background: rgba(127, 127, 127, 0.14);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { MIN_CROP_FRAC, type CropRect, type FrameId, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
|
||||
import { bitmapReader, samplePixel, specsLine, type ShotSpecs } from '../engine/imageOps';
|
||||
import { samplePixel, specsLine, type ShotSpecs } from '../engine/imageOps';
|
||||
import { RAW_ACCEPT } from '../engine/rawDevelop';
|
||||
// A phone's HEIC is offered beside the camera RAWs: it is the same offer to a
|
||||
// file the browser will not open by itself (see engine/heicDevelop.ts).
|
||||
import { HEIC_ACCEPT } from '../engine/heicDevelop';
|
||||
import { findHealSource } from '../../shared/utils/heal';
|
||||
import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask';
|
||||
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
|
||||
import { measureMark } from '../engine/exportEngine';
|
||||
@@ -110,8 +109,8 @@ const strokeRuns = (spots: { x: number; y: number; r: number }[], box: { width:
|
||||
// there too — the band then reads the same on a portrait photo as on a
|
||||
// landscape one. Different brushes are different marks and are not joined.
|
||||
const gap = Math.hypot((a.x - b.x) * box.width, (a.y - b.y) * box.height);
|
||||
// A spot with no radius is one this run is not about — the patch band's
|
||||
// patchless spots — and two of those are not a band either.
|
||||
// A spot with no radius is one no run is about, and two of those are not a
|
||||
// band either.
|
||||
if (a.r !== b.r || a.r <= 0 || gap > (a.r + b.r) * box.width) {
|
||||
flush();
|
||||
continue;
|
||||
@@ -270,10 +269,9 @@ export function ImageStage({
|
||||
straightenRuler = null,
|
||||
curvePanel = null,
|
||||
// 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.
|
||||
// spot is laid down at. One spot is a circle — HEAL's the area it fills,
|
||||
// MOSAIC's the cell it hides. 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,
|
||||
@@ -354,9 +352,8 @@ export function ImageStage({
|
||||
// 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.
|
||||
// HEAL's spots carry the area each one fills (shared/utils/heal.ts), which is
|
||||
// the picture the repair is being judged against.
|
||||
brushTool?: 'heal' | 'mosaic' | null;
|
||||
brushSpots?: (HealSpot | MosaicSpot)[];
|
||||
brushR?: number;
|
||||
@@ -602,40 +599,26 @@ export function ImageStage({
|
||||
// is drawn from the pointer track as it moves.
|
||||
//
|
||||
// A spot already on the photo is the other thing the pointer can take: HEAL's
|
||||
// spots are aimed repairs, so pressing one and dragging moves it — the hole,
|
||||
// or the patch it borrowed — and a press that never travels only chooses it,
|
||||
// which is what puts its × on the photo. MOSAIC's spots are not held: they are
|
||||
// cells of a covered region rather than a repair to place, and a mosaic that
|
||||
// could be grabbed would also be one the user cannot paint over again.
|
||||
// spots are aimed repairs, so pressing one and dragging moves it — and a press
|
||||
// that never travels only chooses it, which is what puts its × on the photo.
|
||||
// MOSAIC's spots are not held: they are cells of a covered region rather than a
|
||||
// repair to place, and a mosaic that could be grabbed would also be one the
|
||||
// user cannot paint over again.
|
||||
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 circle the pointer has hold of, then the spot a press chose, which
|
||||
// wears the × that deletes it. undoPushedRef is the gesture's own undo
|
||||
// boundary: the step is taken on the first change, so a drag is one step and
|
||||
// a press that changed nothing costs none.
|
||||
const grabRef = useRef<{ i: number; part: 'dst' | 'src' } | null>(null);
|
||||
// The spot a press chose, which wears the × that deletes it. undoPushedRef is
|
||||
// the gesture's own undo boundary: the step is taken on the first change, so a
|
||||
// drag is one step and a press that changed nothing costs none.
|
||||
const grabRef = useRef<number | null>(null);
|
||||
const [selSpot, setSelSpot] = useState<number | null>(null);
|
||||
// The spot in hand: the one the pointer is over, or the repair just laid,
|
||||
// which is the one the user is looking at. A HEAL spot wears its ring only
|
||||
// while it is in hand or chosen; the rest of them fall back to the patch they
|
||||
// borrowed and a faint print of what they mended, so a photo with a dozen
|
||||
// repairs is not a photo with a dozen rings.
|
||||
// which is the one the user is looking at. A spot wears its ring only while it
|
||||
// is in hand or chosen; the rest keep a faint print of the mark they made, so
|
||||
// a photo with a dozen repairs is not a photo with a dozen rings.
|
||||
const [handSpot, setHandSpot] = useState<number | null>(null);
|
||||
const undoPushedRef = useRef(false);
|
||||
|
||||
// The same decode the eyedropper keeps, under the same rule: one bitmap per
|
||||
// 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();
|
||||
pickCache.current?.bmp.close();
|
||||
pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) };
|
||||
}
|
||||
return bitmapReader(pickCache.current.bmp);
|
||||
}, [previewUrl]);
|
||||
|
||||
// A pointer position as both a fraction of the render (what a spot stores)
|
||||
// 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
|
||||
@@ -651,17 +634,15 @@ export function ImageStage({
|
||||
return { fx, fy, px: clientX - rect.left, py: clientY - rect.top };
|
||||
}, []);
|
||||
|
||||
// Which spot the pointer is on, if any: HEAL's own circles, holes and the
|
||||
// patches they borrowed alike. The nearest one wins — the patch a repair
|
||||
// borrowed sits a few pixels from the hole it fills on a small brush, so
|
||||
// taking the first circle found would hand the patch's own centre to the
|
||||
// hole — and the hole wins a tie with its own patch. The reach is grown past
|
||||
// the circle so a brush turned down to a few pixels is still a repair the
|
||||
// Which spot the pointer is on, if any: the circle the brush painted, and
|
||||
// nothing else — a repair borrows no patch any more, so the mark the user can
|
||||
// take hold of is the one they made. The nearest wins, and the reach is grown
|
||||
// past the circle so a brush turned down to a few pixels is still a repair the
|
||||
// user can take hold of.
|
||||
const spotUnder = useCallback(
|
||||
(p: BrushPoint) => {
|
||||
if (!box || brushTool !== 'heal' || !onBrushEdit) return null;
|
||||
let best: { i: number; part: 'dst' | 'src' } | null = null;
|
||||
let best: number | null = null;
|
||||
let bestGap = Infinity;
|
||||
for (let i = brushSpots.length - 1; i >= 0; i--) {
|
||||
const s = brushSpots[i];
|
||||
@@ -669,16 +650,10 @@ export function ImageStage({
|
||||
// a couple of pixels is still a repair the user can take hold of, and a
|
||||
// press a little way off one is still a press that means to paint.
|
||||
const reach = Math.max(4, s.r * box.width);
|
||||
const at = [
|
||||
{ part: 'dst' as const, x: s.x, y: s.y },
|
||||
...('sx' in s ? [{ part: 'src' as const, x: s.sx, y: s.sy }] : []),
|
||||
];
|
||||
for (const c of at) {
|
||||
const gap = Math.hypot((p.fx - c.x) * box.width, (p.fy - c.y) * box.height);
|
||||
if (gap > reach || gap >= bestGap) continue;
|
||||
best = { i, part: c.part };
|
||||
bestGap = gap;
|
||||
}
|
||||
const gap = Math.hypot((p.fx - s.x) * box.width, (p.fy - s.y) * box.height);
|
||||
if (gap > reach || gap >= bestGap) continue;
|
||||
best = i;
|
||||
bestGap = gap;
|
||||
}
|
||||
return best;
|
||||
},
|
||||
@@ -699,17 +674,12 @@ export function ImageStage({
|
||||
setHandSpot(null);
|
||||
};
|
||||
|
||||
// The band each stroke left, read off the spots the recipe holds — the holes
|
||||
// one way and, for HEAL, the patches they borrowed the other. Nothing about a
|
||||
// stroke is stored, so this is where "that long drag was one mark" comes from,
|
||||
// and it is the same band after a reload.
|
||||
// The band each stroke left, read off the spots the recipe holds. Nothing
|
||||
// about a stroke is stored, so this is where "that long drag was one mark"
|
||||
// comes from, and it is the same band after a reload.
|
||||
const bands = useMemo(() => {
|
||||
if (!box) return null;
|
||||
// Only HEAL's spots borrowed anything, so a spot with no patch has no
|
||||
// circle in the patch band: it is mapped to its own centre with no radius,
|
||||
// which joins nothing and draws nothing.
|
||||
const src = brushSpots.map((s) => ('sx' in s ? { x: s.sx, y: s.sy, r: s.r } : { x: s.x, y: s.y, r: 0 }));
|
||||
return { dst: strokeRuns(brushSpots, box), src: strokeRuns(src, box) };
|
||||
return { dst: strokeRuns(brushSpots, box) };
|
||||
}, [brushSpots, box]);
|
||||
|
||||
// Where the × of the spot the pointer chose goes: on its top-right diagonal,
|
||||
@@ -755,10 +725,10 @@ export function ImageStage({
|
||||
// press moves it rather than painting over it; anywhere else paints the
|
||||
// stroke it always did.
|
||||
const hit = spotUnder(p);
|
||||
if (hit) {
|
||||
if (hit !== null) {
|
||||
grabRef.current = hit;
|
||||
undoPushedRef.current = false;
|
||||
setSelSpot(hit.i);
|
||||
setSelSpot(hit);
|
||||
return;
|
||||
}
|
||||
setSelSpot(null);
|
||||
@@ -777,75 +747,80 @@ export function ImageStage({
|
||||
const p = brushPoint(e.clientX, e.clientY);
|
||||
setBrushPos(p ? { fx: p.fx, fy: p.fy } : null);
|
||||
const grab = grabRef.current;
|
||||
if (grab && onBrushEdit) {
|
||||
if (grab !== null && onBrushEdit) {
|
||||
// Off the photo the spot stays where it was: a repair is a place on the
|
||||
// picture, and a pointer past the frame has left it.
|
||||
if (!p) return;
|
||||
const was = brushSpots[grab.i];
|
||||
const next = brushSpots.slice();
|
||||
next[grab.i] =
|
||||
grab.part === 'src' && 'sx' in was ? { ...was, sx: p.fx, sy: p.fy } : { ...was, x: p.fx, y: p.fy };
|
||||
next[grab] = { ...next[grab], x: p.fx, y: p.fy };
|
||||
onBrushEdit(next, !undoPushedRef.current);
|
||||
undoPushedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const stroke = strokeRef.current;
|
||||
// Which repair is the one in hand, for the circle it wears: the spot under
|
||||
// Which repair is the one in hand, for the ring it wears: the spot under
|
||||
// the pointer. Only while nothing is being painted or dragged, so a stroke
|
||||
// does not flicker the rings it runs over.
|
||||
if (!stroke) setHandSpot(p ? (spotUnder(p)?.i ?? null) : null);
|
||||
if (!stroke) setHandSpot(p ? (spotUnder(p) ?? null) : null);
|
||||
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 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);
|
||||
const step = Math.max(1, brushR * stroke.w * BRUSH_SPACING);
|
||||
const dist = Math.hypot(p.px - last.px, p.py - last.py);
|
||||
if (dist < step) return;
|
||||
// The pointer's own samples are wherever the browser happened to report a
|
||||
// move, which on a fast drag is several radii apart — spots that far apart
|
||||
// do not touch, and a row of circles that do not touch is a row of circles
|
||||
// and not a stroke (the renderer's mask is their union). So the segment the
|
||||
// pointer travelled is walked at the brush's own spacing and a spot is laid
|
||||
// at every step: a run is joined whatever speed it was drawn at.
|
||||
const n = Math.floor(dist / step);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = (i * step) / dist;
|
||||
stroke.pts.push({
|
||||
fx: last.fx + (p.fx - last.fx) * t,
|
||||
fy: last.fy + (p.fy - last.fy) * t,
|
||||
px: last.px + (p.px - last.px) * t,
|
||||
py: last.py + (p.py - last.py) * t,
|
||||
});
|
||||
}
|
||||
setBrushTrail(stroke.pts.slice());
|
||||
};
|
||||
|
||||
const brushUp = useCallback(async (e?: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (panRef.current) {
|
||||
panRef.current = null;
|
||||
if (e?.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
return;
|
||||
}
|
||||
if (grabRef.current) {
|
||||
// A press on a spot that never travelled is a choice, not an edit: nothing
|
||||
// was written, so the caller is not asked for a step.
|
||||
grabRef.current = null;
|
||||
return;
|
||||
}
|
||||
const stroke = strokeRef.current;
|
||||
strokeRef.current = null;
|
||||
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
|
||||
// a patch from another light would show as a mark of its own, which is worse
|
||||
// than the speck. The speck stays; the user tries another size or another
|
||||
// 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, brushR);
|
||||
return src ? [{ x: p.fx, y: p.fy, r: brushR, sx: src.sx, sy: src.sy }] : [];
|
||||
});
|
||||
onBrushSpots(spots);
|
||||
// The repair just laid is the one in hand: the pointer has not moved since,
|
||||
// so no move has reported a hover, and without this the ring of the spot the
|
||||
// user is looking at is the one thing that would not be there. It gives the
|
||||
// ring up again the moment the pointer walks anywhere.
|
||||
if (spots.length) setHandSpot(brushSpots.length + spots.length - 1);
|
||||
}, [brushR, brushSpots.length, brushTool, previewReader, onBrushSpots]);
|
||||
const brushUp = useCallback(
|
||||
(e?: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (panRef.current) {
|
||||
panRef.current = null;
|
||||
if (e?.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
return;
|
||||
}
|
||||
if (grabRef.current !== null) {
|
||||
// A press on a spot that never travelled is a choice, not an edit:
|
||||
// nothing was written, so the caller is not asked for a step.
|
||||
grabRef.current = null;
|
||||
return;
|
||||
}
|
||||
const stroke = strokeRef.current;
|
||||
strokeRef.current = null;
|
||||
setBrushTrail([]);
|
||||
if (!stroke || !onBrushSpots) return;
|
||||
// Every spot the stroke laid down, reported together: the spots land with
|
||||
// the gesture rather than under it, which is one undo step however long
|
||||
// the stroke was. Nothing is read off the photo while they land — where a
|
||||
// repair is taken from is the renderer's business, not the brush's
|
||||
// (shared/utils/heal.ts), so a stroke over dust and a stroke over a face
|
||||
// are the same gesture and neither can be refused.
|
||||
const spots = stroke.pts.map((p) => ({ x: p.fx, y: p.fy, r: brushR }));
|
||||
onBrushSpots(spots);
|
||||
// The repair just laid is the one in hand: the pointer has not moved
|
||||
// since, so no move has reported a hover, and without this the ring of
|
||||
// the spot the user is looking at is the one thing that would not be
|
||||
// there. It gives the ring up again the moment the pointer walks anywhere.
|
||||
setHandSpot(brushSpots.length + spots.length - 1);
|
||||
},
|
||||
[brushR, brushSpots.length, onBrushSpots]
|
||||
);
|
||||
|
||||
// The wheel over the brush is the stage's own, as it is everywhere else in
|
||||
// the app: a repair is aimed at a zoom, and the wheel is the gesture a user
|
||||
@@ -1605,13 +1580,10 @@ export function ImageStage({
|
||||
{/* 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, and the wheel changes that size. A HEAL spot
|
||||
shows the pair the renderer works with — the circle it fills and
|
||||
the patch it borrowed — and wears that circle only while it is the
|
||||
spot in hand: the repairs already on the photo read as the marks
|
||||
they left rather than as a field of rings, and the ring comes back
|
||||
under the pointer. A MOSAIC spot has nothing to show beside
|
||||
itself, since what is under it is gone. */}
|
||||
size a spot will be, and the wheel changes that size. A spot
|
||||
wears its circle only while it is the spot in hand: the repairs
|
||||
already on the photo read as the marks they left rather than as a
|
||||
field of rings, and the ring comes back under the pointer. */}
|
||||
{brushTool && !cropEditing && box ? (
|
||||
<div
|
||||
className="brush-layer"
|
||||
@@ -1662,13 +1634,10 @@ export function ImageStage({
|
||||
</svg>
|
||||
) : null}
|
||||
{/* The band each stroke left, under the circles: the spots the
|
||||
run is made of, joined into the one mark the drag painted —
|
||||
the holes here, and HEAL's borrowed patches in the dashed band
|
||||
beside them. What is drawn is the recipe's own spots, so the
|
||||
band is what survives a reload, an undo and a copy of the
|
||||
recipe to another photo, where a stroke of its own would not.
|
||||
Laid in the same pass, the two bands cannot double up on
|
||||
themselves and print a darker patch at every joint. */}
|
||||
run is made of, joined into the one mark the drag painted.
|
||||
What is drawn is the recipe's own spots, so the band is what
|
||||
survives a reload, an undo and a copy of the recipe to another
|
||||
photo, where a stroke of its own would not. */}
|
||||
{bands?.dst.paths.length ? (
|
||||
<svg
|
||||
className="brush-band"
|
||||
@@ -1682,19 +1651,6 @@ export function ImageStage({
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{bands?.src.paths.length ? (
|
||||
<svg
|
||||
className="brush-band band-src"
|
||||
data-key="brush-band-src"
|
||||
data-bands={bands.src.paths.length}
|
||||
viewBox={`0 0 ${box.width} ${box.height}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{bands.src.paths.map((b, i) => (
|
||||
<path key={i} d={b.d} style={{ strokeWidth: b.w }} />
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{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
|
||||
@@ -1712,21 +1668,12 @@ export function ImageStage({
|
||||
// circles was showing.
|
||||
//
|
||||
// A HEAL spot that is neither in hand nor chosen is idle: it
|
||||
// keeps the patch it borrowed and a faint print of the place it
|
||||
// mended, and gives up the ring, which comes back under the
|
||||
// pointer — that is where the spot is taken hold of again, so
|
||||
// nothing about it is lost.
|
||||
// keeps a faint print of the repair it made, and gives up the
|
||||
// ring, which comes back under the pointer — that is where the
|
||||
// spot is taken hold of again, so nothing about it is lost.
|
||||
const idle = brushTool === 'heal' && i !== selSpot && i !== handSpot;
|
||||
return (
|
||||
<span className="brush-set" data-key="brush-set" data-i={i} key={i}>
|
||||
{'sx' in s ? (
|
||||
<span
|
||||
className="brush-source"
|
||||
data-key="brush-source"
|
||||
data-band={bands?.src.joined[i] ? '1' : undefined}
|
||||
style={at(s.sx, s.sy)}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className="brush-spot"
|
||||
data-key="brush-spot"
|
||||
|
||||
Reference in New Issue
Block a user