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:
+30
-9
@@ -12,21 +12,33 @@ import { ColorAdjustments, BaseFilter } from '../types';
|
||||
// 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
|
||||
@@ -43,6 +55,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.
|
||||
|
||||
Reference in New Issue
Block a user