bd57dd72b3
The tone spec draws EXPOSURE as `min="-5" max="5" step="0.01"` with a `0.00` readout, and the app's row was still the original whole-unit knob: -10..+10 in one step, printed as a bare `+2`. The recipe file behind it has always carried units where one unit is EV_PER_UNIT = 0.25 of a stop, which is the same ±2.5 EV travel, so the two are the same range said two ways. The def now talks stops on its face and units in the store: `min: -5, max: 5, step: 0.01`, a `twoStops` readout (`0.00`, `+0.50`, `-0.25`, no unit because the label is the unit), and accessors that translate — `get` multiplies by EV_PER_UNIT, `set` divides and rounds to 1/10000. EV_PER_UNIT is exported for that one use. Because the ×10 of `deepen` is not applied here and the store's field is untouched, a recipe that shipped with EXPOSURE 2 still means the two units it always meant: nothing gets brighter or darker, and half a stop is still 2 units in the file. The knob steps by 0.01, which is 0.04 units — the slider can express a quarter stop where the old one could only express quarter stops, and everything between them. TINT and TEMPERATURE keep the web's ranges (tint ±100 on the ±10 store scale, temperature 2500..10000 K). The spec's ±150 tint and its 2000..50000 K temperature were reviewed and left as they are: the tint at ±100 is already what `deepen` publishes and the wider pair would move stored looks. Verified: - `npx tsc --noEmit` clean; `npm run build` clean. - Repo checks re-run green: `highlight-knee-check`, `auto-tone-check`, `half-check`, `white-level-check`, `preview-match-check` — the last two render real looks through the engine, so a shifted EXPOSURE scale would have shown up as a brightness change in recipes that carry a non-zero one. - Live browser pass on the deployed build: the row reports `min=-5 max=5 step=0.01`, prints `+1.00` at a full stop, `+0.01` at a hundredth, `-0.25` at a quarter stop down and `0.00` at rest, and EXPOSURE +1.00 still lifts the graded frame (mean luma 184.3 -> 203.4). ponytail: the CREATE RECIPES form (RecipeCreatePanel) still edits a sim's raw adjustments in store units, EXPOSURE included. Leave it there until that form is asked for stops: it is a store editor, not the develop panel, and every field on it is in the same units. Co-authored-by: PenguinHarness <noreply@penguin.local>
287 lines
8.2 KiB
TypeScript
287 lines
8.2 KiB
TypeScript
import { ColorAdjustments } from '../types';
|
||
import { EV_PER_UNIT } from './colorUtils';
|
||
|
||
// 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 spec reads EXPOSURE to two decimals, so "+0.50" and "-0.25" and a plain
|
||
// "0.00" — the EV unit is already on the row's own label, not in its readout.
|
||
const twoStops = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||
|
||
// 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 amount that only goes up — keeps its own scale, because -100 of
|
||
// a percentage is nothing. EXPOSURE is the odd one out: its slider is in stops
|
||
// (-5..+5, two decimals, the spec's own numbers) while the store still holds the
|
||
// ±10 units EV_PER_UNIT converts, so its accessors translate instead of scaling
|
||
// and every recipe's brightness is untouched.
|
||
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: -5,
|
||
max: 5,
|
||
step: 0.01,
|
||
defaultValue: 0,
|
||
display: twoStops,
|
||
// Stops out, units in: the store has carried ±10 units since the first
|
||
// recipe, so the knob translates rather than replacing the scale. The
|
||
// /4 lands on a whole 0.04 for every step the slider can emit, which keeps
|
||
// the stored number readable in a recipe file.
|
||
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
|
||
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }),
|
||
},
|
||
{
|
||
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),
|
||
};
|