studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels

Two specs, one commit: the develop state becomes the panel column the Lightroom
spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become
shapes of the tone curve, the way the mapping spec measures them.

The column. The left rail used to hand LIGHT a row of chips and nothing else;
the four tone knobs were chips that opened a curve, and the rest of the develop
state lived in the chip row's own vocabulary. `DevelopPanels` renders the five
sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS —
as an accordion, all open, and every parameter the recipe holds has a row in it
with a `data-key` off the parameter name: slider, value readout, double-click to
default. Sliders are always visible, so a knob is one drag away instead of two
taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber,
green through pink) so the direction is on the control. The PRO looks the spec
marks stay in the list but locked, tagged PRO, and tapping one asks for PRO —
they are shown, not hidden, and not silently dropped.

WHITE and BLACK move out of the WB group. They were the temperature group's
extremes, which is what a white balance control does — the toe and the shoulder
of the same ramp — but the spec puts them with the tone knobs and gives them the
two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE
and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights`
and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives.
A recipe written before this commit still reads: the keys are unchanged.

The four knobs. The first pass of the mapping spec added a mask per zone onto the
channel: `luma += knob * mask * intensity`, and the shader followed it. It is the
wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the
four masks are not a partition of the ramp. They sum to one at the ends and to
zero at the midpoint, so an adjustment in the middle of a zone is applied where
the mask is half and not at all where the mask has fallen to nothing, and the
ramp inverts: with every knob at its stop the curve folds over itself, slope −5
at t=0.87, and the twin catches it as a non-monotone ramp.

So each knob moves a knot on the curve instead, which is the reading the spec's
own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50,
HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve
through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a
quarter of the range at that knot, so the range is 0.75..1.00 at the top and
0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`)
by clamping each against its neighbour. Between knots the curve is a straight
line, and 0.5 is untouched by every knob, so a knob at zero is the identity
exactly rather than nearly, and any combination of the four is monotone. The mask
sum survives where the spec is right about it: it hints the split between the two
dark zones and the two light ones, nothing else.

The hue is kept the way the spec keeps it: work in luma, then scale the chroma
offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a
saturated red stays the same red and only its brightness moves. The ratio is
clamped to 0.55..1.35 because at luma near zero the division is the whole
highlight of the picture on one code value.

Verified:

- `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader —
  four masks, four anchors, the four `mix` lines — and asserts the constructions
  it replaced are gone, then drives a twin of the ramp in JS: the masks do not
  overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162
  combinations of the four knobs, every combination is monotone, the amplitude at
  each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded
  case from the additive build is in the harness as a regression.
- `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and
  `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`,
  `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check`
  and `white-level-check` all pass against the bundle — the catalogue, the RAW
  path, auto tone and the white level are untouched by the panel move.
- Driven in a real browser (`tone-live-check.mjs`, Chromium against
  `vite preview`, a P1010256.JPG in the source control, mean luma of the preview
  canvas read before and after each knob): neutral 184.25, WHITE +1 187.35,
  BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80.
  Every knob moves the picture the way the spec says it should and none of them
  moves it much — a stop of a knob is a quarter of a zone, not a level.
