diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index e07fe3c..308bda9 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -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'); diff --git a/docker/frontend/shared/utils/paramDefs.ts b/docker/frontend/shared/utils/paramDefs.ts index c076600..140d5c8 100644 --- a/docker/frontend/shared/utils/paramDefs.ts +++ b/docker/frontend/shared/utils/paramDefs.ts @@ -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: [ { diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 067a573..4ed9c4c 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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>> = { // 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]; diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index c809cf3..0029d6a 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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: ( + <> + + + + ), + // 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: ( + ({ 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: ( + <> + + + + ), + }; + // 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() {
{/* column 1 — the tab's own chips, RESET ruled off at the foot */} -
+
{tab === 'create' ? ( ) : ( - + <> + {/* 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' ? : null} + {tab === 'light' ? ( + + ) : ( + + )} + )} {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} {/* The mixer's readout: the colour the eyedropper last read, as the diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 9fe92de..dd6869a 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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; } diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 100fa3a..aae8c9e 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -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 `-