From 7e47a153b859d33f837bbd22386185ce28fb2fa3 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 25 Sep 2026 17:44:28 +0700 Subject: [PATCH] web: make EXPOSURE, EV and HIGHLIGHT mean what Lightroom means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/shared/types/index.ts | 4 +- docker/frontend/shared/utils/colorUtils.ts | 73 +++++++++++----------- docker/frontend/shared/utils/toneShader.ts | 61 +++++++++++++++--- docker/frontend/src/App.tsx | 27 ++++++++ docker/frontend/src/engine/exportEngine.ts | 64 ++++++++++++------- docker/frontend/src/ui/Histogram.tsx | 26 ++++++++ 6 files changed, 186 insertions(+), 69 deletions(-) diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 091fb48..8656128 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -201,7 +201,9 @@ export interface ColorAdjustments { // way a stack of local adjustments reads in Lightroom. Absent = no local // adjustments at all, and no pass is built. masks?: GradientMask[]; - exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. + // -3 to +3 EV. Camera: AE bias (hardware, so the render adds none). + // Library: a real 2^EV gain on LINEAR light (EXPOSURE_SKSL) — not a matrix row. + exposureCompensation: number; } export interface Recipe { diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index 2e59099..d84b870 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -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> = { - '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> = { + '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); diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 14d5407..ac99c56 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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. // diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 27644a0..4d0981b 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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 diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index ac21c05..00e213d 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -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 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 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 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()); diff --git a/docker/frontend/src/ui/Histogram.tsx b/docker/frontend/src/ui/Histogram.tsx index 6948930..71306f6 100644 --- a/docker/frontend/src/ui/Histogram.tsx +++ b/docker/frontend/src/ui/Histogram.tsx @@ -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 {