From c2a4740a3f025883742c3490f3faeb972f97353a Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 13:00:14 +0700 Subject: [PATCH] web: let the white balance carry its tint, and take its ratio in the light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/shared/utils/colorUtils.ts | 39 +++++++++--- docker/frontend/src/App.tsx | 62 +++++++++++++++----- docker/frontend/src/ui/RecipeCreatePanel.tsx | 6 +- src/components/RecipeCreateModal.tsx | 6 +- src/utils/colorUtils.ts | 39 +++++++++--- 5 files changed, 116 insertions(+), 36 deletions(-) diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index cc3ad0e..2e59099 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -98,21 +98,33 @@ export function sanitizeHslBands(raw: unknown): Partial (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. diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index d416c6c..4016519 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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(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(null); const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('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, { 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'), diff --git a/docker/frontend/src/ui/RecipeCreatePanel.tsx b/docker/frontend/src/ui/RecipeCreatePanel.tsx index 7fbc700..614357d 100644 --- a/docker/frontend/src/ui/RecipeCreatePanel.tsx +++ b/docker/frontend/src/ui/RecipeCreatePanel.tsx @@ -11,12 +11,14 @@ import type { BaseFilter, ColorAdjustments, Recipe } from '../../shared/types'; export type RecipeDraft = Omit; // 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 }, ]; diff --git a/src/components/RecipeCreateModal.tsx b/src/components/RecipeCreateModal.tsx index 7c83e37..230ea71 100644 --- a/src/components/RecipeCreateModal.tsx +++ b/src/components/RecipeCreateModal.tsx @@ -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 }, ]; diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index da79cfb..b8bf2f6 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -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.