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:
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user