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
+27
View File
@@ -43,6 +43,7 @@ import { curveIsActive } from '../shared/utils/toneCurve';
import { HEAL_DEFAULT_R } from '../shared/utils/heal';
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
import { readHistogram, autoExposureStops } from './ui/Histogram';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -2438,6 +2439,29 @@ export function Workspace() {
// for the mono stock and swaps right back. Adjustments are never touched, so
// a knob moved while the switch is on survives the trip back.
const monoOn = isMonochromeBase(recipe.baseFilter);
// AUTO (LIGHT tab): the one chip that WRITES a value instead of naming one. It
// measures the LOADED PHOTO — not the preview — so the number does not depend
// on the knobs already in play: pressing it twice lands on the same EV, the way
// Lightroom's Auto does. readHistogram gives the average luminance,
// autoExposureStops turns it into stops, and the EV knob takes it, rounded to
// the 0.1 that knob's own readout prints.
const autoExposure = async () => {
if (!previewBytes) return;
const url = URL.createObjectURL(new Blob([previewBytes as BlobPart], { type: 'image/jpeg' }));
try {
const hist = await readHistogram(url, () => true);
if (hist) {
remember();
setAdjustment({ exposureCompensation: Math.round(autoExposureStops(hist.lum) * 10) / 10 });
}
} catch {
// A photo that will not decode leaves the recipe alone — a broken preview
// is not worth an error banner here.
} finally {
URL.revokeObjectURL(url);
}
};
const toggleMono = () => {
remember();
if (monoOn) {
@@ -2509,6 +2533,9 @@ export function Workspace() {
}
case 'light':
return [
// AUTO rides at the head of the strip because it is the one chip here
// that is an ACTION rather than a knob or a look (see autoExposure).
{ key: 'auto', label: 'AUTO', onClick: () => void autoExposure() },
...paramChips(PARAM_DEFS.iq),
groupChip('dr'),
// TONE CURVE is not a row of sliders: it opens the graph on the photo