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:
@@ -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' = 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)),
|
||||
// which puts the asymptote on 1.0) so the two stops the sensor holds above its
|
||||
// white level are COMPRESSED into the frame instead of being thrown away by the
|
||||
// old fade-to-white; the tone pass draws the same curve in linear light on the
|
||||
// value the develop and the camera match left, where -HL is the knob (T = 0.5,
|
||||
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all
|
||||
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW,
|
||||
// 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.
|
||||
// drawn on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), which puts the
|
||||
// asymptote on 1.0) so the two stops the sensor holds above its white level are
|
||||
// COMPRESSED into the frame instead of being thrown away by the old
|
||||
// fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
|
||||
// top to move. Measured on DSC03453.ARW, 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
|
||||
// checked as arithmetic, with the constants the source is asserted to carry.
|
||||
// THE TONE PASS builds the luma a new ramp instead. The four knobs
|
||||
// (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
|
||||
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.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
|
||||
// lift keeps its headroom weight — and the recovery must not also ride the
|
||||
// additive term, which would darken the white the knee protects.
|
||||
assert.match(tone, /if \(hl < 0\.0\) \{/);
|
||||
assert.match(tone, /vec3 lin = toLinear\(rgb\);/);
|
||||
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
|
||||
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/);
|
||||
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/);
|
||||
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/);
|
||||
// The transfer pair has to be the accurate one, or the knee is drawn in a space
|
||||
// that is not linear at all.
|
||||
// The tone pass, read as the string it actually emits: TONE_ANCHOR is
|
||||
// interpolated, so the template has to be resolved before it can be matched.
|
||||
const anchorSrc = tone.match(/export const TONE_ANCHOR = ([0-9.]+);/)?.[1];
|
||||
assert.ok(anchorSrc, 'TONE_ANCHOR is gone — the four knots no longer share a reach');
|
||||
const A = Number(anchorSrc);
|
||||
assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter of the ramp');
|
||||
const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(tmpl, 'TONE_SKSL is gone');
|
||||
assert.equal((tmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
|
||||
const sksl = tmpl.replaceAll('${TONE_ANCHOR}', String(A));
|
||||
|
||||
// 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\)\);/);
|
||||
|
||||
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what
|
||||
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of
|
||||
// the tone pass's knob.
|
||||
// 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).
|
||||
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 [
|
||||
[0.7, 1 / (2 * (1 - 0.7))],
|
||||
[0.5, 0.25],
|
||||
[0.5, 1],
|
||||
]) {
|
||||
// Below the knee the frame is untouched, and the curve is continuous and C1 at
|
||||
// T — slope 1 on both sides — so there is no seam for a later pass to mask.
|
||||
assert.equal(knee(T - 0.2, T, S), T - 0.2);
|
||||
assert.equal(knee(T, T, S), T);
|
||||
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
|
||||
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
|
||||
// Monotone, and never a brightening: a recovery slider that lifted a highlight
|
||||
// would be a lift in disguise, and an inverted pair of pixels is a visible edge.
|
||||
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);
|
||||
// Below the knee the frame is untouched, and the curve is continuous and C1 at T
|
||||
// — slope 1 on both sides — so there is no seam for a later pass to mask.
|
||||
assert.equal(knee(T - 0.2, T, S), T - 0.2);
|
||||
assert.equal(knee(T, T, S), T);
|
||||
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
|
||||
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
|
||||
// Monotone, and never a brightening: an inverted pair of pixels is a visible edge.
|
||||
let prev = -Infinity;
|
||||
for (let l = 0; l <= 2; l += 1 / 512) {
|
||||
assert.ok(slope(l) > 0, `inverted at ${l}`);
|
||||
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);
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// old fade-to-white left nothing above 250 at all.
|
||||
// (1.93) on 248 — a ramp of a dozen code values where the old fade-to-white left
|
||||
// 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 DEV_S = 1 / (2 * (1 - 0.7));
|
||||
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
|
||||
assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, T, 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');
|
||||
|
||||
@@ -105,6 +105,33 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.shadow ?? 0,
|
||||
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: [
|
||||
{
|
||||
@@ -129,32 +156,6 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.tint ?? 0,
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -1,51 +1,73 @@
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader.
|
||||
// Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic
|
||||
// Range). SkSL runtime effect over a child image shader.
|
||||
//
|
||||
// Lightness/chroma split: the curve moves the luma and the colour difference
|
||||
// (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B
|
||||
// by one gain keeps the *ratio* but crushes absolute chroma — that is what
|
||||
// turned saturated blues black under -SH and bright colours grey under -HL.
|
||||
// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the
|
||||
// four zones of the tone mapping doc, and no two of them own the same part of
|
||||
// the ramp:
|
||||
//
|
||||
// Wide, soft knees so the knobs reach like a tone curve instead of biting only
|
||||
// at the very ends: HL rides the top (0.50..1.00) so it leaves the greys alone
|
||||
// (a knee that started lower dragged a mid-grey down) while SH rides the lower
|
||||
// half (0.00..0.55), and the 0.50 midpoint never moves.
|
||||
// BLACKS peak at 0.00, gone by 0.25
|
||||
// SHADOWS peak at 0.25, gone by 0.50
|
||||
// HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00
|
||||
// WHITES peak at 1.00, gone by 0.75
|
||||
//
|
||||
// HL is two different controls with one knob, because recovery and a lift are
|
||||
// not the same move:
|
||||
// The four masks below are those tents — the doc's smoothsteps, one per quarter
|
||||
// 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
|
||||
// top half (T = 0.5), pulled down by the ratio of the new luma to the old (see
|
||||
// the knee in TONE_SKSL). That is the shape the doc asks for, and the shape the
|
||||
// encoded domain cannot give — on the encoded value the last stop of headroom
|
||||
// is a few code values wide. It is the only term here that is not a shift, so
|
||||
// it is also the only one that can put detail back into a blown sky rather than
|
||||
// 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.
|
||||
// Colour: the luma takes the move and R, G, B keep their RATIO, which is the
|
||||
// doc's `R_new = R_old * Luma_new / Luma_old`. One gain on all three channels
|
||||
// carries the hue through with the chroma, so a shadow lifted under a warm
|
||||
// light does not drift toward white. The `cg` clamp below is the one guard kept
|
||||
// on the ratio: past 1.35 it blows a dark saturated colour to white, and below
|
||||
// 0.55 it collapses a colour to black.
|
||||
//
|
||||
// +HL is a LIFT, weighted by the headroom that is left, (1 - t): the move falls
|
||||
// to zero as t reaches pure white, so a lamp or a specular is not turned grey,
|
||||
// and it rides into the upper midtones where a lift is wanted. The pull peaks
|
||||
// around t = 0.79 at 0.13 of the ramp for a full +10.
|
||||
// These four masks are ADDED in the doc's own pseudo-shader, and measured that
|
||||
// way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per
|
||||
// unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp
|
||||
// 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
|
||||
// +0.003 at t = 0.99 with HL +10 and SH -10, and the two knees barely overlap),
|
||||
// so a brighter input can never come out darker. The earlier multiplicative
|
||||
// form was NOT monotonic: with hl=-1 a grey 0.73 came out darker than 0.80.
|
||||
// WHITE and BLACK are not the per-channel toe and shoulder they were on the WB
|
||||
// tab any more. The doc puts the two points on the ends of the SAME ramp as the
|
||||
// other two, so they are the ends this ramp is drawn through, and nothing else
|
||||
// in the shader reads them.
|
||||
//
|
||||
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights
|
||||
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
|
||||
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down.
|
||||
// sh - shadow -1..1: + lifts the dark side, - deepens it.
|
||||
// wh - white point -1..1: per channel, from the WB tab. + lifts the shoulder,
|
||||
// - rolls it down. Cubic weight in the channel's own value, so the
|
||||
// brighter channel of a highlight moves most.
|
||||
// bl - black point -1..1: per channel. + lifts the toe (faded black), -
|
||||
// crushes it; the darker channel of a shadow moves most.
|
||||
// Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a
|
||||
// smoothstep there is an S-curve through the knots, so it bends the ramp by up
|
||||
// to six code values in the quarter-tones even with all four knobs on zero — and
|
||||
// this pass still runs for the stock split tones and for DR alone, where nothing
|
||||
// the user set asked for a contrast move. Straight segments keep a neutral
|
||||
// setting the exact identity. The smoothsteps are the four ZONE masks above,
|
||||
// which is where the doc's shape belongs: they weight DR and the split tones,
|
||||
// and nothing but their peak positions has to be smooth.
|
||||
//
|
||||
// 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
|
||||
// (rather than the colour matrix) because it needs per-pixel chroma:
|
||||
// already-vivid pixels move least, so skins/skies deepen without the neon
|
||||
@@ -80,6 +102,13 @@ const BAND_BLOCK = hslBandGaps()
|
||||
)
|
||||
.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 = `
|
||||
uniform shader src;
|
||||
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;
|
||||
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
|
||||
// constants colorUtils.planckianLinear uses on the WB side and EXPOSURE_SKSL
|
||||
// uses for its own pass. Highlight recovery needs the same space: a knee drawn
|
||||
// on the encoded value has the wrong shape (the midtones sit high and the last
|
||||
// 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));
|
||||
// Fraction of the way from e0 to e1, clamped — the position on one straight
|
||||
// segment of the tone ramp.
|
||||
float lin(float e0, float e1, float x) {
|
||||
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
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.
|
||||
// The (1.0 - t) headroom weight is the whole point of the highlight curve: at
|
||||
// t = 1.0 the
|
||||
// weight is 0, so the pull-back cannot touch a pure white (a sun, a bulb, a
|
||||
// specular) and cannot turn it grey. A LIFT gets the same weight, which rides
|
||||
// it into the upper midtones and leaves the clipping where it was.
|
||||
// The LIFT only, so max(hl, 0): -HL has already been spent in linear light
|
||||
// above, and running it through this additive term as well would double-count
|
||||
// it (and, being an additive shift, would darken the white the knee just
|
||||
// protected).
|
||||
float o = t + max(hl, 0.0) * hlMask * (1.0 - t) + sh * 0.34 * shMask;
|
||||
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
|
||||
o += dr * 0.12 * shMask * (1.0 - t);
|
||||
o -= dr * 0.18 * hlMask * t;
|
||||
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
// The four tents of the doc, one per quarter of the ramp: BLACKS peaks on
|
||||
// 0.00 and is gone by 0.25, SHADOWS peaks on 0.25 and is gone by 0.50,
|
||||
// HIGHLIGHTS peaks on 0.75 and is gone by 0.50 and 1.00, WHITES peaks on
|
||||
// 1.00 and is gone by 0.75. Each is the doc's own smoothstep, each is clipped
|
||||
// by subtracting the tent before it so the four never overlap and no luma is
|
||||
// ever counted twice, and the 0.50 midpoint is weighted by none of them: they
|
||||
// are the weights the stock split tones ride, which is why they are smooth and
|
||||
// why they stay out of the ramp below — nothing else in this shader reads them.
|
||||
float blMask = 1.0 - smoothstep(0.00, 0.25, t);
|
||||
float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0);
|
||||
float whMask = smoothstep(0.75, 1.00, 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);
|
||||
// Lightness takes the curve, hue stays: the colour difference is gained
|
||||
// only part-way so darkening cannot collapse a colour to black and lifting
|
||||
// cannot blow a dark saturated colour out to white.
|
||||
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
|
||||
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
|
||||
// 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.
|
||||
@@ -514,8 +527,8 @@ export interface ToneUniforms {
|
||||
dr: number; // 0..1
|
||||
hl: number; // -1..1 (adjustments.highlight / 10)
|
||||
sh: number; // -1..1 (adjustments.shadow / 10)
|
||||
wh: number; // -1..1 (adjustments.whites / 10 — WB white point, per channel)
|
||||
bl: number; // -1..1 (adjustments.blacks / 10 — WB black 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 — moves the 0.00 end of the ramp)
|
||||
vib: number; // -1..1 (adjustments.vibrance / 10)
|
||||
shT: [number, number, number]; // shadow 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
|
||||
// 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
|
||||
// paper. Mid-tones see neither mask, so the ramp keeps every step the matrix
|
||||
// handed over — which is what 'deep black' costs in a 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
|
||||
// ~5% and -0.05 trims the top ~1%.
|
||||
// paper. Mid-tones are between the 0.25 and the 0.75 knots, so they keep
|
||||
// every step the matrix handed over — which is what 'deep black' costs in a
|
||||
// colour stock and does not have to cost here.
|
||||
// The values move the two end knots of the ramp: -0.12 puts the toe on 0.22
|
||||
// and -0.05 rolls the head to 0.7375 (each is TONE_ANCHOR = 0.25 per unit).
|
||||
monochrome: { sh: -0.12, hl: -0.05 },
|
||||
// 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
|
||||
// that LIFTS instead of rolling (-0.05 → +0.26), which is the whites step of
|
||||
// the brief. Midtones see neither mask, so the long smooth stretch between
|
||||
// the two ends survives — that is what keeps a hard push off the posterised
|
||||
// look, and the strength the stock needs on the greys is its matrix slope
|
||||
// (SIM_CONTRAST_BIAS in colorUtils), not another mask here.
|
||||
// (-0.32 against Acros' -0.12, so 0.17 against 0.22) so the darks reach true
|
||||
// black, and a shoulder that LIFTS instead of rolling (-0.05 → +0.26, the
|
||||
// head going to 0.815), which is the whites step of the brief. The stretch
|
||||
// between the two inner knots (0.25 and 0.75) is still the identity, so the
|
||||
// long smooth stretch of the greys survives — that is what keeps a hard push
|
||||
// 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 },
|
||||
};
|
||||
|
||||
@@ -569,6 +583,9 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
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 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 shT: [number, number, number] = film.shT ?? [0, 0, 0];
|
||||
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
|
||||
|
||||
+66
-10
@@ -50,6 +50,7 @@ import { HEAL_DEFAULT_R } from '../shared/utils/heal';
|
||||
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';
|
||||
import { DevelopPanels } from './ui/DevelopPanels';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
|
||||
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
|
||||
@@ -2755,16 +2756,15 @@ export function Workspace() {
|
||||
return rows;
|
||||
}
|
||||
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 [
|
||||
// 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() },
|
||||
...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',
|
||||
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
|
||||
// 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.
|
||||
@@ -3115,7 +3150,12 @@ export function Workspace() {
|
||||
|
||||
<div className="chips">
|
||||
{/* 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' ? (
|
||||
<RecipeCreatePanel
|
||||
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}
|
||||
{/* The mixer's readout: the colour the eyedropper last read, as the
|
||||
|
||||
@@ -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-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 --------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
.workspace { flex-direction: column; }
|
||||
|
||||
@@ -162,6 +162,9 @@ export function MiniSlider({
|
||||
step = 1,
|
||||
prefix = 'hsl-knob',
|
||||
format,
|
||||
defaultValue = 0,
|
||||
track,
|
||||
dataKey,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
@@ -172,6 +175,16 @@ export function MiniSlider({
|
||||
step?: number;
|
||||
prefix?: 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;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
@@ -194,10 +207,10 @@ export function MiniSlider({
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [min, max, step]);
|
||||
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">
|
||||
<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)}
|
||||
</b>
|
||||
</div>
|
||||
@@ -208,6 +221,7 @@ export function MiniSlider({
|
||||
step={step}
|
||||
value={value}
|
||||
aria-label={label}
|
||||
className={track ? `track-${track}` : undefined}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onDoubleClick={onReset}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user