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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user