From 1d7510366ab245334ad130fb2f532fe211e6f810 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 06:38:43 +0700 Subject: [PATCH] fix(studio): a dust spot is a place on the photo, so a quarter turn carries it with the frame --- docker/frontend/scripts/orientation-check.mjs | 130 ++++++++++++++++++ docker/frontend/shared/utils/orientation.ts | 124 +++++++++++++++++ docker/frontend/src/App.tsx | 26 +++- 3 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 docker/frontend/scripts/orientation-check.mjs create mode 100644 docker/frontend/shared/utils/orientation.ts diff --git a/docker/frontend/scripts/orientation-check.mjs b/docker/frontend/scripts/orientation-check.mjs new file mode 100644 index 0000000..a7f52f4 --- /dev/null +++ b/docker/frontend/scripts/orientation-check.mjs @@ -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'); diff --git a/docker/frontend/shared/utils/orientation.ts b/docker/frontend/shared/utils/orientation.ts new file mode 100644 index 0000000..3d8133d --- /dev/null +++ b/docker/frontend/shared/utils/orientation.ts @@ -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)), + }; +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 28a84ee..833da5c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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); }, },