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:
2026-09-29 17:04:08 +07:00
parent 34f8601c91
commit c0aaa67361
7 changed files with 178 additions and 106 deletions
@@ -81,6 +81,18 @@ assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back'); assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back'); assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again'); assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
// The rebuild after the ramp: the chroma difference rides the new luma, scaled
// by one number that only the cube can pull back. The doc's ratio is NOT it any
// more — scaling all three channels by Luma_new / Luma_old is exact until a
// channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came
// back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5
// it multiplies a near-black pixel's cast by up to x30.
assert.match(sksl, /float k = 1\.0;/);
assert.match(sksl, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
assert.match(sksl, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
assert.match(sksl, /rgb = clamp\(vec3\(o\) \+ \(rgb - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio came back');
// The transfer pair has to be the accurate one where it is still used (the // The transfer pair has to be the accurate one where it is still used (the
// exposure pass), or that pass is drawn in a space that is not linear at all. // exposure pass), or that pass is drawn in a space that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/); assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
@@ -238,4 +250,76 @@ for (const bl of [-1, 1])
assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`); assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`);
} }
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
// chroma difference, and the one scale the cube allows.
const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
function rebuild(rgb, k) {
const t = lumaOf(rgb);
const o = ramp(t, k).o;
const hiC = Math.max(...rgb);
const loC = Math.min(...rgb);
let s = 1;
if (hiC > t) s = Math.min(s, (1 - o) / (hiC - t));
if (loC < t) s = Math.min(s, o / (t - loC));
const out = rgb.map((c) => o + (c - t) * s);
return { out, clamped: out.map((c) => clamp01(c)), o, t };
}
function hueOf(c) {
const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn;
if (d < 1e-9) return NaN;
let h;
if (mx === c[0]) h = (c[1] - c[2]) / d + (c[1] < c[2] ? 6 : 0);
else if (mx === c[1]) h = (c[2] - c[0]) / d + 2;
else h = (c[0] - c[1]) / d + 4;
return ((h * 60) % 360 + 360) % 360;
}
const colourCases = [
[0.9, 0.72, 0.6], // skin — the case that moved 24° under the ratio
[1, 0.97, 0.92], // a warm white at the very top of the ramp
[0.45, 0.65, 0.9], // sky
[1, 0.6, 0.2], // orange, one channel already on the ceiling
[0.45, 0.85, 0.4], // green
[0.05, 0.03, 0.02], // a shadow with a cast
[0.01, 0.008, 0.006],// and the same cast with almost no light on it at all
];
const greyCases = [[0.1, 0.1, 0.1], [0.5, 0.5, 0.5], [0.7, 0.7, 0.7], [0.9, 0.9, 0.9], [0.97, 0.97, 0.97]];
const knobSets = [];
for (const hl of [-1, -0.5, 0, 0.5, 1])
for (const wh of [-1, 0, 1])
for (const sh of [-1, 0, 1])
for (const bl of [-1, 0, 1]) knobSets.push({ hl, wh, sh, bl });
for (const k of knobSets) {
for (const rgb of colourCases) {
const { out, clamped, o } = rebuild(rgb, k);
// The clamp is never what saves the pixel: the scale already landed the
// result inside the cube, which is the whole point of it.
for (let i = 0; i < 3; i++)
assert.ok(Math.abs(out[i] - clamped[i]) < 1e-12, `the cube clipped ${i} of ${rgb} at ${JSON.stringify(k)}`);
// Hue cannot move: every channel difference is scaled by the same number.
const dh = hueOf(clamped) - hueOf(rgb);
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `hue moved ${dh} for ${rgb} at ${JSON.stringify(k)}`);
// ...and the new luma is the ramp's, exactly (the differences sum to zero
// in this weighting, so the scale drops out of the luma).
close(lumaOf(clamped), o, `luma ${rgb} at ${JSON.stringify(k)}`);
}
// A grey is a grey: no difference to carry, so it lands on the ramp value and
// picks up no cast on the way.
for (const rgb of greyCases) {
const { clamped, o } = rebuild(rgb, k);
for (const c of clamped) close(c, o, `grey drifted at ${JSON.stringify(k)}`);
}
}
// Every knob on zero is the identity for the colour too, not just the luma.
for (const rgb of [...colourCases, ...greyCases]) {
const { clamped } = rebuild(rgb, {});
for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero');
}
// The chroma is CARRIED, not re-scaled: where the cube has room, the channel
// differences come out as they went in (this is where the doc's ratio diverged
// — it multiplied them by Luma_new / Luma_old, which over-saturates a lift and
// drains a pull in the half of the ramp above L = 0.5, the highlight zone).
const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped;
const grew = (carried[0] - carried[1]) / (0.7 - 0.55);
close(grew, 1, 'the chroma was re-scaled on a highlight lift');
console.log('highlight-knee-check ok'); console.log('highlight-knee-check ok');
+20 -6
View File
@@ -22,12 +22,26 @@ export interface ContinuousParamDef {
const sign = (v: number) => (v > 0 ? `+${v}` : String(v)); const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`; const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
// The bipolar develop knobs read -100..+100: the spec's own numbers (the panel
// draws every TONE and PRESENCE slider from -100 to 100, and the tone-mapping doc
// normalises that /100 on the way to its uniforms). The recipe keeps the ±10 unit
// every look has always been filed in, so the ×10 belongs to the slider and not
// to the store: a look saved, shared or shipped in DEFAULT_RECIPES before this
// still means exactly what it meant. A knob that is not bipolar — a temperature
// in kelvin, an exposure in stops, a grain size in percent, an amount that only
// goes up — keeps its own scale, because -100 of a percentage is nothing.
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites', 'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
const deepen = (def: ContinuousParamDef): ContinuousParamDef =>
HUNDRED.has(def.key)
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
: def;
export const PARAM_DEFS: { export const PARAM_DEFS: {
iq: ContinuousParamDef[]; iq: ContinuousParamDef[];
wb: ContinuousParamDef[]; wb: ContinuousParamDef[];
filters: ContinuousParamDef[]; filters: ContinuousParamDef[];
} = { } = {
iq: [ iq: ([
{ {
key: 'exposure', key: 'exposure',
label: 'EXPOSURE', label: 'EXPOSURE',
@@ -132,8 +146,8 @@ export const PARAM_DEFS: {
get: (a) => a.blacks ?? 0, get: (a) => a.blacks ?? 0,
set: (v) => ({ blacks: v }), set: (v) => ({ blacks: v }),
}, },
], ] as ContinuousParamDef[]).map(deepen),
wb: [ wb: ([
{ {
key: 'temperature', key: 'temperature',
label: 'COLOR TEMP', label: 'COLOR TEMP',
@@ -156,8 +170,8 @@ export const PARAM_DEFS: {
get: (a) => a.tint ?? 0, get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }), set: (v) => ({ tint: v }),
}, },
], ] as ContinuousParamDef[]).map(deepen),
filters: [ filters: ([
{ {
key: 'denoise', key: 'denoise',
label: 'NOISE REDUCTION', label: 'NOISE REDUCTION',
@@ -257,5 +271,5 @@ export const PARAM_DEFS: {
get: (a) => a.vignette ?? 0, get: (a) => a.vignette ?? 0,
set: (v) => ({ vignette: v }), set: (v) => ({ vignette: v }),
}, },
], ] as ContinuousParamDef[]).map(deepen),
}; };
+30 -11
View File
@@ -17,12 +17,11 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// of the ramp — and the 0.50 midpoint is in none of them: it is the one value // of the ramp — and the 0.50 midpoint is in none of them: it is the one value
// every one of the four leaves where it was. // every one of the four leaves where it was.
// //
// Colour: the luma takes the move and R, G, B keep their RATIO, which is the // Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the
// doc's `R_new = R_old * Luma_new / Luma_old`. One gain on all three channels // pixel lands on its new luma with the chroma it had, so the hue is untouched
// carries the hue through with the chroma, so a shadow lifted under a warm // and a grey stays grey. The doc reaches the same place with a ratio
// light does not drift toward white. The `cg` clamp below is the one guard kept // (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and
// on the ratio: past 1.35 it blows a dark saturated colour to white, and below // moves the hue the moment a channel clips; see the note on `k` below.
// 0.55 it collapses a colour to black.
// //
// These four masks are ADDED in the doc's own pseudo-shader, and measured that // These four masks are ADDED in the doc's own pseudo-shader, and measured that
// way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per // way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per
@@ -221,11 +220,31 @@ vec4 main(vec2 xy) {
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t)); o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t)); o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
o = clamp(o, 0.0, 1.0); o = clamp(o, 0.0, 1.0);
// Lightness takes the curve, hue stays: the colour difference is gained // Lightness takes the curve; the colour rides the difference. The pixel moves
// only part-way so darkening cannot collapse a colour to black and lifting // to its new luma and carries its own chroma with it — the three channel
// cannot blow a dark saturated colour out to white. // differences are scaled by ONE number, so the hue cannot move and a grey
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35); // cannot pick up a cast (a neutral has no difference to carry, and lands on
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0); // o exactly).
//
// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading
// of the same sentence, and it is what this pass used to do. It is exact —
// until the result stops fitting. Past 1.0 a channel clips, the differences
// stop being scaled together, and the hue goes with them: measured on the
// scratchpad probe (hl-variants.mjs), a skin tone at 24.0° came back at 48.0°
// at HIGHLIGHT +100, and a warm white at 37° at 57.4°. Under L = 0.5 the same
// ratio also multiplies whatever cast a near-black pixel had — x30 on a
// shadow with a hair of warmth, which is colour noise amplified, the reason
// the old arbitrary 0.55..1.35 clamp was there.
//
// So the scale is the chroma's own (1.0) and the only thing that pulls it back
// is the cube: a pixel with no room left gives up saturation instead of hue,
// and one that the curve has actually driven to 1.0 arrives at white.
float k = 1.0;
float hiC = max(max(rgb.r, rgb.g), rgb.b);
float loC = min(min(rgb.r, rgb.g), rgb.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
// Split tone (stock look): the shadows and the highlights may each carry // Split tone (stock look): the shadows and the highlights may each carry
// their own tint, so the two ends of the curve can drift opposite ways // their own tint, so the two ends of the curve can drift opposite ways
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
+38 -78
View File
@@ -26,7 +26,7 @@ import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe, type User } from './api'; import { api, type Photo, type SavedRecipe, type User } from './api';
import { TIERS } from './config/tiers'; import { TIERS } from './config/tiers';
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; 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 { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare';
import { writeJpegExif } from '../shared/utils/exifWrite'; import { writeJpegExif } from '../shared/utils/exifWrite';
import { 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 // 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) // visible next to the number. TEMP is the only one left on this path: TINT's row
// to magenta (+10) — the axis every WB tool labels G..M. // 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>> = { const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
temperature: temperatureSwatch, 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 // 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, // Continuous parameter chips: label + live value; amber text when edited,
// amber border while its ruler is open. // 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(() => { const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null; if (!openParam) return null;
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not // 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; 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`; 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 // 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 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 // 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 // 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 // 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 // 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 = ( const maskKnobRow = (
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze', key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze',
label: string label: string
): SliderDef => { ): SliderDef => {
const v = selMask?.[key] ?? 0; const v = selMask?.[key] ?? 0;
const wide = Math.round(v * 10);
return { return {
key: `mask-${key}`, key: `mask-${key}`,
label, label,
value: v, value: wide,
min: -10, min: -100,
max: 10, max: 100,
step: 1, step: 1,
display: v > 0 ? `+${v}` : String(v), display: wide > 0 ? `+${wide}` : String(wide),
edited: v !== 0, 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>), onReset: () => setMaskKnob({ [key]: 0 } as Partial<GradientMask>),
}; };
}; };
@@ -2531,25 +2507,25 @@ export function Workspace() {
{ {
key: 'mask-contrast', key: 'mask-contrast',
label: 'CONTRAST', label: 'CONTRAST',
value: selMask.contrast, value: Math.round(selMask.contrast * 10),
min: -10, min: -100,
max: 10, max: 100,
step: 1, 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, edited: selMask.contrast !== 0,
onChange: (v) => setMaskKnob({ contrast: Math.round(v) }), onChange: (v) => setMaskKnob({ contrast: Math.round(v) / 10 }),
onReset: () => setMaskKnob({ contrast: 0 }), onReset: () => setMaskKnob({ contrast: 0 }),
}, },
{ {
key: 'mask-saturation', key: 'mask-saturation',
label: 'SATURATION', label: 'SATURATION',
value: selMask.saturation, value: Math.round(selMask.saturation * 10),
min: -10, min: -100,
max: 10, max: 100,
step: 1, 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, edited: selMask.saturation !== 0,
onChange: (v) => setMaskKnob({ saturation: Math.round(v) }), onChange: (v) => setMaskKnob({ saturation: Math.round(v) / 10 }),
onReset: () => setMaskKnob({ saturation: 0 }), onReset: () => setMaskKnob({ saturation: 0 }),
}, },
maskKnobRow('highlights', 'HIGHLIGHT'), maskKnobRow('highlights', 'HIGHLIGHT'),
@@ -2757,12 +2733,16 @@ export function Workspace() {
} }
case 'light': case 'light':
// LIGHT is the one tab whose knobs are not chips: the sidebar // 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 // (DevelopPanels) puts every one of them on screen at once — WB's two,
// two things that are NOT knobs stay here — AUTO, which is an action // the tonal range, presence, detail and effects — so what stays in this
// (see autoTune), and TONE CURVE, which opens the graph on the photo // row is what is NOT a knob. AUTO, which is an action (see autoTune);
// (ToneCurvePanel) rather than a ruler in the last column. It glows amber // TONE CURVE, which opens the graph on the photo (ToneCurvePanel) rather
// once the graph is off the diagonal, which is the only place the curve // than a ruler in the last column, and glows amber once the graph is off
// is reported. // 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 [ return [
{ key: 'auto', label: 'AUTO', onClick: () => void autoTune() }, { key: 'auto', label: 'AUTO', onClick: () => void autoTune() },
{ {
@@ -2772,27 +2752,10 @@ export function Workspace() {
amberValue: curveIsActive(recipe.adjustments.toneCurve), amberValue: curveIsActive(recipe.adjustments.toneCurve),
onClick: () => toggleParam('curve'), onClick: () => toggleParam('curve'),
}, },
]; // FX's tools are not looks: each is a strip of its own, opened from the
case 'wb': // row. FIX repairs a speck of dust, MOSAIC hides a patch, GRADIENT MASK
return [ // draws a shape; the strip's own chip carries the name and glows amber
{ // while there is something inside it to clear.
// 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 [
{ {
key: 'fix', key: 'fix',
label: 'FIX', label: 'FIX',
@@ -2818,13 +2781,10 @@ export function Workspace() {
}, },
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
// GRAIN is a strip of its own — amount, size, and the count they add up // 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 // to — so it is one chip here and its two knobs are rows of DETAIL &
// chip keeps the place the knob had in this row. // EFFECTS.
...PARAM_DEFS.filters.flatMap((p): ChipDef[] => groupChip('grain'),
p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p])
),
]; ];
}
case 'hsl': { case 'hsl': {
// The mixer is PRO: the picker, the eight bands and the three // 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 // image-wide knobs all come with the account, so a guest is offered the
-2
View File
@@ -58,8 +58,6 @@ export const en: Dict = {
'tab.favorited': 'FAVORITED', 'tab.favorited': 'FAVORITED',
'tab.photos': 'MY PHOTOS', 'tab.photos': 'MY PHOTOS',
'tab.light': 'LIGHT', 'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
'tab.hsl': 'HSL', 'tab.hsl': 'HSL',
'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO', 'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO',
'tab.frame': 'FRAME', 'tab.frame': 'FRAME',
-2
View File
@@ -71,8 +71,6 @@ export const vi = {
'tab.favorited': 'FAVORITED', 'tab.favorited': 'FAVORITED',
'tab.photos': 'ẢNH CỦA TÔI', 'tab.photos': 'ẢNH CỦA TÔI',
'tab.light': 'LIGHT', 'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
'tab.hsl': 'HSL', 'tab.hsl': 'HSL',
'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH', 'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH',
'tab.frame': 'FRAME', 'tab.frame': 'FRAME',
+6 -7
View File
@@ -1,11 +1,12 @@
import type { MsgKey } from '../i18n/vi'; import type { MsgKey } from '../i18n/vi';
import { useI18n } from '../i18n/I18nProvider'; import { useI18n } from '../i18n/I18nProvider';
// The app's eight tabs. WB leads LIGHT — white balance is the first thing the // The app's eight tabs. LIGHT is the develop column and holds every knob of the
// eye corrects, and the web rail draws them in the order the panel opens. The // picture — white balance, the tonal range, presence and the effects — so WB and
// rail is a single column on desktop and becomes the bottom bar under 860px // FX, which used to be tabs of their own, are panels of it now. The rail is a
// (see styles/app.css). // single column on desktop and becomes the bottom bar under 860px (see
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'hsl' | 'frame' | 'save' | 'create'; // 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 // 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. // 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: 'presets', label: 'tab.presets', glyph: '◉' },
{ id: 'favorited', label: 'tab.favorited', glyph: '★' }, { id: 'favorited', label: 'tab.favorited', glyph: '★' },
{ id: 'photos', label: 'tab.photos', glyph: '▤' }, { id: 'photos', label: 'tab.photos', glyph: '▤' },
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
{ id: 'light', label: 'tab.light', 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. // The HSL mixer sits with the colour tabs: it edits one hue band at a time.
{ id: 'hsl', label: 'tab.hsl', glyph: '◍' }, { id: 'hsl', label: 'tab.hsl', glyph: '◍' },
{ id: 'frame', label: 'tab.frame', glyph: '▣' }, { id: 'frame', label: 'tab.frame', glyph: '▣' },