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; // 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), };