- The same run asserts the built DOM: five panels, the 23 `data-key` rows,
  `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and
  `dev-shadow` together in TONE, the gradient classes on the two white balance
  sliders, and the chip slots the panel is handed. The only failed request is
  `/api/events`, which is the backend this preview does not run.

ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a
per-channel rolloff in linear light — is gone, deleted rather than ported. The
additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask
could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends,
and the per-channel rolloff is a second knob for the same picture. Bring it back
as its own parameter if a frame ever clips badly enough to need it.

Also dropped: DR used to ride along as two additive terms. That is where the fold
at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the
ramp past its own end. It shifts the knots now, which is what the film sims
always meant by it, and the numbers in the sims were kept and their meaning
recommented (classic-chrome toe 0.22, head 0.7375, etc.).

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 16:38:56 +07:00
parent 64d41f67e9
commit 34f8601c91
7 changed files with 618 additions and 199 deletions
+210 -55
View File
@@ -1,20 +1,28 @@
// Highlight roll-off, both ends of the pipeline, as one soft knee: // Highlight roll-off in the develop, and the tonal-range ramp in the tone pass.
//
// THE DEVELOP holds the knee:
// //
// L' = L , L < T // L' = L , L < T
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T // L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
// //
// The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), // drawn on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), which puts the
// which puts the asymptote on 1.0) so the two stops the sensor holds above its // asymptote on 1.0) so the two stops the sensor holds above its white level are
// white level are COMPRESSED into the frame instead of being thrown away by the // COMPRESSED into the frame instead of being thrown away by the old
// old fade-to-white; the tone pass draws the same curve in linear light on the // fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
// value the develop and the camera match left, where -HL is the knob (T = 0.5, // top to move. Measured on DSC03453.ARW, where the camera's own preview is
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all // clipped, the develop's luma was 253.4 with a standard deviation of 2.4, against
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW, // 251.2 / 10.0 through the knee.
// where the camera's own preview is clipped the develop's luma was 253.4 with a
// standard deviation of 2.4, against 251.2 / 10.0 through the knee.
// //
// Both are SkSL, so the shape is pinned on the source; the curve itself is // THE TONE PASS builds the luma a new ramp instead. The four knobs
// checked as arithmetic, with the constants the source is asserted to carry. // (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc —
// one tent each, one per quarter of the ramp — and each knob moves the knot it
// owns by TONE_ANCHOR of the ramp, held inside the knot before it. The 0.50
// midpoint is the one value all four leave where it was.
//
// Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then
// checked against the source's own constants, and the ramp re-run here as a twin
// so monotonicity, the neutral identity and the partition of the four masks are
// checked rather than asserted in a comment.
// //
// node scripts/highlight-knee-check.mjs // node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -31,56 +39,203 @@ assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/); assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back'); assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
// The tone pass: the knee runs in LINEAR light and before the luma is read, the // The tone pass, read as the string it actually emits: TONE_ANCHOR is
// lift keeps its headroom weight — and the recovery must not also ride the // interpolated, so the template has to be resolved before it can be matched.
// additive term, which would darken the white the knee protects. const anchorSrc = tone.match(/export const TONE_ANCHOR = ([0-9.]+);/)?.[1];
assert.match(tone, /if \(hl < 0\.0\) \{/); assert.ok(anchorSrc, 'TONE_ANCHOR is gone — the four knots no longer share a reach');
assert.match(tone, /vec3 lin = toLinear\(rgb\);/); const A = Number(anchorSrc);
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/); assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter of the ramp');
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/); const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1];
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/); assert.ok(tmpl, 'TONE_SKSL is gone');
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/); assert.equal((tmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
// The transfer pair has to be the accurate one, or the knee is drawn in a space const sksl = tmpl.replaceAll('${TONE_ANCHOR}', String(A));
// that is not linear at all.
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
// no luma is counted by two of them.
assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/);
assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/);
assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/);
assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/);
// The ramp: five knots, each moved by its own knob and held inside the one
// before it. The 0.50 knot is a literal — nothing may move the midpoint. DR
// moves the same knots, on the toe and the head exactly as it did when it was a
// pair of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the
// knots next to them.
assert.match(sksl, /float a4 = 1\.0 \+ 0\.25 \* wh - dr \* 0\.18;/);
assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* hl - dr \* 0\.09, 0\.5, a4\);/);
assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/);
assert.match(sksl, /float a0 = clamp\(0\.25 \* bl \+ dr \* 0\.12, 0\.0, a1\);/);
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
// Straight between the knots, and NOT a smoothstep: an S-curve through the
// knots bends the ramp by six code values in the quarter-tones with every knob
// on zero, and this pass also runs for the stock split tones and for DR alone.
assert.match(sksl, /float lin\(float e0, float e1, float x\) \{\n return clamp\(\(x - e0\) \/ \(e1 - e0\), 0\.0, 1\.0\);\n\}/);
assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, t\)\), step\(0\.25, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, t\)\), step\(0\.50, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, t\)\), step\(0\.75, t\)\);/);
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
// The linear-light knee that used to run ahead of all this is GONE from the tone
// pass: HIGHLIGHT is one zone move in both directions now, and a second pass over
// the same knob would double-count it.
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
// The transfer pair has to be the accurate one where it is still used (the
// exposure pass), or that pass is drawn in a space that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/); assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what // The develop's arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is its pair (S is what
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of // puts the asymptote on 1.0: T + 1/(2S) = 1).
// the tone pass's knob.
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T))); const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
const T = 0.7;
const S = 1 / (2 * (1 - T));
for (const [T, S] of [ // Below the knee the frame is untouched, and the curve is continuous and C1 at T
[0.7, 1 / (2 * (1 - 0.7))], // — slope 1 on both sides — so there is no seam for a later pass to mask.
[0.5, 0.25], assert.equal(knee(T - 0.2, T, S), T - 0.2);
[0.5, 1], assert.equal(knee(T, T, S), T);
]) { const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
// Below the knee the frame is untouched, and the curve is continuous and C1 at assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
// T — slope 1 on both sides — so there is no seam for a later pass to mask. // Monotone, and never a brightening: an inverted pair of pixels is a visible edge.
assert.equal(knee(T - 0.2, T, S), T - 0.2); let prev = -Infinity;
assert.equal(knee(T, T, S), T); for (let l = 0; l <= 2; l += 1 / 512) {
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6; assert.ok(slope(l) > 0, `inverted at ${l}`);
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`); assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
// Monotone, and never a brightening: a recovery slider that lifted a highlight assert.ok(knee(l, T, S) >= prev);
// would be a lift in disguise, and an inverted pair of pixels is a visible edge. prev = knee(l, T, S);
let prev = -Infinity;
for (let l = 0; l <= 2; l += 1 / 512) {
assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`);
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
assert.ok(knee(l, T, S) >= prev);
prev = knee(l, T, S);
}
// The asymptote: everything the sensor held above the knee lands under it.
// The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
} }
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0'); // The asymptote: everything the sensor held above the knee lands under it, on
// exactly 1.0.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
assert.ok(Math.abs(T + 1 / (2 * S) - 1) < 1e-9, 'the develop plateau left 1.0');
// ...and the same pair in the encoded domain, which is the domain the develop // ...and the same pair in the encoded domain, which is the domain the develop
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau // hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
// (1.93, see the gain above) on 248 — a ramp of a dozen code values where the // (1.93) on 248 — a ramp of a dozen code values where the old fade-to-white left
// old fade-to-white left nothing above 250 at all. // nothing above 250 at all. This is the headroom the four tone knobs move.
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055); const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
const DEV_S = 1 / (2 * (1 - 0.7)); assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237); assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248);
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
// The ramp as arithmetic — the same knots, the same lin() and the same step()
// guards the SkSL above carries, so the shape is measured and not described.
const clamp01 = (x) => Math.min(1, Math.max(0, x));
// Float-exact comparisons are a trap once a value has been through a division
// and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value.
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`);
const smoothstep = (e0, e1, x) => {
const u = clamp01((x - e0) / (e1 - e0));
return u * u * (3 - 2 * u);
};
const lin = (e0, e1, x) => clamp01((x - e0) / (e1 - e0));
const step = (edge, x) => (x < edge ? 0 : 1);
const mix = (a, b, t) => a + (b - a) * t;
function ramp(t, k) {
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
const blMask = 1 - smoothstep(0, 0.25, t);
const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask);
const whMask = smoothstep(0.75, 1, t);
const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask);
const a4 = 1 + A * wh - dr * 0.18;
const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * hl - dr * 0.09));
const a1 = Math.min(0.5, Math.max(0, 0.25 + A * sh + dr * 0.06));
const a0 = Math.min(a1, Math.max(0, A * bl + dr * 0.12));
let o = mix(a0, a1, lin(0, 0.25, t));
o = mix(o, mix(a1, 0.5, lin(0.25, 0.5, t)), step(0.25, t));
o = mix(o, mix(0.5, a3, lin(0.5, 0.75, t)), step(0.5, t));
o = mix(o, mix(a3, a4, lin(0.75, 1, t)), step(0.75, t));
return { o: clamp01(o), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask };
}
// The tents never overlap — each is the doc's smoothstep minus the tent before
// it, so the four together never count a luma twice — and the middle is the
// quiet part: the ends of the ramp are weighted at 1, the 0.50 midpoint by
// nothing at all. That is what leaves DR and the stock split tones on the two
// ends and the mid-grey still.
for (let t = 0; t <= 1; t += 1 / 512) {
const { maskSum } = ramp(t, {});
assert.ok(maskSum >= -1e-15 && maskSum <= 1 + 1e-15, `masks overlap at ${t}: ${maskSum}`);
if (t <= 0.25 || t >= 0.75) assert.ok(Math.abs(maskSum - 1) < 1e-12, `end of the ramp unweighted at ${t}`);
if (Math.abs(t - 0.5) < 1e-12) assert.equal(maskSum, 0, 'the midpoint is weighted');
}
// The neighbouring tents cross at half weight ON the knot between them, and the
// 0.50 midpoint is where all four are on zero — the quiet value, and the reason
// a mid-grey does not move while the ends do.
assert.equal(ramp(0.125, {}).blMask, 0.5);
assert.equal(ramp(0.125, {}).blMask, ramp(0.125, {}).shMask);
assert.equal(ramp(0.25, {}).shMask, 1);
assert.equal(ramp(0.25, {}).blMask, 0);
assert.equal(ramp(0.875, {}).hlMask, ramp(0.875, {}).whMask);
assert.equal(ramp(0.5, {}).maskSum, 0);
assert.equal(ramp(0.75, {}).hlMask, 1);
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
// split tones and for DR alone, so a neutral setting must not curve the frame.
for (let t = 0; t <= 1; t += 1 / 256) close(ramp(t, {}).o, t, `identity broke at ${t}`);
// The midpoint is the one value no knob reaches, at any setting.
for (const k of [{ hl: 1, sh: 1, wh: 1, bl: 1 }, { hl: -1, sh: -1, wh: -1, bl: -1 }, { hl: 1, sh: -1, wh: -1, bl: 1 }])
close(ramp(0.5, k).o, 0.5, 'a knob moved the midpoint');
// Monotone under EVERY combination of the four at full deflection, DR included.
// This is the whole reason the knots exist instead of the doc's additive masks,
// which measured a slope of -5 per unit luma on BLACK +1 against SHADOW -1 (an
// inverted band at t = 0.875, scratchpad tone-proto.mjs): every knot is clamped
// inside the one before it, so the ramp cannot fold.
const combos = [];
for (const bl of [-1, 0, 1])
for (const sh of [-1, 0, 1])
for (const hl of [-1, 0, 1])
for (const wh of [-1, 0, 1])
for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
let worst = Infinity;
for (const k of combos) {
let prev = null;
for (let t = 0; t <= 1; t += 1 / 512) {
const o = ramp(t, k).o;
if (prev !== null) {
assert.ok(o >= prev - 1e-12, `ramp folded at ${t} for ${JSON.stringify(k)}`);
if (o - prev < worst) worst = o - prev;
}
prev = o;
}
}
assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`);
// A knob moves its own quarter, and only its own: +BLACK takes the toe off the
// floor, +SHADOW puts the 0.25 knot on the midpoint, -HIGHLIGHT pulls the 0.75
// knot onto it, and WHITE - rolls the head under 1.0. That is the reach a
// tonal-range slider has — a quarter of the ramp, so the middle stays a middle.
close(ramp(0, {}).o, 0, 'a neutral toe moved');
close(ramp(0, { bl: 1 }).o, A, 'BLACK no longer reaches a quarter of the ramp');
close(ramp(0.25, { sh: 1 }).o, 0.5, 'SHADOW no longer reaches the midpoint');
close(ramp(0.75, { hl: -1 }).o, 0.5, 'HIGHLIGHT no longer reaches the midpoint');
close(ramp(1, { wh: -1 }).o, 0.75, 'WHITE no longer rolls the head under 1.0');
close(ramp(0.25, {}).o, 0.25, 'a neutral knot moved');
close(ramp(0.75, {}).o, 0.75, 'a neutral knot moved');
// WHITE + is free to pass 1.0 — that is the move that clips a highlight to
// white — and the ramp still runs through a raised knot at 1.25.
assert.ok(1 + A * 1 > 1, 'the white knot can no longer pass 1.0');
close(ramp(1, { wh: 1 }).o, 1, 'a raised white knot left the top of the ramp');
// DR at full is the same curve it was: the toe on 0.12 and the head on 0.82,
// which is what the two masked terms added at t = 0 and t = 1, and the midpoint
// still untouched. Now it is a knot move, so BLACK and SHADOW both at -1 (a flat
// stretch between 0.25 and 0.5, where the old additive lift sloped down and
// folded the ramp at 0.238) stays monotone.
close(ramp(0, { dr: 1 }).o, 0.12, 'DR no longer lifts the toe the way it did');
close(ramp(1, { dr: 1 }).o, 0.82, 'DR no longer rolls the head the way it did');
close(ramp(0.5, { dr: 1 }).o, 0.5, 'DR moved the midpoint');
// Black and shadow both at -1 are the flat stretch DR used to fold: the toe is
// held on the floor by the ordering clamp (BLACK's -0.25 cancels DR's +0.12),
// the 0.25 knot is DR's own +0.06, and the stretch between them is a straight
// line up to the midpoint — never a step down.
assert.equal(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0);
assert.equal(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.06);
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28, 'DR folded the flat stretch');
// The two ends stay ordered even at full deflection against each other: the toe
// can never climb past the head.
for (const bl of [-1, 1])
for (const wh of [-1, 1]) {
const toe = ramp(0, { bl, sh: 1, wh }).o;
const head = ramp(1, { bl, wh, hl: -1 }).o;
assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`);
}
console.log('highlight-knee-check ok'); console.log('highlight-knee-check ok');
+27 -26
View File
@@ -105,6 +105,33 @@ export const PARAM_DEFS: {
get: (a) => a.shadow ?? 0, get: (a) => a.shadow ?? 0,
set: (v) => ({ shadow: v }), set: (v) => ({ shadow: v }),
}, },
{
// The two ends of the same ramp the four tone sliders move together (see
// TONE_SKSL): WHITE is the knot on 1.00 and BLACK the one on 0.00, each
// pulled toward the middle as the knob comes down. Not a white-balance
// move any more, so they sit with the other two on the TONE panel. Same
// range as the CREATE form's rows, so a look round-trips.
key: 'whites',
label: 'WHITE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.whites ?? 0,
set: (v) => ({ whites: v }),
},
{
key: 'blacks',
label: 'BLACK',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.blacks ?? 0,
set: (v) => ({ blacks: v }),
},
], ],
wb: [ wb: [
{ {
@@ -129,32 +156,6 @@ export const PARAM_DEFS: {
get: (a) => a.tint ?? 0, get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }), set: (v) => ({ tint: v }),
}, },
{
// The two points live on WB, not on LIGHT: they shift each channel's own
// end of the ramp, which balances a cast at the toe and the shoulder
// rather than adding another tone knob. Same range as the CREATE form's
// rows, so a look round-trips.
key: 'whites',
label: 'WHITE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.whites ?? 0,
set: (v) => ({ whites: v }),
},
{
key: 'blacks',
label: 'BLACK',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.blacks ?? 0,
set: (v) => ({ blacks: v }),
},
], ],
filters: [ filters: [
{ {
+123 -106
View File
@@ -1,51 +1,73 @@
import { BaseFilter, ColorAdjustments } from '../types'; import { BaseFilter, ColorAdjustments } from '../types';
import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). // Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic
// SkSL runtime effect over a child image shader. // Range). SkSL runtime effect over a child image shader.
// //
// Lightness/chroma split: the curve moves the luma and the colour difference // TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the
// (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B // four zones of the tone mapping doc, and no two of them own the same part of
// by one gain keeps the *ratio* but crushes absolute chroma — that is what // the ramp:
// turned saturated blues black under -SH and bright colours grey under -HL.
// //
// Wide, soft knees so the knobs reach like a tone curve instead of biting only // BLACKS peak at 0.00, gone by 0.25
// at the very ends: HL rides the top (0.50..1.00) so it leaves the greys alone // SHADOWS peak at 0.25, gone by 0.50
// (a knee that started lower dragged a mid-grey down) while SH rides the lower // HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00
// half (0.00..0.55), and the 0.50 midpoint never moves. // WHITES peak at 1.00, gone by 0.75
// //
// HL is two different controls with one knob, because recovery and a lift are // The four masks below are those tents — the doc's smoothsteps, one per quarter
// not the same move: // of the ramp — and the 0.50 midpoint is in none of them: it is the one value
// every one of the four leaves where it was.
// //
// -HL is Lightroom's highlight RECOVERY: a soft knee in LINEAR light over the // Colour: the luma takes the move and R, G, B keep their RATIO, which is the
// top half (T = 0.5), pulled down by the ratio of the new luma to the old (see // doc's `R_new = R_old * Luma_new / Luma_old`. One gain on all three channels
// the knee in TONE_SKSL). That is the shape the doc asks for, and the shape the // carries the hue through with the chroma, so a shadow lifted under a warm
// encoded domain cannot give — on the encoded value the last stop of headroom // light does not drift toward white. The `cg` clamp below is the one guard kept
// is a few code values wide. It is the only term here that is not a shift, so // on the ratio: past 1.35 it blows a dark saturated colour to white, and below
// it is also the only one that can put detail back into a blown sky rather than // 0.55 it collapses a colour to black.
// merely darken it. T=0.5 and S = |hl| keep it monotone (the slope leaves the
// knee at 1 and falls, never rises) and it never brightens, so the frame cannot
// invert.
// //
// +HL is a LIFT, weighted by the headroom that is left, (1 - t): the move falls // These four masks are ADDED in the doc's own pseudo-shader, and measured that
// to zero as t reaches pure white, so a lamp or a specular is not turned grey, // way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per
// and it rides into the upper midtones where a lift is wanted. The pull peaks // unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp
// around t = 0.79 at 0.13 of the ramp for a full +10. // should still be climbing. Read here instead as the four ANCHORS of one ramp —
// knots at 0.00, 0.25, 0.50, 0.75 and 1.00, each moved by its own knob, each
// held inside the knot before it, drawn straight in between — the same
// measurement is monotone for every combination of the four at full deflection.
// A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe
// on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range
// slider has in the program this layout copies, without the inversion.
// //
// SH stays an additive shift. Shifts keep the curve monotonic (worst slope // WHITE and BLACK are not the per-channel toe and shoulder they were on the WB
// +0.003 at t = 0.99 with HL +10 and SH -10, and the two knees barely overlap), // tab any more. The doc puts the two points on the ends of the SAME ramp as the
// so a brighter input can never come out darker. The earlier multiplicative // other two, so they are the ends this ramp is drawn through, and nothing else
// form was NOT monotonic: with hl=-1 a grey 0.73 came out darker than 0.80. // in the shader reads them.
// //
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights // Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a
// (Fuji extended DR); 0/auto/DR100 = no extra curve. // smoothstep there is an S-curve through the knots, so it bends the ramp by up
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down. // to six code values in the quarter-tones even with all four knobs on zero — and
// sh - shadow -1..1: + lifts the dark side, - deepens it. // this pass still runs for the stock split tones and for DR alone, where nothing
// wh - white point -1..1: per channel, from the WB tab. + lifts the shoulder, // the user set asked for a contrast move. Straight segments keep a neutral
// - rolls it down. Cubic weight in the channel's own value, so the // setting the exact identity. The smoothsteps are the four ZONE masks above,
// brighter channel of a highlight moves most. // which is where the doc's shape belongs: they weight DR and the split tones,
// bl - black point -1..1: per channel. + lifts the toe (faded black), - // and nothing but their peak positions has to be smooth.
// crushes it; the darker channel of a shadow moves most. //
// The -HL highlight recovery that used to run in LINEAR light ahead of all this
// is gone with it: HIGHLIGHT is one zone move now, in both directions. There is
// still detail at the top to move — the develop's own knee compresses the two
// stops the sensor holds above its white level into the frame (see
// highlight-knee-check.mjs), so -WHITES pulls a plateau down onto 0.75 rather
// than onto a flat 1.0.
//
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji
// extended DR); 0/auto/DR100 = no extra curve. It moves the same four
// knots the knobs move, so DR and a knob cannot fight over the middle and
// DR cannot invert the ramp either — added as its own masked terms on top
// it could, and did: see the fold noted on the knots below.
// hl - highlight -1..1: moves the 0.75 anchor, + up toward white, - down.
// sh - shadow -1..1: moves the 0.25 anchor, + up, - down.
// wh - white point -1..1: moves the 1.00 anchor. + is free to pass 1.0 — that
// is the move that clips a highlight toward white — and - pulls the head
// of the ramp down under it.
// bl - black point -1..1: moves the 0.00 anchor. + lifts the toe off the
// floor (a faded black), - has nothing left to crush at 0.
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader // vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
// (rather than the colour matrix) because it needs per-pixel chroma: // (rather than the colour matrix) because it needs per-pixel chroma:
// already-vivid pixels move least, so skins/skies deepen without the neon // already-vivid pixels move least, so skins/skies deepen without the neon
@@ -80,6 +102,13 @@ const BAND_BLOCK = hslBandGaps()
) )
.join(''); .join('');
// How far a tonal-range knob moves its own knot, in ramp units. A quarter is
// the reach the program this layout copies gives a slider: at full deflection
// the four together can put the toe on the midpoint, the head on it, or either
// end on the quarter next to it — and never past, because each knot is clamped
// inside the one before it.
export const TONE_ANCHOR = 0.25;
export const TONE_SKSL = ` export const TONE_SKSL = `
uniform shader src; uniform shader src;
uniform float dr; uniform float dr;
@@ -147,72 +176,56 @@ float bandW(float hue, float anchor, float gapL, float gapR) {
float d = mod(hue - anchor + 180.0, 360.0) - 180.0; float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR); return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
} }
// The sRGB transfer pair, the accurate one (0.04045/12.92 + 2.4) — the same // Fraction of the way from e0 to e1, clamped — the position on one straight
// constants colorUtils.planckianLinear uses on the WB side and EXPOSURE_SKSL // segment of the tone ramp.
// uses for its own pass. Highlight recovery needs the same space: a knee drawn float lin(float e0, float e1, float x) {
// on the encoded value has the wrong shape (the midtones sit high and the last return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
// stop of headroom is squeezed into a few code values), which is exactly the
// doc's point about working in linear light.
vec3 toLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 toEncoded(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
} }
vec4 main(vec2 xy) { vec4 main(vec2 xy) {
vec4 c = src.eval(xy); vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0); vec3 rgb = clamp(c.rgb, 0.0, 1.0);
// HIGHLIGHT RECOVERY (-HL): the doc's soft knee, and it runs in LINEAR light,
// before the luma and the masks below are read, so every later stage sees the
// recovered value:
// L' = L , L < T
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
// with T = 0.5 and S the knob. The pixel is rebuilt by the ratio L'/L, so
// every channel keeps its share of the light and the hue and the saturation
// cannot drift; the curve leaves T with the slope it arrived with (1), so
// there is no seam at the knee; and the knee never brightens (S = 1 puts the
// white point on 0.75), which is what a recovery slider has to do.
if (hl < 0.0) {
vec3 lin = toLinear(rgb);
float l0 = dot(lin, vec3(0.2126, 0.7152, 0.0722));
if (l0 > 0.5) {
float over = l0 - 0.5;
float S = min(-hl, 1.0);
lin *= (0.5 + over / (1.0 + S * over * 2.0)) / l0;
rgb = clamp(toEncoded(lin), 0.0, 1.0);
}
}
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
float hlMask = smoothstep(0.50, 1.00, t);
float shMask = 1.0 - smoothstep(0.00, 0.55, t);
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier. // NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
// The (1.0 - t) headroom weight is the whole point of the highlight curve: at float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
// t = 1.0 the // The four tents of the doc, one per quarter of the ramp: BLACKS peaks on
// weight is 0, so the pull-back cannot touch a pure white (a sun, a bulb, a // 0.00 and is gone by 0.25, SHADOWS peaks on 0.25 and is gone by 0.50,
// specular) and cannot turn it grey. A LIFT gets the same weight, which rides // HIGHLIGHTS peaks on 0.75 and is gone by 0.50 and 1.00, WHITES peaks on
// it into the upper midtones and leaves the clipping where it was. // 1.00 and is gone by 0.75. Each is the doc's own smoothstep, each is clipped
// The LIFT only, so max(hl, 0): -HL has already been spent in linear light // by subtracting the tent before it so the four never overlap and no luma is
// above, and running it through this additive term as well would double-count // ever counted twice, and the 0.50 midpoint is weighted by none of them: they
// it (and, being an additive shift, would darken the white the knee just // are the weights the stock split tones ride, which is why they are smooth and
// protected). // why they stay out of the ramp below — nothing else in this shader reads them.
float o = t + max(hl, 0.0) * hlMask * (1.0 - t) + sh * 0.34 * shMask; float blMask = 1.0 - smoothstep(0.00, 0.25, t);
// Dynamic range: gentle shadow lift + highlight roll (protect brights). float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0);
o += dr * 0.12 * shMask * (1.0 - t); float whMask = smoothstep(0.75, 1.00, t);
o -= dr * 0.18 * hlMask * t; float hlMask = clamp(smoothstep(0.50, 0.75, t) - whMask, 0.0, 1.0);
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each
// knot is held inside the one before it so the five can never cross. 0.50 is
// fixed: it is the one point all four sliders leave alone, which is what
// keeps a mid-grey a mid-grey while the ends move around it. Straight between
// the knots, so every knob on zero is exactly the identity (see the note at
// the head of this file).
// DR moves the same knots instead of adding its own masked terms on top: it
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
// 0.12 and 0.18 at full strength — and half of each at the knots next to
// them, but because it is a knot move the ordering clamp holds it too. Added
// as a separate term it could not: with BLACK and SHADOW both at -1 the ramp
// is flat between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through
// that stretch, which is a fold at 0.238.
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5);
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
float o = mix(a0, a1, lin(0.00, 0.25, t));
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
o = clamp(o, 0.0, 1.0); o = clamp(o, 0.0, 1.0);
// Lightness takes the curve, hue stays: the colour difference is gained // Lightness takes the curve, hue stays: the colour difference is gained
// only part-way so darkening cannot collapse a colour to black and lifting // only part-way so darkening cannot collapse a colour to black and lifting
// cannot blow a dark saturated colour out to white. // cannot blow a dark saturated colour out to white.
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35); float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0); rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0);
// WHITE/BLACK points, per channel. The weight is cubic in the channel's own
// distance from the end, so in the shadows the darker channels move most and
// in the highlights the brighter ones do: the two points pull R, G and B
// toward a common toe and shoulder, which is a white-balance move (it
// neutralises a cast) and not another tone slider. Each channel's curve is
// still monotonic — 1 - 3*0.18 = 0.46 at worst — so no value can invert.
vec3 dk = 1.0 - rgb;
rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
// Split tone (stock look): the shadows and the highlights may each carry // Split tone (stock look): the shadows and the highlights may each carry
// their own tint, so the two ends of the curve can drift opposite ways // their own tint, so the two ends of the curve can drift opposite ways
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
@@ -514,8 +527,8 @@ export interface ToneUniforms {
dr: number; // 0..1 dr: number; // 0..1
hl: number; // -1..1 (adjustments.highlight / 10) hl: number; // -1..1 (adjustments.highlight / 10)
sh: number; // -1..1 (adjustments.shadow / 10) sh: number; // -1..1 (adjustments.shadow / 10)
wh: number; // -1..1 (adjustments.whites / 10 — WB white point, per channel) wh: number; // -1..1 (adjustments.whites / 10 — moves the 1.00 end of the ramp)
bl: number; // -1..1 (adjustments.blacks / 10 — WB black point, per channel) bl: number; // -1..1 (adjustments.blacks / 10 — moves the 0.00 end of the ramp)
vib: number; // -1..1 (adjustments.vibrance / 10) vib: number; // -1..1 (adjustments.vibrance / 10)
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1 shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1 hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
@@ -545,19 +558,20 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
// the two ENDS and leaves the middle an identity: a smooth shadow toe that // the two ENDS and leaves the middle an identity: a smooth shadow toe that
// reaches a true black (no film-base lift, no flat grey wash) and a highlight // reaches a true black (no film-base lift, no flat grey wash) and a highlight
// shoulder that stops just short of white instead of clipping a cloud to // shoulder that stops just short of white instead of clipping a cloud to
// paper. Mid-tones see neither mask, so the ramp keeps every step the matrix // paper. Mid-tones are between the 0.25 and the 0.75 knots, so they keep
// handed over — which is what 'deep black' costs in a colour stock and does // every step the matrix handed over — which is what 'deep black' costs in a
// not have to cost here. // colour stock and does not have to cost here.
// Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at // The values move the two end knots of the ramp: -0.12 puts the toe on 0.22
// ~5% and -0.05 trims the top ~1%. // and -0.05 rolls the head to 0.7375 (each is TONE_ANCHOR = 0.25 per unit).
monochrome: { sh: -0.12, hl: -0.05 }, monochrome: { sh: -0.12, hl: -0.05 },
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
// (-0.32 against Acros' -0.12) so the darks reach true black, and a shoulder // (-0.32 against Acros' -0.12, so 0.17 against 0.22) so the darks reach true
// that LIFTS instead of rolling (-0.05 → +0.26), which is the whites step of // black, and a shoulder that LIFTS instead of rolling (-0.05 → +0.26, the
// the brief. Midtones see neither mask, so the long smooth stretch between // head going to 0.815), which is the whites step of the brief. The stretch
// the two ends survives — that is what keeps a hard push off the posterised // between the two inner knots (0.25 and 0.75) is still the identity, so the
// look, and the strength the stock needs on the greys is its matrix slope // long smooth stretch of the greys survives — that is what keeps a hard push
// (SIM_CONTRAST_BIAS in colorUtils), not another mask here. // off the posterised look, and the strength the stock needs on the greys is
// its matrix slope (SIM_CONTRAST_BIAS in colorUtils), not another move here.
'mono-high-contrast': { sh: -0.32, hl: 0.26 }, 'mono-high-contrast': { sh: -0.32, hl: 0.26 },
}; };
@@ -569,6 +583,9 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10)); const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10)); const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10)); const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
// WHITE and BLACK ride this pass with the other two, each as the end knot of
// the same ramp (see TONE_SKSL). They are no longer a white-balance move and
// are read by nothing else in the pipeline.
const film = (baseFilter && FILM_TONE[baseFilter]) || {}; const film = (baseFilter && FILM_TONE[baseFilter]) || {};
const shT: [number, number, number] = film.shT ?? [0, 0, 0]; const shT: [number, number, number] = film.shT ?? [0, 0, 0];
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0]; const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
+66 -10
View File
@@ -50,6 +50,7 @@ import { HEAL_DEFAULT_R } from '../shared/utils/heal';
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic'; import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask'; import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
import { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram'; import { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram';
import { DevelopPanels } from './ui/DevelopPanels';
import type { MsgKey } from './i18n/vi'; import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -2755,16 +2756,15 @@ export function Workspace() {
return rows; return rows;
} }
case 'light': case 'light':
// LIGHT is the one tab whose knobs are not chips: the sidebar
// (DevelopPanels) puts every one of them on screen at once, so only the
// two things that are NOT knobs stay here — AUTO, which is an action
// (see autoTune), and TONE CURVE, which opens the graph on the photo
// (ToneCurvePanel) rather than a ruler in the last column. It glows amber
// once the graph is off the diagonal, which is the only place the curve
// is reported.
return [ return [
// AUTO rides at the head of the strip because it is the one chip here
// that is an ACTION rather than a knob or a look (see autoTune).
{ key: 'auto', label: 'AUTO', onClick: () => void autoTune() }, { key: 'auto', label: 'AUTO', onClick: () => void autoTune() },
...paramChips(PARAM_DEFS.iq),
groupChip('dr'),
// TONE CURVE is not a row of sliders: it opens the graph on the photo
// (ToneCurvePanel), so the chip toggles that overlay rather than a
// ruler in the last column. It glows amber once the graph is off the
// diagonal, which is the only place the curve is reported.
{ {
key: 'curve', key: 'curve',
label: 'TONE CURVE', label: 'TONE CURVE',
@@ -3032,6 +3032,41 @@ export function Workspace() {
})), })),
]; ];
// The studio sidebar's three slots (DevelopPanels): the picks that name a
// whole look rather than a number, so they stay strips of their own inside the
// panel that owns them — SIM and D.RANGE with the tone controls, the WB
// presets with the two WB tracks, the two Color Chromes at the foot of the
// effects column. Every one of them is the strip that already existed, so a
// pick made here is the pick made on the tab it came from.
const developSlots = {
profile: (
<>
<ChipRow chips={choiceChips('sim', groupDefs.style.options, groupDefs.style.value, groupDefs.style.onPick, simPreview)} />
<ChipRow
chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)}
/>
</>
),
// COLOR TEMP is left out: the ruler is the panel's own TEMPERATURE row, so a
// chip that opened the same ruler would be a second way to the same knob.
wb: (
<ChipRow
chips={choiceChips('temp', WB_PRESETS.map((p) => ({ v: p.key, d: p.label })), wbValue(), (v) => {
remember();
setWbChoice(v);
const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0];
setAdjustment({ temperature: p.kelvin, tint: p.tint });
})}
/>
),
effects: (
<>
<ChipRow chips={choiceChips('cx', CHROME_OPTIONS, recipe.adjustments.colorChrome || 'none', groupDefs.cx.onPick)} />
<ChipRow chips={choiceChips('cxb', CHROME_OPTIONS, recipe.adjustments.colorChromeBlue || 'none', groupDefs.cxb.onPick)} />
</>
),
};
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the // The panel is a cascade of columns (see styles/app.css): the tab's chips, the
// open chip's own panel, the open group's options, the open ruler. Each level // open chip's own panel, the open group's options, the open ruler. Each level
// is a column of its own, so a child never hides the column it came from. // is a column of its own, so a child never hides the column it came from.
@@ -3115,7 +3150,12 @@ export function Workspace() {
<div className="chips"> <div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */} {/* column 1 — the tab's own chips, RESET ruled off at the foot */}
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main"> <div
className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}${
tab === 'light' ? ' col-dev' : ''
}`}
data-col="main"
>
{tab === 'create' ? ( {tab === 'create' ? (
<RecipeCreatePanel <RecipeCreatePanel
currentBase={recipe.baseFilter} currentBase={recipe.baseFilter}
@@ -3172,7 +3212,23 @@ export function Workspace() {
)} )}
</> </>
) : ( ) : (
<ChipRow chips={chips} /> <>
{/* LIGHT's column is the sidebar itself: the two chips that are
not knobs, then every knob of the tab in one stack, all of
them open at once. */}
{tab === 'light' ? <ChipRow chips={chips} /> : null}
{tab === 'light' ? (
<DevelopPanels
adj={recipe.adjustments}
set={setAdjustmentOnce}
pro={pro}
onPro={promptPro}
slots={developSlots}
/>
) : (
<ChipRow chips={chips} />
)}
</>
)} )}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null} {tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{/* The mixer's readout: the colour the eyedropper last read, as the {/* The mixer's readout: the colour the eyedropper last read, as the
+42
View File
@@ -1274,6 +1274,48 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); } .stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
.stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; } .stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* --- studio sidebar (DevelopPanels) ------------------------------------- */
/* Lightroom's right column: every panel's knobs are on screen at once, each
panel a disclosure that folds its own rows away. Colour comes from the tokens
only, so a theme or accent change lands here with no rules of its own. */
/* The sidebar (DevelopPanels) draws rows, not chips, so its column is the wide
one: the same width CREATE RECIPES takes, and the chips that are not knobs
(AUTO, TONE CURVE) stack over it, wrapped in the column's own .chip-row. */
.col-dev { width: 320px; }
.dev-panels { display: flex; flex-direction: column; gap: 10px; }
.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; }
.dev-panel-head {
display: flex; justify-content: space-between;
width: 100%; padding: 0; border: 0; background: transparent;
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text); cursor: pointer;
}
.dev-panel-head:hover { color: var(--accent); }
.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; }
/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as
a row, not as a button — the tag is what says the row is not a slider. */
.dev-row { display: flex; flex-direction: column; gap: 4px; }
.dev-row.locked {
flex-direction: row; align-items: center; gap: 6px;
width: 100%; padding: 0; border: 0; background: transparent;
text-align: left; opacity: 0.5; cursor: not-allowed;
}
.dev-pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
/* WB's two knobs: the track is the hue the value moves towards, the way
Lightroom's temperature and tint sliders are painted. Everything else keeps
the plain input's accent color. */
.track-temp, .track-tint {
height: 4px; border-radius: 999px;
appearance: none; -webkit-appearance: none;
}
.track-temp { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); }
.track-tint { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); }
.track-temp::-webkit-slider-thumb, .track-tint::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: 12px; height: 12px; border-radius: 50%;
background: var(--text); border: none; cursor: pointer;
}
/* --- responsive --------------------------------------------------------- */ /* --- responsive --------------------------------------------------------- */
@media (max-width: 860px) { @media (max-width: 860px) {
.workspace { flex-direction: column; } .workspace { flex-direction: column; }
+16 -2
View File
@@ -162,6 +162,9 @@ export function MiniSlider({
step = 1, step = 1,
prefix = 'hsl-knob', prefix = 'hsl-knob',
format, format,
defaultValue = 0,
track,
dataKey,
onChange, onChange,
onReset, onReset,
}: { }: {
@@ -172,6 +175,16 @@ export function MiniSlider({
step?: number; step?: number;
prefix?: string; prefix?: string;
format?: (value: number) => string; format?: (value: number) => string;
// The value the readout stays grey at. 0 is the mixer's own default, but the
// studio's WB rows are not the only knobs here: a temperature's default is
// 5500K and a grain size's is 100, so the caller that knows says so.
defaultValue?: number;
// A coloured track for the two WB knobs (CSS track-temp / track-tint), whose
// travel is a hue rather than a number.
track?: 'temp' | 'tint';
// Replaces the `<prefix>-<label>` key for a caller that addresses a row by the
// parameter's own key (the sidebar's dev-<key>) instead of its label.
dataKey?: string;
onChange: (value: number) => void; onChange: (value: number) => void;
onReset: () => void; onReset: () => void;
}) { }) {
@@ -194,10 +207,10 @@ export function MiniSlider({
return () => el.removeEventListener('wheel', onWheel); return () => el.removeEventListener('wheel', onWheel);
}, [min, max, step]); }, [min, max, step]);
return ( return (
<div className="mini-slider" ref={ref} data-key={`${prefix}-${label}`}> <div className="mini-slider" ref={ref} data-key={dataKey ?? `${prefix}-${label}`}>
<div className="mini-head"> <div className="mini-head">
<span>{label}</span> <span>{label}</span>
<b className={value !== 0 ? 'amber' : ''} data-key={`${prefix}-${label}-value`}> <b className={value !== defaultValue ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
{format ? format(value) : value > 0 ? `+${value}` : String(value)} {format ? format(value) : value > 0 ? `+${value}` : String(value)}
</b> </b>
</div> </div>
@@ -208,6 +221,7 @@ export function MiniSlider({
step={step} step={step}
value={value} value={value}
aria-label={label} aria-label={label}
className={track ? `track-${track}` : undefined}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
onDoubleClick={onReset} onDoubleClick={onReset}
/> />
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { ColorAdjustments } from '../../shared/types';
import { PARAM_DEFS } from '../../shared/utils/paramDefs';
import { MiniSlider } from './ChipColumn';
// One lookup for every row. The defs are split over the three tabs' tables and
// the sidebar reads them all at once, so the map is built once here; a key that
// is not in it draws no row rather than a broken one.
const DEF_BY_KEY = new Map(
[...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters].map((def) => [def.key, def])
);
// The stack, top to bottom, and the rows each panel owns. The two WB tracks are
// the only rows whose travel is a colour, so the class each input wears is named
// here with them; anything else is the bare slider.
const TRACK: Record<string, 'temp' | 'tint' | undefined> = { temperature: 'temp', tint: 'tint' };
const PANELS: {
slug: string;
title: string;
rows: string[];
// A slot rides inside its panel, where the parent's own select belongs in the
// reading order — before the panel's knobs except in DETAIL & EFFECTS, whose
// COLOR CHROME selects close the column.
slot?: 'profile' | 'wb' | 'effects';
slotFirst?: boolean;
}[] = [
{ slug: 'profile', title: 'PROFILE', rows: ['ev'], slot: 'profile', slotFirst: true },
{ slug: 'wb', title: 'WB', rows: ['temperature', 'tint'], slot: 'wb', slotFirst: true },
{ slug: 'tone', title: 'TONE', rows: ['exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'] },
{ slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] },
{
slug: 'effects',
title: 'DETAIL & EFFECTS',
rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'],
slot: 'effects',
},
];
function DevRow({
paramKey,
adj,
set,
pro,
onPro,
}: {
paramKey: string;
adj: ColorAdjustments;
set: (patch: Partial<ColorAdjustments>) => void;
pro: boolean;
onPro?: () => void;
}) {
const def = DEF_BY_KEY.get(paramKey);
if (!def) return null;
// A PRO knob on the LITE build is a label with nothing under it: the value
// stays at its default, and the row's own click is handed to the parent's
// upgrade path instead of a slider that would move nothing on the photo.
if (def.pro && !pro) {
return (
<button type="button" className="dev-row locked" data-key={`dev-${paramKey}`} onClick={() => onPro?.()}>
{def.label}
<span className="dev-pro">PRO</span>
</button>
);
}
return (
<MiniSlider
label={def.label}
value={def.get(adj)}
min={def.min}
max={def.max}
step={def.step}
defaultValue={def.defaultValue}
track={TRACK[paramKey]}
format={def.display}
prefix="dev"
dataKey={`dev-${paramKey}`}
onChange={(v) => set(def.set(v))}
onReset={() => set(def.set(def.defaultValue))}
/>
);
}
// The studio's right column: every panel's knobs on screen at once, each panel a
// disclosure that folds its own rows away. One undo step is the parent's `set`,
// so nothing here holds state but which panels are open.
export function DevelopPanels({
adj,
set,
pro = false,
onPro,
slots,
}: {
adj: ColorAdjustments;
set: (patch: Partial<ColorAdjustments>) => void;
pro?: boolean;
onPro?: () => void;
slots?: { profile?: ReactNode; wb?: ReactNode; effects?: ReactNode };
}) {
const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(PANELS.map((panel) => [panel.slug, true]))
);
return (
<div className="dev-panels">
{PANELS.map((panel) => {
const isOpen = open[panel.slug];
const slot = panel.slot ? slots?.[panel.slot] : null;
return (
<section key={panel.slug} className="dev-panel" data-key={`dev-panel-${panel.slug}`}>
<button
type="button"
className="dev-panel-head"
aria-expanded={isOpen}
onClick={() => setOpen((o) => ({ ...o, [panel.slug]: !isOpen }))}
>
{panel.title}
<span aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
</button>
{isOpen ? (
<div className="dev-panel-body">
{panel.slotFirst ? slot : null}
{panel.rows.map((key) => (
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
))}
{panel.slotFirst ? null : slot}
</div>
) : null}
</section>
);
})}
</div>
);
}