fix(studio): a dust spot is a place on the photo, so a quarter turn carries it with the frame

This commit is contained in:
2026-10-13 06:38:43 +07:00
parent 05a527557e
commit 1d7510366a
3 changed files with 278 additions and 2 deletions
@@ -0,0 +1,130 @@
// A mark the brush left is a place ON THE PHOTO. Turn the frame and it has to
// turn with it, or the repair slides off its mote and the mote is uncovered
// again (shared/utils/orientation.ts). The transform is one line of arithmetic
// per mark and none of it throws when it is wrong — it just puts the spot a
// little to the side — so it is measured here against the renderer's own moves
// written a second time in PIXELS: rotateSkImage90's (x, y) -> (H - y, x) and
// flipSkImage's mirror about each axis, undone and redone around every pair of
// orientations, on every corner of the frame and both photo shapes.
//
// node scripts/orientation-check.mjs
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'orientation-check-'));
writeFileSync(join(dir, 'orientation.mjs'), transpile('../shared/utils/orientation.ts'));
const { layMarks } = await import(pathToFileURL(join(dir, 'orientation.mjs')).href);
const ROTS = [0, 90, 180, 270];
const ORI = [];
for (const rot of ROTS) for (const flipH of [false, true]) for (const flipV of [false, true]) ORI.push({ rot, flipH, flipV });
// The renderer, a second time and in pixels — this is the reference the module
// under test has to agree with, not a call into it.
// A quarter turn clockwise: a W x H frame comes out H x W, and the pixel that
// was at (x, y) is at (H - y, x) — see rotateSkImage90's translate(h, 0) /
// rotate(90) pair.
const turnPx = (p, W, H) => ({ x: H - p.y, y: p.x, W: H, H: W });
const unturnPx = (p, W, H) => ({ x: p.y, y: W - p.x, W: H, H: W }); // the inverse of the above
const flipPx = (p, flipH, flipV, W, H) => ({ x: flipH ? W - p.x : p.x, y: flipV ? H - p.y : p.y, W, H });
// Where a mark drawn on `from`'s frame lands on `to`'s frame, worked out in
// pixels: off the old frame, through the photo as it arrived, onto the new one.
// The radius is a length of the photo, so it is carried in pixels too and read
// back out as a fraction of the width it ends up under. The order is the
// renderer's read backwards and then forwards again — the mirrors come off
// first because they go on last.
function reference(spot, from, to, photoW, photoH) {
const fromW = from.rot % 180 === 0 ? photoW : photoH;
const fromH = from.rot % 180 === 0 ? photoH : photoW;
let p = { x: spot.x * fromW, y: spot.y * fromH, W: fromW, H: fromH };
const rad = spot.r * fromW;
p = flipPx(p, from.flipH, from.flipV, p.W, p.H);
for (let i = 0; i < (from.rot / 90) % 4; i++) p = unturnPx(p, p.W, p.H);
for (let i = 0; i < (to.rot / 90) % 4; i++) p = turnPx(p, p.W, p.H);
p = flipPx(p, to.flipH, to.flipV, p.W, p.H);
return { x: p.x / p.W, y: p.y / p.H, r: rad / p.W };
}
const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-9, `${msg}: ${a} vs ${b}`);
// An ellipse is the same ellipse at angle and angle + pi, and the quarter turns
// land a full turn apart when the frame starts at 270 (270 -> 0 is +90, read as
// a -270 the long way round). So angles are compared where they mean something.
const nearAngle = (a, b, msg) => {
const d = (((a - b) % Math.PI) + Math.PI) % Math.PI;
assert.ok(Math.min(d, Math.PI - d) < 1e-9, `${msg}: ${a} vs ${b}`);
};
let checked = 0;
for (const [photoW, photoH] of [
[1600, 1200],
[1200, 1600],
]) {
for (const from of ORI) {
for (const to of ORI) {
for (const spot of [
{ x: 0.13, y: 0.71, r: 0.012 },
{ x: 0.5, y: 0.5, r: 0.05 },
{ x: 0, y: 0, r: 0.5 },
{ x: 1, y: 1, r: 0.001 },
]) {
const want = reference(spot, from, to, photoW, photoH);
const got = layMarks({ heal: [spot] }, from, to, { w: photoW, h: photoH }).heal[0];
const tag = `${photoW}x${photoH} ${JSON.stringify(from)} -> ${JSON.stringify(to)} ${JSON.stringify(spot)}`;
near(got.x, want.x, `x ${tag}`);
near(got.y, want.y, `y ${tag}`);
near(got.r, want.r, `r ${tag}`);
checked++;
}
}
}
}
console.log(`spots: ${checked} carried, all on their own photo`);
// The two things a quarter turn promises in words: the spot is still the same
// number of PIXELS across, and a round trip hands the frame back.
const spot = { x: 0.31, y: 0.62, r: 0.02 };
const q = layMarks({ heal: [spot] }, { rot: 0, flipH: false, flipV: false }, { rot: 90, flipH: false, flipV: false }, { w: 1600, h: 1200 }).heal[0];
near(q.r * 1200, spot.r * 1600, 'a 90 degree turn keeps the spot the same width');
near(q.x, 1 - spot.y, '90 CW puts x where 1 - y was');
near(q.y, spot.x, '90 CW puts y where x was');
const back = layMarks({ heal: [q] }, { rot: 90, flipH: false, flipV: false }, { rot: 0, flipH: false, flipV: false }, { w: 1600, h: 1200 }).heal[0];
near(back.x, spot.x, 'four turns come home');
near(back.y, spot.y, 'four turns come home');
near(back.r, spot.r, 'four turns come home');
// Masks: the same two points, the semi-axes rescaled like a radius, and the
// ellipse's angle turned with the frame — a mirror is a sign flip, an odd turn
// is a quarter of π.
const radial = { kind: 'radial', x: 0.2, y: 0.3, ex: 0.4, ey: 0.5, rx: 0.1, ry: 0.05, angle: 0, feather: 0.5, exposure: 1, contrast: 0, saturation: 0 };
const m90 = layMarks({ masks: [radial] }, { rot: 0, flipH: false, flipV: false }, { rot: 90, flipH: false, flipV: false }, { w: 1600, h: 1200 }).masks[0];
nearAngle(m90.angle, Math.PI / 2, '90 degrees turns the ellipse a quarter of pi');
near(m90.x, 1 - radial.y, 'the mask centre turns with the frame');
near(m90.rx * 1200, radial.rx * 1600, 'a semi-axis is a width like any other');
const m270 = layMarks({ masks: [radial] }, { rot: 270, flipH: false, flipV: false }, { rot: 0, flipH: false, flipV: false }, { w: 1600, h: 1200 }).masks[0];
nearAngle(m270.angle, Math.PI / 2, 'a quarter turn is a quarter turn whatever the frame was on');
const mFlip = layMarks({ masks: [radial] }, { rot: 0, flipH: false, flipV: false }, { rot: 0, flipH: true, flipV: false }, { w: 1600, h: 1200 }).masks[0];
nearAngle(mFlip.angle, -radial.angle, 'a mirror reflects the ellipse');
near(mFlip.x, 1 - radial.x, 'a mirror reflects the centre');
const mBoth = layMarks({ masks: [radial] }, { rot: 0, flipH: true, flipV: false }, { rot: 0, flipH: true, flipV: true }, { w: 1600, h: 1200 }).masks[0];
nearAngle(mBoth.angle, radial.angle, 'two mirrors are a half turn, and that is no turn for an ellipse');
// Nothing moves when nothing moved, and the marks of a look that has none stay
// absent rather than becoming empty lists the renderer would build a pass for.
const empty = { exposureCompensation: 0 };
assert.equal(layMarks(empty, ORI[0], ORI[0], { w: 1600, h: 1200 }), empty, 'a still frame hands the same object back');
const carried = layMarks({ ...empty, heal: [spot] }, ORI[0], ORI[5], { w: 1600, h: 1200 });
assert.equal(carried.mosaic, undefined, 'a look without hidden patches gains none');
assert.equal(carried.masks, undefined, 'a look without masks gains none');
assert.equal(layMarks({ ...empty, heal: [spot] }, ORI[0], ORI[5], null).heal[0], spot, 'nothing is carried before the photo is known');
console.log('VERDICT orientation: ok');
+124
View File
@@ -0,0 +1,124 @@
import type { ColorAdjustments, GradientMask } from '../types';
// FRAME tab > ROTATE and the two mirrors, against the marks the brushes left.
//
// Everything positional in a recipe — HEAL's dust spots, MOSAIC's hidden
// patches, the gradient masks — is measured as a fraction of the frame the user
// drew it on (x of the frame's width, y of its height, radii of its width;
// shared/utils/heal.ts). The fractions are what land the same mark on the
// preview and on the full-size export. They also mean a mark is a place ON THE
// PHOTO, not a place on the screen: turn or mirror the frame under it and the
// fraction it holds points somewhere else on the picture. HEAL is where that
// shows — the renderer works the repair out of the photo around the mask's rim,
// so a spot that has slid off its mote paints a clean circle beside the mote
// while the mote itself is uncovered again — but the same drift moves every
// hidden patch and every mask.
//
// So the marks travel with the photo: taken off the frame they were drawn on and
// put back on the frame the user now has. The order is the renderer's own
// (shared/utils/skiaImage.ts applyPhotoRotation), read backwards — the mirrors
// come off first because they go on last, then the quarter turns, and the same
// two moves go back on in the renderer's order.
//
// STRAIGHTEN is deliberately not carried. A fine angle is a knob the user drags
// through every value between two degrees, and the marks would have to be
// rebuilt on each of those frames only to smear themselves across the photo as
// the drag goes on (rotateSkImageBy also scales the content up to cover the
// corners, so a spot would have to be scaled by that same factor and would
// still creep inward). ponytail: the discrete moves are the ones worth carrying;
// a straighten that moves the dust wants the marks stored against the photo
// itself rather than against the frame.
//
// A committed crop is the same kind of limit, older than this file: the crop
// window is a rectangle of the frame, the marks are measured inside it, and the
// window does not turn when the frame does — the crop already picks a different
// part of the photo after a quarter turn. The marks are carried as if the frame
// turned whole, which keeps them on their own photo where the crop has not moved
// and is no worse than standing still where it has.
export interface Orientation {
rot: number; // the quarter turn, degrees clockwise: 0, 90, 180 or 270
flipH: boolean;
flipV: boolean;
}
type Dims = { w: number; h: number };
type Spot = { x: number; y: number; r: number };
// The photo after its quarter turns: the two sides swap on an odd one.
const frameDims = (photo: Dims, rot: number): Dims =>
rot % 180 === 0 ? { w: photo.w, h: photo.h } : { w: photo.h, h: photo.w };
// `turns` quarter turns clockwise in fraction space: (x, y) -> (1 - y, x). The
// frame's sides swap with it, and a radius held as a fraction of the width keeps
// its pixels only if it is rescaled by the width it was measured against over the
// one it is about to sit in — which is why the dims travel back out with the
// point.
function turn(spot: Spot, turns: number, w: number, h: number) {
let { x, y, r } = spot;
for (let i = 0; i < ((turns % 4) + 4) % 4; i++) {
const nx = 1 - y;
y = x;
x = nx;
r = (r * w) / h;
const swap = w;
w = h;
h = swap;
}
return { spot: { x, y, r }, w, h };
}
// One mark, off the old frame and onto the new one. A mirror does not move the
// dims and a turn does, so the two are applied in the renderer's order and the
// dims are read at each step.
function carry(spot: Spot, from: Orientation, to: Orientation, photo: Dims): Spot {
let p: Spot = { ...spot };
if (from.flipH) p.x = 1 - p.x;
if (from.flipV) p.y = 1 - p.y;
let off = frameDims(photo, from.rot);
let back = turn(p, 4 - from.rot / 90, off.w, off.h); // the photo as it arrived
let on = turn(back.spot, to.rot / 90, back.w, back.h);
p = on.spot;
if (to.flipH) p.x = 1 - p.x;
if (to.flipV) p.y = 1 - p.y;
return p;
}
// A mask is two points (the drag's ends, or a radial's centre and a corner of its
// box) and two semi-axes. The axes are fractions of the width like a spot's
// radius, so they take the same rescale; the ellipse's angle takes the turn as a
// turn and a mirror as a sign flip — an ellipse is the same ellipse at angle θ
// and θ + π, which is what makes one sign enough for a reflection.
function carryMask(m: GradientMask, from: Orientation, to: Orientation, photo: Dims): GradientMask {
const a = carry({ x: m.x, y: m.y, r: m.rx }, from, to, photo);
const b = carry({ x: m.ex, y: m.ey, r: m.ry }, from, to, photo);
const mirror = (o: Orientation) => (o.flipH !== o.flipV ? -1 : 1);
return {
...m,
x: a.x,
y: a.y,
ex: b.x,
ey: b.y,
rx: a.r,
ry: b.r,
angle: mirror(to) * (mirror(from) * m.angle + ((to.rot - from.rot) * Math.PI) / 180),
};
}
// Every mark of a look, carried from the frame it was drawn on to the frame the
// user has just turned it into. A no-op hands back the same object, so a caller
// can put this on every pick of the ROTATE strip without churning the recipe.
export function layMarks(
adj: ColorAdjustments,
from: Orientation,
to: Orientation,
photo: Dims | null | undefined
): ColorAdjustments {
if (!photo?.w || !photo?.h) return adj;
if (from.rot === to.rot && from.flipH === to.flipH && from.flipV === to.flipV) return adj;
return {
...adj,
heal: adj.heal?.map((s) => ({ ...s, ...carry(s, from, to, photo) })),
mosaic: adj.mosaic?.map((s) => ({ ...s, ...carry(s, from, to, photo) })),
masks: adj.masks?.map((m) => carryMask(m, from, to, photo)),
};
}
+24 -2
View File
@@ -63,6 +63,7 @@ import {
import { grainPerInch, grainStockFor } from '../shared/utils/grainShader';
import { curveIsActive } from '../shared/utils/toneCurve';
import { HEAL_DEFAULT_R } from '../shared/utils/heal';
import { layMarks, type Orientation } from '../shared/utils/orientation';
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
import { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram';
@@ -1498,6 +1499,22 @@ export function Workspace() {
[remember, setAdjustment]
);
// The ROTATE strip and the two mirrors are the only things that move the photo
// under the marks already on it, and a mark is a place on the photo, not a
// place on the screen: turn the frame and every spot the dust brush laid down
// has to turn with it, or the repair slides off its mote and the mote is
// uncovered again (shared/utils/orientation.ts). One undo step, like the turn
// itself — UNDO hands back the frame AND the marks of the frame before it.
const carryMarks = useCallback(
(next: Orientation) => {
setRecipe((prev) => ({
...prev,
adjustments: layMarks(prev.adjustments, { rot: rotation, flipH, flipV }, next, photoDims),
}));
},
[rotation, flipH, flipV, photoDims]
);
// FX's HEAL brush writes the spots the same way a knob writes its value: one
// gesture is one undo step whatever it laid down, so UNDO takes the dust back
// out a stroke at a time, and CLEAR — one step — hands the whole photo back.
@@ -2851,9 +2868,12 @@ export function Workspace() {
],
onPick: (v) => {
remember();
setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270));
const next = v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270);
// RESET levels the whole frame, angle and mirrors included.
if (v === 'reset') {
const level = v === 'reset';
carryMarks({ rot: next, flipH: level ? false : flipH, flipV: level ? false : flipV });
setRotation(next);
if (level) {
setStraighten(0);
setFlipH(false);
setFlipV(false);
@@ -3759,6 +3779,7 @@ export function Workspace() {
active: flipH,
onClick: () => {
remember();
carryMarks({ rot: rotation, flipH: !flipH, flipV });
setFlipH((v) => !v);
},
},
@@ -3768,6 +3789,7 @@ export function Workspace() {
active: flipV,
onClick: () => {
remember();
carryMarks({ rot: rotation, flipH, flipV: !flipV });
setFlipV((v) => !v);
},
},