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.
+48 -14
View File
@@ -107,14 +107,19 @@ const PRO_SIMS: string[] = ['sim-bw-high-contrast', 'sim-leica', 'sim-leica-vivi
// FREE draws the frame on the canvas and lets the user drag its corners, so
// every ratio is offered here — no ratio is applied until APPLY.
const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9'];
const TEMP_PRESETS: { label: string; kelvin: number }[] = [
{ label: 'AUTO', kelvin: 5500 },
{ label: 'DAYLIGHT', kelvin: 5600 },
{ label: 'DAYLIGHT -3R', kelvin: 5800 },
{ label: 'CLOUDY', kelvin: 6500 },
{ label: 'SHADE', kelvin: 7500 },
{ label: 'TUNGSTEN', kelvin: 3200 },
{ label: 'FLUOR', kelvin: 4000 },
// The seven white-balance presets, and the engine PAIR each one stands for: the
// phone's own WB_PAIRS, kelvin AND tint, so the same chip prints the same frame
// on both platforms. AUTO and DAYLIGHT share the neutral 5500K/0 pair (the
// engine has no scene meter), which is why the chip is keyed by PRESET and not
// by value — the pair alone cannot say which of the two is lit.
const WB_PRESETS: { key: string; label: string; kelvin: number; tint: number }[] = [
{ key: 'auto', label: 'AUTO', kelvin: 5500, tint: 0 },
{ key: 'daylight', label: 'DAYLIGHT', kelvin: 5500, tint: 0 },
{ key: 'daylight-3r', label: 'DAYLIGHT -3R', kelvin: 5500, tint: -3 },
{ key: 'cloudy', label: 'CLOUDY', kelvin: 6500, tint: 1 },
{ key: 'shade', label: 'SHADE', kelvin: 7500, tint: 2 },
{ key: 'tungsten', label: 'TUNGSTEN', kelvin: 3200, tint: 0 },
{ key: 'fluorescent', label: 'FLUOR', kelvin: 4000, tint: 3 },
];
// Kelvin -> the CAST the ruler prints. Not the colour of a blackbody at that K:
@@ -279,6 +284,10 @@ export function Workspace() {
// Where on the photo that colour was read: the mixer's panel hangs there.
const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
// The TEMP preset the user last tapped. AUTO and DAYLIGHT are the same pair on
// the engine, so the pair alone cannot say which chip is lit — the phone keeps
// the same memory (wbChoice in AdjustmentPanel).
const [wbChoice, setWbChoice] = useState<string | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
const [peek, setPeek] = useState(false);
@@ -1766,6 +1775,25 @@ export function Workspace() {
// Option strips: a chip opens one, each option picks a value. `off` is the
// neutral value the chip glows amber against.
//
// TEMP's value: the preset the user last picked while the engine pair still
// matches it, otherwise the preset that pair maps to (neutral ⇒ AUTO),
// otherwise the bare kelvin — where COLOR TEMP dragged by hand lands. The
// phone's own wbValue(), so one pair lights one chip on both platforms.
const wbValue = (): string => {
const t = Math.round(recipe.adjustments.temperature);
const ti = recipe.adjustments.tint ?? 0;
const remembered = WB_PRESETS.find((w) => w.key === wbChoice);
if (remembered && remembered.kelvin === t && remembered.tint === ti) return remembered.key;
return WB_PRESETS.find((w) => w.kelvin === t && w.tint === ti)?.key ?? `${t}K`;
};
// TEMP is the one strip chip that names its value, and it names it always:
// TEMP AUTO at the neutral pair, TEMP SHADE on a preset, TEMP 6300K where a
// hand-dragged ruler landed.
const wbLabel = (): string => {
const v = wbValue();
return WB_PRESETS.find((w) => w.key === v)?.label ?? v;
};
const groupDefs: Record<Exclude<GroupKey, 'wm'>, {
label: string;
off: string;
@@ -1842,14 +1870,20 @@ export function Workspace() {
// TEMP: the presets plus COLOR TEMP, which opens the ruler above instead
// of picking a value.
temp: {
label: 'TEMP', off: 'auto', value: String(recipe.adjustments.temperature),
label: 'TEMP', off: 'auto', value: wbValue(),
options: [
{ v: 'color-temp', d: 'COLOR TEMP' },
...TEMP_PRESETS.map((p) => ({ v: String(p.kelvin), d: p.label })),
...WB_PRESETS.map((p) => ({ v: p.key, d: p.label })),
],
onPick: (v) => {
if (v !== 'color-temp') remember();
return v === 'color-temp' ? toggleParam('temperature') : setAdjustment({ temperature: Number(v) });
if (v === 'color-temp') return toggleParam('temperature');
remember();
// A preset is the LOOK, tint included: the phone's WB-tab chip sets the
// pair, and a chip that only moved the kelvin left the frame a different
// colour on the two platforms.
setWbChoice(v);
const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0];
setAdjustment({ temperature: p.kelvin, tint: p.tint });
},
},
wmColor: {
@@ -2151,10 +2185,10 @@ export function Workspace() {
return [
{
// TEMP is the one strip chip that names its value — nothing else
// does.
// does, and it names it even at the neutral pair: TEMP AUTO.
...groupChip('temp'),
label: 'TEMP',
value: Math.round(recipe.adjustments.temperature) === 5500 ? 'AUTO' : `${recipe.adjustments.temperature}K`,
value: wbLabel(),
},
...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')),
groupChip('cx'),
+4 -2
View File
@@ -11,12 +11,14 @@ import type { BaseFilter, ColorAdjustments, Recipe } from '../../shared/types';
export type RecipeDraft = Omit<Recipe, 'id' | 'isCustom'>;
// WB presets mirror the phone's WB-tab chips but keep pure presets (no -R
// variants — those are the separate Red/Blue offset boxes below).
// variants — those are the separate Red/Blue offset boxes below). SHADE is the
// WB tab's own 7500K/+2: it read 8000K here, which made a recipe created in one
// place print a different white balance from the same chip picked in the other.
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
{ key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 },
{ key: 'shade', label: 'SHADE', temperature: 7500, tint: 2 },
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
];
+4 -2
View File
@@ -39,12 +39,14 @@ interface RecipeCreateModalProps {
}
// WB presets mirror the WB-tab chips but keep pure presets (no -R variants —
// those are the separate Red/Blue offset textboxes below).
// those are the separate Red/Blue offset textboxes below). SHADE is the WB tab's
// own 7500K/+2: it read 8000K here, which made a recipe created in the modal
// print a different white balance from the same chip picked on the photo.
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
{ key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 },
{ key: 'shade', label: 'SHADE', temperature: 7500, tint: 2 },
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
];
+30 -9
View File
@@ -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.