EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store

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>
This commit is contained in:
2026-09-29 17:09:01 +07:00
parent c0aaa67361
commit bd57dd72b3
2 changed files with 20 additions and 9 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = {
// button decides. Raise it toward 0.5 for Lightroom's full travel, and accept
// that every recipe already saved with a non-zero EXPOSURE gets brighter with
// it.
const EV_PER_UNIT = 0.25;
export const EV_PER_UNIT = 0.25;
// The EXPOSURE knob and the sim bias, in stops, for the linear-light pass in
// exportEngine. Separate from the colour matrix on purpose: the matrix works on
+19 -8
View File
@@ -1,4 +1,5 @@
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
@@ -21,6 +22,9 @@ export interface ContinuousParamDef {
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
@@ -28,8 +32,11 @@ const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
// 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.
// 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)
@@ -45,13 +52,17 @@ export const PARAM_DEFS: {
{
key: 'exposure',
label: 'EXPOSURE',
min: -10,
max: 10,
step: 1,
min: -5,
max: 5,
step: 0.01,
defaultValue: 0,
display: sign,
get: (a) => a.exposure,
set: (v) => ({ exposure: v }),
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',