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:
@@ -164,10 +164,31 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
|
||||
// values are one hop from the rows they lift. Only LC STREETLIFE VIVID carries
|
||||
// one — its matrix is a pure separation with no lift, and without this it reads
|
||||
// a full step darker than the other sims.
|
||||
const SIM_EXPOSURE_BIAS: Partial<Record<BaseFilter, number>> = {
|
||||
'leica-vivid': 2,
|
||||
//
|
||||
// In STOPS (EV), on the same scale as exposureStops below, because that is the
|
||||
// domain the gain is applied in now. The old entry was +2 Exposure units, whose
|
||||
// 1.04x scale + 0.03 offset measured out at roughly this much light.
|
||||
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = {
|
||||
'leica-vivid': 0.25,
|
||||
};
|
||||
|
||||
// What one unit of the EXPOSURE knob is worth, in stops. Lightroom's slider is
|
||||
// ±5 EV; this one is ±10 units, so ±2.5 EV at the ends — the same reach AUTO is
|
||||
// allowed to ask for (AUTO_EV_MAX), so the knob can always express what the
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// sRGB-ENCODED values, where multiplying by 2 is a gamma-space move that lifts a
|
||||
// mid-grey to a blown 1.0 instead of the 0.73 a real stop of light gives.
|
||||
export function exposureStops(adj: ColorAdjustments, baseFilter?: BaseFilter): number {
|
||||
const units = adj.exposure ?? 0;
|
||||
return units * EV_PER_UNIT + (SIM_EXPOSURE_BIAS_EV[(baseFilter ?? 'none') as BaseFilter] ?? 0);
|
||||
}
|
||||
|
||||
// A second brightness a stock owns, in Contrast-slider units. The knob itself
|
||||
// stays at 0 (a sim is colour only), so the extra slope is added here, where
|
||||
// the knob's own gain/offset mapping already lives.
|
||||
@@ -185,18 +206,13 @@ export const isMonochromeBase = (baseFilter: BaseFilter | undefined): boolean =>
|
||||
!!baseFilter && MONO_BASES.includes(baseFilter);
|
||||
|
||||
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
|
||||
// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the
|
||||
// exposure-compensation EV gain are intentionally NOT part of the matrix —
|
||||
// dynamicRange/highlight/shadow run as a tone shader (see toneShader.ts),
|
||||
// and EV is applied either as hardware AE bias (camera) or by the caller via
|
||||
// applyExposureGain (library), never doubled.
|
||||
// adjustments. EV, the EXPOSURE knob and the sim exposure bias are all
|
||||
// intentionally NOT part of the matrix: a stop of light is a gain on LIGHT, and
|
||||
// a matrix runs on sRGB-encoded values, where the same multiply is a gamma-space
|
||||
// move (a mid-grey at +1 EV would clip instead of landing at 0.73). They go
|
||||
// through EXPOSURE_SKSL in exportEngine instead. dynamicRange/highlight/shadow
|
||||
// are in the same place for the same kind of reason — see toneShader.ts.
|
||||
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
|
||||
// Sim-owned exposure bias, in Exposure-slider units. A sim is colour only and
|
||||
// its `adjustments` stay neutral, so this does NOT surface on the Exposure
|
||||
// knob — it is folded in here, at the same stage and with the same gain/offset
|
||||
// mapping the knob uses, so the pixels get the lift the stock needs while the
|
||||
// UI keeps reading 0.
|
||||
const exposure = (adj.exposure ?? 0) + (SIM_EXPOSURE_BIAS[baseFilter] ?? 0);
|
||||
const contrast = adj.contrast + (SIM_CONTRAST_BIAS[baseFilter] ?? 0);
|
||||
const saturation = adj.saturation;
|
||||
const temperature = adj.temperature;
|
||||
@@ -435,17 +451,17 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
matrix = multiplyMatrices(shiftMat, matrix);
|
||||
}
|
||||
|
||||
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
|
||||
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
|
||||
if (exposure !== 0 || contrast !== 0) {
|
||||
const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
|
||||
const expOffset = (exposure / 10) * 0.15;
|
||||
// 6. Contrast — applied to ALL base filters (monochrome included) so both keep
|
||||
// affecting mono shots. Contrast scales around mid-grey 0.5, in the encoded
|
||||
// domain, which is where a contrast move belongs (it is a shape on the printed
|
||||
// ramp, not a gain on light).
|
||||
if (contrast !== 0) {
|
||||
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
|
||||
const conOffset = 0.5 * (1 - conScale);
|
||||
const adjMat = [
|
||||
expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale,
|
||||
0, expScale * conScale, 0, 0, expOffset + conOffset * expScale,
|
||||
0, 0, expScale * conScale, 0, expOffset + conOffset * expScale,
|
||||
conScale, 0, 0, 0, conOffset,
|
||||
0, conScale, 0, 0, conOffset,
|
||||
0, 0, conScale, 0, conOffset,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(adjMat, matrix);
|
||||
@@ -454,21 +470,6 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// Apply photographic EV compensation (stops) as a linear gain: 2^EV on every
|
||||
// RGB channel (offsets scale too, matching true exposure). Only used for
|
||||
// stills that were NOT exposed with a hardware AE bias (library images).
|
||||
export function applyExposureGain(matrix: number[], evStops: number): number[] {
|
||||
if (!evStops) return matrix;
|
||||
const s = Math.pow(2, evStops);
|
||||
const gainMat = [
|
||||
s, 0, 0, 0, 0,
|
||||
0, s, 0, 0, 0,
|
||||
0, 0, s, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
return multiplyMatrices(gainMat, matrix);
|
||||
}
|
||||
|
||||
// 4x5 Matrix multiplication utility: A * B
|
||||
function multiplyMatrices(a: number[], b: number[]): number[] {
|
||||
const result = new Array(20).fill(0);
|
||||
|
||||
@@ -10,15 +10,20 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
// turned saturated blues black under -SH and bright colours grey under -HL.
|
||||
//
|
||||
// Wide, soft knees so the knobs reach like a tone curve instead of biting only
|
||||
// at the very ends: HL rides only the top (0.65..1.00) so it leaves the greys
|
||||
// and midtones alone (a 0.45 knee dragged a mid-grey down) while SH rides the
|
||||
// lower half (0.00..0.55), and the 0.50 midpoint never moves. Earlier narrow
|
||||
// knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo
|
||||
// without true whites or blacks.
|
||||
// at the very ends: HL rides the top (0.50..1.00) so it leaves the greys alone
|
||||
// (a knee that started lower dragged a mid-grey down) while SH rides the lower
|
||||
// half (0.00..0.55), and the 0.50 midpoint never moves.
|
||||
//
|
||||
// Both knobs are pure additive shifts of the luma. That keeps the curve
|
||||
// monotonic (steepest knee slope 4.29/unit * 0.22 = 0.94, and the two knees
|
||||
// never overlap), so a brighter input can never come out darker. The earlier
|
||||
// HL is Lightroom's highlight RECOVERY and it is weighted by the headroom that is
|
||||
// left, (1 - t): the move falls to zero as t reaches pure white, so a blown sky,
|
||||
// a lamp or a specular can be pulled back without the white going grey. The pull
|
||||
// peaks around t = 0.79 at 0.13 of the ramp for a full -10 — the edge of a cloud,
|
||||
// which is the detail there is to get back (measured, and checked for
|
||||
// monotonicity over the whole -1..1 rectangle of the two knobs).
|
||||
//
|
||||
// Both knobs are additive shifts of the luma. That keeps the curve monotonic
|
||||
// (worst slope +0.003 at t = 0.99 with HL +10 and SH -10, and the two knees
|
||||
// barely overlap), so a brighter input can never come out darker. The earlier
|
||||
// multiplicative form was NOT monotonic: with hl=-1 a grey 0.73 came out
|
||||
// darker than 0.80.
|
||||
//
|
||||
@@ -136,10 +141,15 @@ vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
float hlMask = smoothstep(0.65, 1.00, t);
|
||||
float hlMask = smoothstep(0.50, 1.00, t);
|
||||
float shMask = 1.0 - smoothstep(0.00, 0.55, t);
|
||||
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
|
||||
float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
|
||||
// The (1.0 - t) headroom weight is the whole point of the highlight curve: at
|
||||
// t = 1.0 the
|
||||
// weight is 0, so the pull-back cannot touch a pure white (a sun, a bulb, a
|
||||
// specular) and cannot turn it grey. A LIFT gets the same weight, which rides
|
||||
// it into the upper midtones and leaves the clipping where it was.
|
||||
float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
|
||||
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
|
||||
o += dr * 0.12 * shMask * (1.0 - t);
|
||||
o -= dr * 0.18 * hlMask * t;
|
||||
@@ -224,6 +234,37 @@ ${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
|
||||
}
|
||||
`;
|
||||
|
||||
// EXPOSURE / EV — the one pass that has to run in LINEAR light.
|
||||
//
|
||||
// A stop is a multiplier on LIGHT, and the old EV row multiplied sRGB-ENCODED
|
||||
// values: +1 EV took a mid-grey 0.5 straight to a blown 1.0 where a real stop
|
||||
// gives 0.73. Here the pixel is linearised, scaled by 2^ev, and re-encoded —
|
||||
// which is what a camera does when the shutter stays open twice as long: every
|
||||
// value keeps its ratio, the highlights roll instead of flattening, and the
|
||||
// HIGHLIGHT knob still has something to pull back afterwards.
|
||||
//
|
||||
// It sits between the graded image and the tone shader (see exportEngine step 3),
|
||||
// so `ev` carries the EXPOSURE knob, the stock's own bias and the EV knob added
|
||||
// up in stops — the caller hands in one number.
|
||||
//
|
||||
// The transfer pair is the accurate one (0.04045/12.92 + 2.4, and its inverse):
|
||||
// the same constants colorUtils.planckianLinear uses on the WB side.
|
||||
export const EXPOSURE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float ev;
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// Bright Pass Filter for the HDF EFFECT pass (HDF), SkSL over a child image
|
||||
// shader — the pattern TONE_SKSL above already proved on device.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user