Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
WB and FX were separate tabs whose only content was the same Lightroom-style develop column LIGHT already renders, so the rail carried three doors into one room. LIGHT now owns the whole column: white balance, tone, presence, the effects group (grain, dehaze, vignette) and the filters, in that order. The `wb` and `fx` TabIds, their rail entries, their `wbLabel()` helper and the now dead `tab.wb` / `tab.fx` i18n keys are gone; `ToolRail` documents eight tabs. Every continuous develop value that the UI exposes as a symmetric knob now runs -100..+100 instead of -10..+10. `paramDefs` gains the `HUNDRED` key set and the `deepen` helper, which widens a def's range and scales its accessors by ten, so the store keeps its -10..+10 internal scale and every stored look, DEFAULT_RECIPES entry and URL round-trip is byte-identical. Params with a meaningful physical scale (temperature in kelvin, exposure in EV-ish units, grain, grain size, hdf, vignette, rotate, crop) keep their own units. Highlight recovery no longer bends hue. The old knee clamped the per-channel gain into 0.55..1.35, which is a per-channel operation and therefore a hue rotation: on the flat skin patch it walked hue from 24 deg to 48 deg at HIGHLIGHT +100, and on a saturated 30:1 chroma ramp it desaturated toward black instead of toward white. The shader now caps the pixel's distance from its undersaturated knee point while preserving the direction of that offset, i.e. it scales chroma and keeps hue, then clamps into gamut. Verified: - `npx tsc --noEmit` clean; `npm run build` clean. - `node scripts/highlight-knee-check.mjs` (pins the new shader source and twin-tests 135 knob combinations) ok. - Existing checks re-run green: `auto-tone-check`, `half-check`, `white-level-check`, `preview-match-check`, `library-check`, `scan-nav-check`, `roll-walk-check`. - Live browser pass: rail shows exactly the seven expected tabs with no WB or FX; LIGHT renders 5 panels / 23 `data-key` knobs; 12 knobs report `min=-100 max=100`; everything else keeps its own range. - Highlight hue measured on ten flat colour patches: HIGHLIGHT -100 gives a hue delta of 0.00 deg on every chromatic patch; HIGHLIGHT +100 stays within 0.22 deg (sky) and 0.28 deg (magenta) wherever chroma survives, and the patches the ramp intentionally drives to white arrive fully neutral. Greys stay neutral (channel spread <= 2/255) at every knob setting. Skin patch before/after: 23.94 deg -> 0.00 deg. ponytail: temperature (2500-10000 K), exposure (+-10 units at 0.25 EV each), grain, grain size, hdf, vignette, rotate and crop deliberately keep their own scales rather than the blanket -100..+100; widen them the day a user asks for more range, not before. Hue assertions live in the flat-patch check because real-photo measurements pick up 2-4 deg of resample drift from the snapshot pipeline that has nothing to do with the shader. Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
+38
-78
@@ -26,7 +26,7 @@ import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
import { api, type Photo, type SavedRecipe, type User } from './api';
|
||||
import { TIERS } from './config/tiers';
|
||||
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
||||
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
|
||||
import { PARAM_DEFS } from '../shared/utils/paramDefs';
|
||||
import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare';
|
||||
import { writeJpegExif } from '../shared/utils/exifWrite';
|
||||
import {
|
||||
@@ -172,11 +172,10 @@ function hslToHex(h: number, s: number, l: number): string {
|
||||
}
|
||||
|
||||
// The knobs that drive a colour get a swatch under their ruler, so the hue is
|
||||
// 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.
|
||||
// visible next to the number. TEMP is the only one left on this path: TINT's row
|
||||
// lives in the sidebar's WB panel now, so its ±10 axis draws no ruler here.
|
||||
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
|
||||
temperature: temperatureSwatch,
|
||||
tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3),
|
||||
};
|
||||
|
||||
// The option strips, one open at a time (phone's GroupKey set minus the groups
|
||||
@@ -2038,24 +2037,6 @@ export function Workspace() {
|
||||
|
||||
// Continuous parameter chips: label + live value; amber text when edited,
|
||||
// amber border while its ruler is open.
|
||||
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
|
||||
defs.map((def) => {
|
||||
const value = def.get(recipe.adjustments);
|
||||
const locked = !!def.pro && !pro;
|
||||
return {
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
value: def.display(value),
|
||||
active: openParam === def.key,
|
||||
amberValue: openParam !== def.key && value !== def.defaultValue,
|
||||
// The phone greys a PRO knob out; the web keeps it clickable and says
|
||||
// why instead, because the fix here is an action (prove the address)
|
||||
// rather than a purchase.
|
||||
pro: !!def.pro,
|
||||
onClick: locked ? promptPro : () => toggleParam(def.key),
|
||||
};
|
||||
});
|
||||
|
||||
const slider: SliderDef | null = useMemo(() => {
|
||||
if (!openParam) return null;
|
||||
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not
|
||||
@@ -2184,13 +2165,6 @@ export function Workspace() {
|
||||
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;
|
||||
};
|
||||
// GRAIN's readout: the clump count the stock and the SIZE knob add up to, in
|
||||
// the print's own terms (grainPerInch, grainShader.ts). A READOUT, not a pick —
|
||||
// the strip prints it beside the two knobs that move it. The field swings a
|
||||
@@ -2493,22 +2467,24 @@ export function Workspace() {
|
||||
// shoulder, not a gain on the whole pixel), and the two spatial ones — CLARITY
|
||||
// against the frame's blurred reference, DEHAZE through the dark channel. Each
|
||||
// one means inside the mask what the row of the same name means on the whole
|
||||
// frame. All six are the same -10..+10 row, so one factory builds them.
|
||||
// frame, down to the -100..+100 the frame-wide rows read (the mask keeps the
|
||||
// look's own ±10 unit, and the ×10 is the slider's — see paramDefs).
|
||||
const maskKnobRow = (
|
||||
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze',
|
||||
label: string
|
||||
): SliderDef => {
|
||||
const v = selMask?.[key] ?? 0;
|
||||
const wide = Math.round(v * 10);
|
||||
return {
|
||||
key: `mask-${key}`,
|
||||
label,
|
||||
value: v,
|
||||
min: -10,
|
||||
max: 10,
|
||||
value: wide,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
display: v > 0 ? `+${v}` : String(v),
|
||||
display: wide > 0 ? `+${wide}` : String(wide),
|
||||
edited: v !== 0,
|
||||
onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) } as Partial<GradientMask>),
|
||||
onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) / 10 } as Partial<GradientMask>),
|
||||
onReset: () => setMaskKnob({ [key]: 0 } as Partial<GradientMask>),
|
||||
};
|
||||
};
|
||||
@@ -2531,25 +2507,25 @@ export function Workspace() {
|
||||
{
|
||||
key: 'mask-contrast',
|
||||
label: 'CONTRAST',
|
||||
value: selMask.contrast,
|
||||
min: -10,
|
||||
max: 10,
|
||||
value: Math.round(selMask.contrast * 10),
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
display: selMask.contrast > 0 ? `+${selMask.contrast}` : String(selMask.contrast),
|
||||
display: selMask.contrast > 0 ? `+${Math.round(selMask.contrast * 10)}` : String(Math.round(selMask.contrast * 10)),
|
||||
edited: selMask.contrast !== 0,
|
||||
onChange: (v) => setMaskKnob({ contrast: Math.round(v) }),
|
||||
onChange: (v) => setMaskKnob({ contrast: Math.round(v) / 10 }),
|
||||
onReset: () => setMaskKnob({ contrast: 0 }),
|
||||
},
|
||||
{
|
||||
key: 'mask-saturation',
|
||||
label: 'SATURATION',
|
||||
value: selMask.saturation,
|
||||
min: -10,
|
||||
max: 10,
|
||||
value: Math.round(selMask.saturation * 10),
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
display: selMask.saturation > 0 ? `+${selMask.saturation}` : String(selMask.saturation),
|
||||
display: selMask.saturation > 0 ? `+${Math.round(selMask.saturation * 10)}` : String(Math.round(selMask.saturation * 10)),
|
||||
edited: selMask.saturation !== 0,
|
||||
onChange: (v) => setMaskKnob({ saturation: Math.round(v) }),
|
||||
onChange: (v) => setMaskKnob({ saturation: Math.round(v) / 10 }),
|
||||
onReset: () => setMaskKnob({ saturation: 0 }),
|
||||
},
|
||||
maskKnobRow('highlights', 'HIGHLIGHT'),
|
||||
@@ -2757,12 +2733,16 @@ export function Workspace() {
|
||||
}
|
||||
case 'light':
|
||||
// LIGHT is the one tab whose knobs are not chips: the sidebar
|
||||
// (DevelopPanels) puts every one of them on screen at once, so only the
|
||||
// two things that are NOT knobs stay here — AUTO, which is an action
|
||||
// (see autoTune), and TONE CURVE, which opens the graph on the photo
|
||||
// (ToneCurvePanel) rather than a ruler in the last column. It glows amber
|
||||
// once the graph is off the diagonal, which is the only place the curve
|
||||
// is reported.
|
||||
// (DevelopPanels) puts every one of them on screen at once — WB's two,
|
||||
// the tonal range, presence, detail and effects — so what stays in this
|
||||
// row is what is NOT a knob. AUTO, which is an action (see autoTune);
|
||||
// TONE CURVE, which opens the graph on the photo (ToneCurvePanel) rather
|
||||
// than a ruler in the last column, and glows amber once the graph is off
|
||||
// the diagonal; and the three tools that take the pointer or a strip of
|
||||
// their own — FIX's brushes, GRADIENT MASK's shapes, MONOCHROME and the
|
||||
// GRAIN strip. WB and FX are panels of this tab now, so their rows live
|
||||
// here: a chip that opened the same record the panel already shows would
|
||||
// be a second way to one knob.
|
||||
return [
|
||||
{ key: 'auto', label: 'AUTO', onClick: () => void autoTune() },
|
||||
{
|
||||
@@ -2772,27 +2752,10 @@ export function Workspace() {
|
||||
amberValue: curveIsActive(recipe.adjustments.toneCurve),
|
||||
onClick: () => toggleParam('curve'),
|
||||
},
|
||||
];
|
||||
case 'wb':
|
||||
return [
|
||||
{
|
||||
// TEMP is the one strip chip that names its value — nothing else
|
||||
// does, and it names it even at the neutral pair: TEMP AUTO.
|
||||
...groupChip('temp'),
|
||||
label: 'TEMP',
|
||||
value: wbLabel(),
|
||||
},
|
||||
...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')),
|
||||
groupChip('cx'),
|
||||
groupChip('cxb'),
|
||||
];
|
||||
case 'fx': {
|
||||
// FX's tools are not looks: each is a strip of its own, opened from the
|
||||
// row and holding the two chips that arm the pointer — FIX's brushes and
|
||||
// GRADIENT MASK's shapes. The strip's own chip carries the name and glows
|
||||
// amber while there is something inside it to clear, so the row above the
|
||||
// look chips stays a row of looks.
|
||||
return [
|
||||
// FX's tools are not looks: each is a strip of its own, opened from the
|
||||
// row. FIX repairs a speck of dust, MOSAIC hides a patch, GRADIENT MASK
|
||||
// draws a shape; the strip's own chip carries the name and glows amber
|
||||
// while there is something inside it to clear.
|
||||
{
|
||||
key: 'fix',
|
||||
label: 'FIX',
|
||||
@@ -2818,13 +2781,10 @@ export function Workspace() {
|
||||
},
|
||||
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
|
||||
// GRAIN is a strip of its own — amount, size, and the count they add up
|
||||
// to — so it is one chip here and its two knobs live inside it; the
|
||||
// chip keeps the place the knob had in this row.
|
||||
...PARAM_DEFS.filters.flatMap((p): ChipDef[] =>
|
||||
p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p])
|
||||
),
|
||||
// to — so it is one chip here and its two knobs are rows of DETAIL &
|
||||
// EFFECTS.
|
||||
groupChip('grain'),
|
||||
];
|
||||
}
|
||||
case 'hsl': {
|
||||
// The mixer is PRO: the picker, the eight bands and the three
|
||||
// image-wide knobs all come with the account, so a guest is offered the
|
||||
|
||||
@@ -58,8 +58,6 @@ export const en: Dict = {
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.photos': 'MY PHOTOS',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.hsl': 'HSL',
|
||||
'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO',
|
||||
'tab.frame': 'FRAME',
|
||||
|
||||
@@ -71,8 +71,6 @@ export const vi = {
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.photos': 'ẢNH CỦA TÔI',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.hsl': 'HSL',
|
||||
'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH',
|
||||
'tab.frame': 'FRAME',
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { MsgKey } from '../i18n/vi';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
// The app's eight tabs. WB leads LIGHT — white balance is the first thing the
|
||||
// eye corrects, and the web rail draws them in the order the panel opens. The
|
||||
// rail is a single column on desktop and becomes the bottom bar under 860px
|
||||
// (see styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'hsl' | 'frame' | 'save' | 'create';
|
||||
// The app's eight tabs. LIGHT is the develop column and holds every knob of the
|
||||
// picture — white balance, the tonal range, presence and the effects — so WB and
|
||||
// FX, which used to be tabs of their own, are panels of it now. The rail is a
|
||||
// single column on desktop and becomes the bottom bar under 860px (see
|
||||
// styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'photos' | 'light' | 'hsl' | 'frame' | 'save' | 'create';
|
||||
|
||||
// The build this bundle came from, put in by vite at build time and named by
|
||||
// whoever built the image (see vite.config.ts). Empty under a dev server.
|
||||
@@ -15,9 +16,7 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
|
||||
{ id: 'favorited', label: 'tab.favorited', glyph: '★' },
|
||||
{ id: 'photos', label: 'tab.photos', glyph: '▤' },
|
||||
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
|
||||
{ id: 'light', label: 'tab.light', glyph: '☀' },
|
||||
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
|
||||
// The HSL mixer sits with the colour tabs: it edits one hue band at a time.
|
||||
{ id: 'hsl', label: 'tab.hsl', glyph: '◍' },
|
||||
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
|
||||
|
||||
Reference in New Issue
Block a user