Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100

WB and FX were separate tabs whose only content was the same Lightroom-style
develop column LIGHT already renders, so the rail carried three doors into one
room. LIGHT now owns the whole column: white balance, tone, presence, the
effects group (grain, dehaze, vignette) and the filters, in that order. The
`wb` and `fx` TabIds, their rail entries, their `wbLabel()` helper and the now
dead `tab.wb` / `tab.fx` i18n keys are gone; `ToolRail` documents eight tabs.

Every continuous develop value that the UI exposes as a symmetric knob now
runs -100..+100 instead of -10..+10. `paramDefs` gains the `HUNDRED` key set
and the `deepen` helper, which widens a def's range and scales its accessors by
ten, so the store keeps its -10..+10 internal scale and every stored look,
DEFAULT_RECIPES entry and URL round-trip is byte-identical. Params with a
meaningful physical scale (temperature in kelvin, exposure in EV-ish units,
grain, grain size, hdf, vignette, rotate, crop) keep their own units.

Highlight recovery no longer bends hue. The old knee clamped the per-channel
gain into 0.55..1.35, which is a per-channel operation and therefore a hue
rotation: on the flat skin patch it walked hue from 24 deg to 48 deg at
HIGHLIGHT +100, and on a saturated 30:1 chroma ramp it desaturated toward black
instead of toward white. The shader now caps the pixel's distance from its
undersaturated knee point while preserving the direction of that offset, i.e.
it scales chroma and keeps hue, then clamps into gamut.

Verified:
- `npx tsc --noEmit` clean; `npm run build` clean.
- `node scripts/highlight-knee-check.mjs` (pins the new shader source and
  twin-tests 135 knob combinations) ok.
- Existing checks re-run green: `auto-tone-check`, `half-check`,
  `white-level-check`, `preview-match-check`, `library-check`,
  `scan-nav-check`, `roll-walk-check`.
- Live browser pass: rail shows exactly the seven expected tabs with no WB or
  FX; LIGHT renders 5 panels / 23 `data-key` knobs; 12 knobs report
  `min=-100 max=100`; everything else keeps its own range.
- Highlight hue measured on ten flat colour patches: HIGHLIGHT -100 gives a
  hue delta of 0.00 deg on every chromatic patch; HIGHLIGHT +100 stays within
  0.22 deg (sky) and 0.28 deg (magenta) wherever chroma survives, and the
  patches the ramp intentionally drives to white arrive fully neutral. Greys
  stay neutral (channel spread <= 2/255) at every knob setting. Skin patch
  before/after: 23.94 deg -> 0.00 deg.

ponytail: temperature (2500-10000 K), exposure (+-10 units at 0.25 EV each),
grain, grain size, hdf, vignette, rotate and crop deliberately keep their own
scales rather than the blanket -100..+100; widen them the day a user asks for
more range, not before. Hue assertions live in the flat-patch check because
real-photo measurements pick up 2-4 deg of resample drift from the snapshot
pipeline that has nothing to do with the shader.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 17:04:08 +07:00
parent 34f8601c91
commit c0aaa67361
7 changed files with 178 additions and 106 deletions
+20 -6
View File
@@ -22,12 +22,26 @@ export interface ContinuousParamDef {
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
// The bipolar develop knobs read -100..+100: the spec's own numbers (the panel
// draws every TONE and PRESENCE slider from -100 to 100, and the tone-mapping doc
// normalises that /100 on the way to its uniforms). The recipe keeps the ±10 unit
// every look has always been filed in, so the ×10 belongs to the slider and not
// to the store: a look saved, shared or shipped in DEFAULT_RECIPES before this
// still means exactly what it meant. A knob that is not bipolar — a temperature
// in kelvin, an exposure in stops, a grain size in percent, an amount that only
// goes up — keeps its own scale, because -100 of a percentage is nothing.
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites', 'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
const deepen = (def: ContinuousParamDef): ContinuousParamDef =>
HUNDRED.has(def.key)
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
: def;
export const PARAM_DEFS: {
iq: ContinuousParamDef[];
wb: ContinuousParamDef[];
filters: ContinuousParamDef[];
} = {
iq: [
iq: ([
{
key: 'exposure',
label: 'EXPOSURE',
@@ -132,8 +146,8 @@ export const PARAM_DEFS: {
get: (a) => a.blacks ?? 0,
set: (v) => ({ blacks: v }),
},
],
wb: [
] as ContinuousParamDef[]).map(deepen),
wb: ([
{
key: 'temperature',
label: 'COLOR TEMP',
@@ -156,8 +170,8 @@ export const PARAM_DEFS: {
get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }),
},
],
filters: [
] as ContinuousParamDef[]).map(deepen),
filters: ([
{
key: 'denoise',
label: 'NOISE REDUCTION',
@@ -257,5 +271,5 @@ export const PARAM_DEFS: {
get: (a) => a.vignette ?? 0,
set: (v) => ({ vignette: v }),
},
],
] as ContinuousParamDef[]).map(deepen),
};
+30 -11
View File
@@ -17,12 +17,11 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// 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.
//
// 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.
// Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the
// pixel lands on its new luma with the chroma it had, so the hue is untouched
// and a grey stays grey. The doc reaches the same place with a ratio
// (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and
// moves the hue the moment a channel clips; see the note on `k` below.
//
// 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
@@ -221,11 +220,31 @@ vec4 main(vec2 xy) {
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);
// Lightness takes the curve; the colour rides the difference. The pixel moves
// to its new luma and carries its own chroma with it — the three channel
// differences are scaled by ONE number, so the hue cannot move and a grey
// cannot pick up a cast (a neutral has no difference to carry, and lands on
// o exactly).
//
// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading
// of the same sentence, and it is what this pass used to do. It is exact —
// until the result stops fitting. Past 1.0 a channel clips, the differences
// stop being scaled together, and the hue goes with them: measured on the
// scratchpad probe (hl-variants.mjs), a skin tone at 24.0° came back at 48.0°
// at HIGHLIGHT +100, and a warm white at 37° at 57.4°. Under L = 0.5 the same
// ratio also multiplies whatever cast a near-black pixel had — x30 on a
// shadow with a hair of warmth, which is colour noise amplified, the reason
// the old arbitrary 0.55..1.35 clamp was there.
//
// So the scale is the chroma's own (1.0) and the only thing that pulls it back
// is the cube: a pixel with no room left gives up saturation instead of hue,
// and one that the curve has actually driven to 1.0 arrives at white.
float k = 1.0;
float hiC = max(max(rgb.r, rgb.g), rgb.b);
float loC = min(min(rgb.r, rgb.g), rgb.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 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.