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:
@@ -22,9 +22,10 @@
|
||||
import { Skia } from './skiaShim';
|
||||
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types';
|
||||
import { ASPECT_RATIO_W_H } from '../../shared/types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../../shared/utils/colorUtils';
|
||||
import { getSkiaColorMatrix, exposureStops } from '../../shared/utils/colorUtils';
|
||||
import {
|
||||
TONE_SKSL,
|
||||
EXPOSURE_SKSL,
|
||||
GLOW_SKSL,
|
||||
CLARITY_SKSL,
|
||||
getToneUniforms,
|
||||
@@ -143,6 +144,7 @@ function flush(surface: any): void {
|
||||
|
||||
let sharpenEffect: any = null;
|
||||
let toneEffect: any = null;
|
||||
let exposureEffect: any = null;
|
||||
let cinemaEffect: any = null;
|
||||
let glowEffect: any = null;
|
||||
let halationEffect: any = null;
|
||||
@@ -151,11 +153,12 @@ let curveEffect: any = null;
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
if (!exposureEffect) exposureEffect = Skia.RuntimeEffect.Make(EXPOSURE_SKSL);
|
||||
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
|
||||
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
|
||||
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect };
|
||||
return { sharpenEffect, toneEffect, exposureEffect, cinemaEffect, glowEffect, halationEffect, curveEffect };
|
||||
}
|
||||
|
||||
// FX's HEAL is the one effect whose shader depends on the recipe rather than on
|
||||
@@ -443,20 +446,24 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
|
||||
// 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
|
||||
// The matrix grades into its OWN image first, because a SkPaint runs its
|
||||
// shader before its colourFilter: with both on one paint the exposure gain
|
||||
// landed AFTER the tone pass, so +EXPOSURE took the 0.78 a rolled-back
|
||||
// highlight came out at and pushed it straight back to 1.0 — the HIGHLIGHT
|
||||
// slider looked dead the moment exposure went up (measured, see the order
|
||||
// probe). The tone shader's child is the graded image, so the highlight
|
||||
// mask finally sees the exposed value it is meant to pull back.
|
||||
const evStops = adjustments.exposureCompensation ?? 0;
|
||||
const evMatrix = options?.evFromCamera
|
||||
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
||||
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
|
||||
// 3. Colour matrix, then the exposure pass (+EV unless the camera already
|
||||
// biased the shot). The matrix grades into its OWN image first, because a
|
||||
// SkPaint runs its shader before its colourFilter: with both on one paint the
|
||||
// exposure gain landed AFTER the tone pass, so +EXPOSURE took the 0.78 a
|
||||
// rolled-back highlight came out at and pushed it straight back to 1.0 — the
|
||||
// HIGHLIGHT slider looked dead the moment exposure went up (measured, see the
|
||||
// order probe). The tone shader's child is the graded-and-exposed image, so
|
||||
// the highlight mask finally sees the value it is meant to pull back.
|
||||
//
|
||||
// The gain itself is the linear-light pass of its own (EXPOSURE_SKSL), not a
|
||||
// row of the matrix: a stop is a multiplier on light and the matrix runs on
|
||||
// sRGB-encoded values (see colorUtils.exposureStops). It carries the EXPOSURE
|
||||
// knob, the stock's bias and the EV knob, all in stops.
|
||||
const userEv = options?.evFromCamera ? 0 : adjustments.exposureCompensation ?? 0;
|
||||
const evStops = exposureStops(adjustments, recipe.baseFilter) + userEv;
|
||||
const matrix = getSkiaColorMatrix(recipe.baseFilter, adjustments);
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(matrix));
|
||||
const matrixIsIdentity = matrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
|
||||
|
||||
// 3b. Tone shader.
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
@@ -473,7 +480,8 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
const curveOn = curveIsActive(curve);
|
||||
let paintShader: any = null;
|
||||
let gradeImage = skImage;
|
||||
const graded = (toneIsActive(tone) || cinemaOn || curveOn) && !matrixIsIdentity;
|
||||
const expOn = evStops !== 0;
|
||||
const graded = (toneIsActive(tone) || cinemaOn || curveOn || expOn) && !matrixIsIdentity;
|
||||
if (graded) {
|
||||
const snapshot = gradeThrough(skImage, width, height, colorFilter);
|
||||
if (snapshot) {
|
||||
@@ -487,20 +495,32 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
)
|
||||
);
|
||||
// Exposure takes the place of the colour filter on the draw, so it too needs
|
||||
// the matrix to be IN its child — hence expOn in `graded` above. Everything
|
||||
// downstream that samples "the image" now samples after the exposure.
|
||||
let exposeShader: any = null;
|
||||
if (expOn) {
|
||||
const { exposureEffect: effect } = effects();
|
||||
if (effect) exposeShader = own(effect.makeShaderWithChildren([evStops], [imageShaderOf()]));
|
||||
}
|
||||
const baseShaderOf = () => exposeShader ?? imageShaderOf();
|
||||
if (toneIsActive(tone)) {
|
||||
const { toneEffect: effect } = effects();
|
||||
if (effect) {
|
||||
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
|
||||
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [baseShaderOf()]));
|
||||
}
|
||||
}
|
||||
if (cinemaOn) {
|
||||
const { cinemaEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const cs = own(effect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
|
||||
const cs = own(effect.makeShaderWithChildren(cinema.flat, [toneShader ?? baseShaderOf()]));
|
||||
if (cs) paintShader = cs;
|
||||
}
|
||||
}
|
||||
if (!paintShader && toneShader) paintShader = toneShader;
|
||||
// The exposure pass must survive as the last member of the chain when nothing
|
||||
// else is active — it is a shader, not a colour filter, so the draw needs it.
|
||||
if (!paintShader && exposeShader) paintShader = exposeShader;
|
||||
// 3e. The curve pass, last: whatever the stock, the matrix, the mixer or the
|
||||
// seasonal grade did, a point moved on the graph lands the pixel where the
|
||||
// graph says. The table is an image, so the shader can look it up per channel
|
||||
@@ -533,7 +553,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
: null;
|
||||
const cs =
|
||||
effect && lutShader
|
||||
? own(effect.makeShaderWithChildren([], [paintShader ?? imageShaderOf(), lutShader]))
|
||||
? own(effect.makeShaderWithChildren([], [paintShader ?? baseShaderOf(), lutShader]))
|
||||
: null;
|
||||
if (cs) paintShader = cs;
|
||||
}
|
||||
@@ -577,7 +597,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
const strength = (adjustments.hdf ?? 0) / 10;
|
||||
const sigma = width * (0.006 + 0.024 * strength);
|
||||
const { glowEffect: effect } = effects();
|
||||
const srcShader = paintShader ?? imageShaderOf();
|
||||
const srcShader = paintShader ?? baseShaderOf();
|
||||
const glowShader =
|
||||
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
||||
const glowPaint = own(Skia.Paint());
|
||||
@@ -645,7 +665,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
// chips still mean 0/3/6; a sensor stock carries none at any amount.
|
||||
if (grainAmount > 0 && stock.halation > 0 && stock.halo > 0) {
|
||||
const { halationEffect: effect } = effects();
|
||||
const srcShader = paintShader ?? imageShaderOf();
|
||||
const srcShader = paintShader ?? baseShaderOf();
|
||||
const haloShader = effect ? own(effect.makeShaderWithChildren(halationUniformArray(stock), [srcShader])) : null;
|
||||
if (haloShader) {
|
||||
const haloPaint = own(Skia.Paint());
|
||||
|
||||
Reference in New Issue
Block a user