web: let the white balance carry its tint, and take its ratio in the light

TEMP was a kelvin and nothing else, and the seven presets were seven numbers
the two platforms disagreed about: AUTO 5500, DAYLIGHT 5600, DAYLIGHT -3R
5800, CLOUDY 6500, SHADE 7500 here and 8000 in both recipe-creation forms,
TUNGSTEN 3200, FLUOR 4000. A chip tapped in the recipe panel and the same
chip tapped on the photo printed different frames.

The presets are now the phone's own WB_PAIRS — kelvin AND tint — in all three
places, so every chip lands the same pair on either platform:

  AUTO 5500/0   DAYLIGHT 5500/0   DAYLIGHT -3R 5500/-3   CLOUDY 6500/1
  SHADE 7500/2  TUNGSTEN 3200/0   FLUOR 4000/3

SHADE read 8000K in RecipeCreatePanel and RecipeCreateModal; it is the WB
tab's 7500K/+2 now, so a recipe created in a form prints what the same chip
prints on the photo.

AUTO and DAYLIGHT stand for one pair, so the pair alone cannot say which of
the two is lit. TEMP is therefore keyed by preset and not by value: `wbChoice`
remembers the chip last tapped (the phone's own wbChoice), and `wbValue()`
answers it only while the engine pair still matches, else the first preset
that pair maps to, else the bare kelvin. `wbLabel()` names that pick on the
chip, which now always carries one: TEMP AUTO at the neutral pair, TEMP SHADE
on a preset, TEMP 6300K on the app's own default recipe where a hand-dragged
ruler landed. Measured on the deployed build (`wb-preset-test.cjs`, 33/0):
AUTO and DAYLIGHT print the same frame to the level, DAYLIGHT -3R moves the
green away from DAYLIGHT at the same 5500K, the ruler warms monotonically
across TUNGSTEN 0.525 / FLUOR 0.730 / AUTO 1.000 / CLOUDY 1.141 / SHADE 1.295,
a hand-drag to 10000K names the chip `10000K` and warms the picture R x1.183
B x0.793, SHADE tapped after that drag restores the pair 7500/2 and prints the
same frame the first tap did (R 156.8 vs 156.8), and a temperature drag leaves
a preset's tint standing (FLUOR's +3).

kelvinToRGB itself was the other half. It took the ratio of the sRGB-ENCODED
blackbody colours and square-rooted it, which measured R x1.06 / B x0.89 from
5500K to 10000K — a shift a swatch shows and a sunset does not. White balance
is a gain on LIGHT, so the ratio is taken in linear light now
(`planckianLinear`) and tamed by a new `KELVIN_TAME = 0.5`: the same 10000K
moves the frame R x1.16 / B x0.76, and 2500K its mirror, which is what a
camera does with its WB set 4500K off the scene. One constant scales the whole
ruler and both platforms carry the same one. Measured through the app
(`_temp-probe.cjs`, gains against 5500K): 2500 R x0.569 B x1.640, 4000 R x0.866
B x1.197, 6500 R x1.057 B x0.940, 10000 R x1.140 B x0.759 — the readout is
compressed against the raw gain because the cast lands on encoded, clipping
pixels, which is the reason for the tame in the first place.

ponytail: no scene meter, so AUTO stays the neutral 5500K/0 pair and is a name
for it, not a measurement. Add one when the engine reads the frame.

ponytail: KELVIN_TAME scales the whole ruler both ways. Split it into a warm
and a cool constant only if the two ends are ever asked to move apart.

Verified: `wb-preset-test.cjs` 33/0 and `_temp-probe.cjs`, `sims-test.cjs`
31/0, `fx-mono-test.cjs` 15/0, `wm-font-test.cjs` 38/0 against the deployed
build; web `tsc --noEmit` clean, the phone's scoped check down to its two
pre-existing `skiaImage.ts` nulls.
This commit is contained in:
2026-09-23 13:00:14 +07:00
parent 4795a2a0ee
commit c2a4740a3f
5 changed files with 116 additions and 36 deletions
+30 -9
View File
@@ -98,21 +98,33 @@ export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBan
// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow-
// green rather than orange. Here every gain is divided by its own Rec.709
// luma, so a neutral pixel keeps its level and only the cast moves.
// - the excursion was arbitrary. These are the physical ratios of the sRGB
// blackbody colours, tamed by a square root: the raw linear-light ratio
// (2.1x on blue at 2500K) over-corrects once it lands on already tone-mapped
// pixels, which is exactly the domain a 4x5 colour matrix works in.
// - the excursion. It was the ratio of the sRGB-ENCODED blackbody colours,
// square-rooted, which measured R x1.06 / B x0.89 from 5500K to 10000K: a
// shift a swatch shows and a sunset does not. White balance is a gain on
// LIGHT, so the ratio is taken in linear light now (planckianLinear) and
// tamed by KELVIN_TAME: the same 10000K moves the frame R x1.16 / B x0.76,
// and 2500K its mirror, the way a camera does when its WB is set 4500K off
// the scene. One constant scales the whole ruler, and both platforms carry
// the same one, so a Kelvin picked on either lands the same cast.
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
const k = Math.min(10000, Math.max(2500, kelvin));
const light = planckianSRGB(k);
const ref = planckianSRGB(5500);
const light = planckianLinear(k);
const ref = planckianLinear(5500);
return normalizeGainLuma([
Math.sqrt(ref.r / Math.max(light.r, 1e-4)),
Math.sqrt(ref.g / Math.max(light.g, 1e-4)),
Math.sqrt(ref.b / Math.max(light.b, 1e-4)),
Math.pow(ref.r / Math.max(light.r, 1e-4), KELVIN_TAME),
Math.pow(ref.g / Math.max(light.g, 1e-4), KELVIN_TAME),
Math.pow(ref.b / Math.max(light.b, 1e-4), KELVIN_TAME),
]);
}
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
// square root of it — a cast that survives a sunset and a print. Toward 1 is a
// more violent ruler, toward 0.33 a gentler one; change it in BOTH copies
// (src/utils/colorUtils.ts and docker/frontend/shared/utils/colorUtils.ts).
const KELVIN_TAME = 0.5;
// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit
// of the Planckian locus; exact enough from 1000K up, and exactly neutral where
// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the
@@ -129,6 +141,15 @@ function planckianSRGB(kelvin: number): { r: number; g: number; b: number } {
return { r: norm(r), g: norm(g), b: norm(b) };
}
// The same colour in LINEAR light, which is the domain a white-balance gain
// belongs in: a camera's WB is a multiplier on the light, not on the encoded
// pixel, so the ratio has to be taken before the sRGB transfer curve.
function planckianLinear(kelvin: number): { r: number; g: number; b: number } {
const srgb = planckianSRGB(kelvin);
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
return { r: toLinear(srgb.r), g: toLinear(srgb.g), b: toLinear(srgb.b) };
}
// Divide a set of channel gains by their own luma: the CAST moves, the exposure
// does not. Channels still clip where they must — this only keeps a neutral
// pixel sitting at its neutral level.