web: make EXPOSURE, EV and HIGHLIGHT mean what Lightroom means

A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB
colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL:
linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded
values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral
PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239.

The pass sits between the matrix and the tone shader, and the tone / cinema /
curve / glow / halation children all sample through it, so HIGHLIGHT finally
sees the value exposure produced instead of the one before it. Recovery keeps
`L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the
colour comes back as `color * (luma_new / luma)`: a blown white stays white
(255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside
it do not move.

AUTO is the histogram the LIGHT tab already draws: weighted mean luminance
(guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to
the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the
83.3 predicted, idempotent on a second press. A stock's own bias rides the same
pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so
-1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177).

ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the
two copies have to move together; recipes saved before this commit (EXPOSURE 2,
HIGHLIGHT +-1) render under the new stop semantics.

Verified on the rebuilt container (BASE=http://localhost:8090):
- web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio
  2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO
  EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console
  errors)
- sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1
  returning 0.4177, bias 0.0382)
- regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0,
  heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch
  33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0,
  save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a
  size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and
  progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing.
- npx tsc --noEmit clean.
This commit is contained in:
2026-09-25 17:44:28 +07:00
parent d2115941c7
commit 7e47a153b8
6 changed files with 186 additions and 69 deletions
+26
View File
@@ -54,6 +54,32 @@ export async function readHistogram(url: string, alive: () => boolean): Promise<
return { lum, r, g, b };
}
// AUTO's reach, in stops — the same travel the EXPOSURE knob has (±10 units at
// 0.25 EV, see colorUtils.EV_PER_UNIT), so the knob can always express what the
// button decided instead of the number being clipped by the UI.
export const AUTO_EV_MAX = 2.5;
// Lightroom's Auto, as far as a browser can take it: the average luminance off
// the binned histogram, asked for the number of stops that puts it on the 0.48 a
// grey card lands at in a normal exposure. log2 of the ratio IS that number of
// stops, and the engine's EXPOSURE_SKSL takes exactly such an EV value — so this
// hands back stops, not a pixel gain.
//
// `lum` is readHistogram's 256 bins, so bin i sits at i/255 of the ramp. The
// guard keeps a black frame from dividing by zero (and from asking for infinity);
// the black frame then gets the full +2.5.
export function autoExposureStops(lum: number[]): number {
let sum = 0;
let weighted = 0;
for (let i = 0; i < lum.length; i++) {
sum += lum[i];
weighted += (i / (lum.length - 1)) * lum[i];
}
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
const stops = Math.log2(0.48 / avg);
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
}
// One channel across the full width of the ramp. `close` also draws the floor,
// which is only wanted for the filled luminance curve.
function curve(bins: number[], max: number, close: boolean): string {