web: print what the temperature ruler does, not the colour of the light

COLOR TEMP's swatch was Tanner Helland's blackbody fit, so it painted the
colour of the LIGHT the number names: 2500K came out orange #ff9f46 while the
engine cooled the frame on the same knob. Read off the render of a neutral
128-grey (mean of the painted preview, CCT via McCamy), the two ran opposite
ways at every stop:

  K      old swatch        swatch CCT   picture mean    picture CCT
  2500   #ff9f46           2384K        115,135,204     47691K  (blue)
  3200   #ffb87b           3096K        128,141,171     11246K
  4000   #ffcea6           3931K        135,141,156      8203K
  5500   #ffedde           5414K        144,139,143      6479K  (unity)
  6500   #fffefa           6322K        148,138,139      6029K
  7500   #e6ebff           7730K        149,138,132      5555K
 10000   #cadaff          10024K        153,138,128      5180K  (warm)

So the number is a Kelvin of the scene's light — which is exactly why the
engine warms the picture as K rises — and the swatch was the one thing on the
ruler that disagreed with the ruler. It is now painted from kelvinToRGB
itself, the same gains the render puts on every pixel, on a mid grey, so the
band under the knob can never drift from the frame above it. kelvinToHex goes
away with it: one Kelvin->colour mapping in the app, not two.

Measured after: the swatch's R-B tracks the render's own cast at every stop
(-82 vs -89.8 at 2500K, +23 vs +24.0 at 10000K), 5500K is flat #808080 — the
engine's unity point — and 3200K sits cool / 7500K warm either side of it.

temp-swatch-test.cjs (scratchpad): 33 PASS / 0 FAIL.
This commit is contained in:
2026-09-23 11:17:59 +07:00
parent d55b7b49ca
commit 7a8c0aa2b5
+13 -11
View File
@@ -33,7 +33,7 @@ import {
type HslBandId,
type Recipe,
} from '../shared/types';
import { HSL_BANDS, isMonochromeBase, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils';
import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -117,15 +117,17 @@ const TEMP_PRESETS: { label: string; kelvin: number }[] = [
{ label: 'FLUOR', kelvin: 4000 },
];
// Kelvin -> sRGB (Tanner Helland's approximation, what every WB widget uses):
// 2500K reads warm orange, 5500K a near-neutral white, 10000K blue.
function kelvinToHex(k: number): string {
const t = Math.min(40000, Math.max(1000, k)) / 100;
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');
const r = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
const g = t <= 66 ? 99.4708025861 * Math.log(t) - 161.1195681661 : 288.1221695283 * (t - 60) ** -0.0755148492;
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
return `#${chan(r)}${chan(g)}${chan(b)}`;
// Kelvin -> the CAST the ruler prints. Not the colour of a blackbody at that K:
// the number names the light the scene was shot under, so the engine compensates
// for it and WARMS the picture as K rises (kelvinToRGB). A swatch painted with
// the light's own colour would therefore run the other way from the picture —
// orange under a knob that blue-d the frame. Measuring the ruler means painting
// what the render does: the engine's own gains on a mid grey, which is the same
// matrix step the preview puts on every pixel, so this can never drift from it.
function temperatureSwatch(k: number): string {
const g = kelvinToRGB(k);
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0');
return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`;
}
// HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its
@@ -144,7 +146,7 @@ function hslToHex(h: number, s: number, l: number): string {
// visible next to the number. TINT runs green (-10) through a neutral grey (0)
// to magenta (+10) — the axis every WB tool labels G..M.
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
temperature: kelvinToHex,
temperature: temperatureSwatch,
tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3),
};