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.