c0aaa67361
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>
276 lines
7.4 KiB
TypeScript
276 lines
7.4 KiB
TypeScript
import { ColorAdjustments } from '../types';
|
||
|
||
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
|
||
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
|
||
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
|
||
// /set accessors are stateless so one table serves every render.
|
||
export interface ContinuousParamDef {
|
||
key: string;
|
||
label: string;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
defaultValue: number;
|
||
display: (v: number) => string;
|
||
get: (a: ColorAdjustments) => number;
|
||
set: (v: number) => Partial<ColorAdjustments>;
|
||
// PRO-only knob: the LITE build greys the chip out (PRO corner tag) and
|
||
// cannot open its row, so the value stays at its default there.
|
||
pro?: boolean;
|
||
}
|
||
|
||
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: ([
|
||
{
|
||
key: 'exposure',
|
||
label: 'EXPOSURE',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.exposure,
|
||
set: (v) => ({ exposure: v }),
|
||
},
|
||
{
|
||
key: 'ev',
|
||
label: 'EV',
|
||
min: -3,
|
||
max: 3,
|
||
step: 0.5,
|
||
defaultValue: 0,
|
||
display: oneEv,
|
||
get: (a) => a.exposureCompensation ?? 0,
|
||
set: (v) => ({ exposureCompensation: v }),
|
||
},
|
||
{
|
||
key: 'contrast',
|
||
label: 'CONTRAST',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.contrast,
|
||
set: (v) => ({ contrast: v }),
|
||
},
|
||
{
|
||
key: 'color',
|
||
label: 'COLOR',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.saturation,
|
||
set: (v) => ({ saturation: v }),
|
||
},
|
||
{
|
||
key: 'vibrance',
|
||
label: 'VIBRANCE',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.vibrance ?? 0,
|
||
set: (v) => ({ vibrance: v }),
|
||
},
|
||
{
|
||
key: 'highlight',
|
||
label: 'HIGHLIGHT',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.highlight ?? 0,
|
||
set: (v) => ({ highlight: v }),
|
||
},
|
||
{
|
||
key: 'shadow',
|
||
label: 'SHADOW',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
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 }),
|
||
},
|
||
] as ContinuousParamDef[]).map(deepen),
|
||
wb: ([
|
||
{
|
||
key: 'temperature',
|
||
label: 'COLOR TEMP',
|
||
min: 2500,
|
||
max: 10000,
|
||
step: 100,
|
||
defaultValue: 5500,
|
||
display: (v) => `${v}K`,
|
||
get: (a) => a.temperature,
|
||
set: (v) => ({ temperature: v }),
|
||
},
|
||
{
|
||
key: 'tint',
|
||
label: 'TINT',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.tint ?? 0,
|
||
set: (v) => ({ tint: v }),
|
||
},
|
||
] as ContinuousParamDef[]).map(deepen),
|
||
filters: ([
|
||
{
|
||
key: 'denoise',
|
||
label: 'NOISE REDUCTION',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.denoise,
|
||
set: (v) => ({ denoise: v }),
|
||
},
|
||
{
|
||
key: 'clarity',
|
||
label: 'CLARITY',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.clarity,
|
||
set: (v) => ({ clarity: v }),
|
||
},
|
||
{
|
||
// The haze the frame's own pixels carry (dark channel prior, toneShader's
|
||
// DEHAZE_SKSL): positive takes the scattered light out, negative puts it
|
||
// back — the same expression, with the transmission pushed above 1, which
|
||
// is the knob a photo shot through mist wants from this side. Its chip sits
|
||
// with the other spatial knob, CLARITY.
|
||
key: 'dehaze',
|
||
label: 'DEHAZE',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.dehaze ?? 0,
|
||
set: (v) => ({ dehaze: v }),
|
||
},
|
||
{
|
||
key: 'sharpening',
|
||
label: 'SHARPENING',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.sharpening ?? 0,
|
||
set: (v) => ({ sharpening: v }),
|
||
},
|
||
{
|
||
key: 'grain',
|
||
label: 'MONOCHROME GRAIN',
|
||
min: 0,
|
||
max: 10,
|
||
// Half a step: the grain is a look, not a switch, and the AMOUNT chip in
|
||
// the GRAIN strip is what spends the half.
|
||
step: 0.5,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.grain,
|
||
set: (v) => ({ grain: v }),
|
||
},
|
||
{
|
||
// The SIZE knob: percent of the stock's own cell (grainShader.ts), so the
|
||
// same number means the same texture RELATIVE to the picture on both
|
||
// platforms and the readout beside it is the stock's, scaled.
|
||
key: 'grainSize',
|
||
label: 'GRAIN SIZE',
|
||
min: 50,
|
||
max: 200,
|
||
step: 5,
|
||
defaultValue: 100,
|
||
display: (v) => `${v}%`,
|
||
get: (a) => a.grainSize ?? 100,
|
||
set: (v) => ({ grainSize: v }),
|
||
},
|
||
{
|
||
key: 'hdf',
|
||
label: 'HDF EFFECT',
|
||
pro: true,
|
||
min: 0,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.hdf ?? 0,
|
||
set: (v) => ({ hdf: v }),
|
||
},
|
||
{
|
||
key: 'vignette',
|
||
label: 'VIGNETTING',
|
||
min: 0,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.vignette ?? 0,
|
||
set: (v) => ({ vignette: v }),
|
||
},
|
||
] as ContinuousParamDef[]).map(deepen),
|
||
};
|