web: the three mixer knobs move the whole image, a frame chip toggles itself, ROTATE lets go when STRAIGHTEN steers

HUE, SAT and LUM leave the colour row: behind an IMAGE divider they are
hslHue/hslSat/hslLum, seeded into the shader's band accumulator at full
weight for every hue, while the eight band chips keep picking which colour
the panel on the photo edits. The image lightness term stays ungated so a
frame drained to grey by -SAT still answers +LUM.

FRAME loses its NO FRAME chip: pressing the frame already on the photo
takes it off.

ROTATE's quarter turns stop lighting the moment the fine angle leaves 0,
so the strip shows which of the two is steering the photo.
This commit is contained in:
2026-09-18 18:45:41 +07:00
parent 27035c4acb
commit 4057566a14
4 changed files with 73 additions and 17 deletions
+6
View File
@@ -105,6 +105,12 @@ export interface ColorAdjustments {
// Selective colour by hue band. Only the bands the user actually moved are
// present, so an untouched mixer costs nothing and an old recipe stays valid.
hslBands?: Partial<Record<HslBandId, HslBand>>;
// The mixer's overall move: one hue turn, one saturation scale and one
// lightness offset for the WHOLE image, applied to every hue before the band
// edits above. -10..+10 like every other knob; absent/0 = no move at all.
hslHue?: number;
hslSat?: number;
hslLum?: number;
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
}
@@ -20,6 +20,9 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
hslHue: 0,
hslSat: 0,
hslLum: 0,
exposureCompensation: 0,
};
+27 -4
View File
@@ -48,6 +48,11 @@ import { HSL_BANDS, hslBandGaps } from './colorUtils';
// hue — so unlike everything else above it, the eight bands are not a
// global move and cannot live in the colour matrix. See the band block at
// the foot of TONE_SKSL.
// gh/gs/gl - the mixer's overall move: the same three quantities for the WHOLE
// image, so they are simply the starting value of the per-band
// accumulator and every hue gets them at full weight. The lightness one
// is not gated by saturation (unlike the bands'), so a frame drained to
// grey by -SAT still answers +LUM.
// The eight band lines of TONE_SKSL's mixer, generated from HSL_BANDS so the
// anchors and the gaps in the shader are the same numbers the chips are built
// from. Each line reads its own band's three values with a CONSTANT index —
@@ -80,6 +85,9 @@ uniform float hslOn;
uniform float hslH[8];
uniform float hslS[8];
uniform float hslL[8];
uniform float gh;
uniform float gs;
uniform float gl;
// sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are
// named after: a hue shift must not change how light a colour is, and a
// lightness move must not change its hue, which is exactly what scaling RGB
@@ -202,10 +210,14 @@ vec4 main(vec2 xy) {
vec3 hsl = rgb2hsl(rgb);
float gate = smoothstep(0.0, 0.08, hsl.y);
float hd = hsl.x * 360.0;
vec3 acc = vec3(0.0);
// The overall move is the seed: every hue gets its turn and its saturation
// scale at full weight, and the bands add their own share on top. The
// lightness term is added below UNGATED, so it still lifts a colour that a
// -SAT has already drained to grey.
vec3 acc = vec3(gh, gs, 0.0) * gate;
${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0);
hsl.z = clamp(hsl.z + acc.z * 0.25, 0.0, 1.0);
hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0);
rgb = hsl2rgb(hsl);
}
return vec4(clamp(rgb, 0.0, 1.0), c.a);
@@ -289,10 +301,13 @@ export interface ToneUniforms {
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
cc: number; // 0..1 Color Chrome depth (0 = 'none')
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
hslOn: number; // 1 when any band is moved (0 skips the whole mixer)
hslOn: number; // 1 when any band or the overall move is set (0 skips the mixer)
hslH: number[]; // 8 × -1..1 per band, in HSL_BANDS order (±30° of hue at full)
hslS: number[]; // 8 × -1..1 per band (saturation scale, -1 = grey)
hslL: number[]; // 8 × -1..1 per band (additive lightness, ±0.25 at full)
gh: number; // -1..1 whole-image hue turn (±30° at full)
gs: number; // -1..1 whole-image saturation scale
gl: number; // -1..1 whole-image lightness offset (±0.25 at full, ungated)
}
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
@@ -357,6 +372,11 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
}
// A monochrome stock has no hue to be selective about.
if (!colour) hslOn = 0;
// The mixer's overall move, which every hue receives at full weight.
const gh = tenth(adj.hslHue);
const gs = tenth(adj.hslSat);
const gl = tenth(adj.hslLum);
if (colour && (gh || gs || gl)) hslOn = 1;
return {
dr,
hl: hl + (film.hl ?? 0),
@@ -372,6 +392,9 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
hslH,
hslS,
hslL,
gh,
gs,
gl,
};
}
@@ -381,7 +404,7 @@ export function toneUniformArray(u: ToneUniforms): number[] {
return [
u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL,
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
];
}
+37 -13
View File
@@ -612,6 +612,16 @@ export function Workspace() {
[recipe.adjustments.hslBands, hslBand, setAdjustmentOnce]
);
// The mixer's overall move: one door for the three image-wide knobs, so the
// ruler and the FIELDS it writes are the same values whatever opened it.
const setHslGlobal = useCallback(
(which: 0 | 1 | 2, v: number) => {
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
setAdjustmentOnce({ [key]: Math.max(-10, Math.min(10, Math.round(v))) });
},
[setAdjustmentOnce]
);
// --- crop -----------------------------------------------------------------
// Picking a ratio only opens the frame; the crop is committed by APPLY.
const pickCrop = useCallback(
@@ -1143,17 +1153,18 @@ export function Workspace() {
// photo (StraightenRuler, drawn by ImageStage) so the angle is read off the
// picture it is levelling; nothing opens here.
if (openParam === 'straighten') return null;
// The mixer's three knobs are not in PARAM_DEFS: they read and write one
// band of hslBands rather than a flat field of adjustments.
// The mixer's three knobs are not in PARAM_DEFS: they read and write the
// image's own move rather than a flat field of adjustments, and unlike the
// panel on the photo they are not tied to the band the mixer is aimed at.
if (openParam.startsWith('hsl.')) {
const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2;
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const value = band[which];
const set = (v: number) => setBandKnob(which as 0 | 1 | 2, v);
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
const value = own[which];
const set = (v: number) => setHslGlobal(which as 0 | 1 | 2, v);
const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM';
return {
key: openParam,
label: `${name} ${HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}`,
label: `${name} IMAGE`,
value,
min: -10,
max: 10,
@@ -1189,7 +1200,7 @@ export function Workspace() {
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce, setBandKnob, hslBand, straighten, setStraightenOnce]);
}, [openParam, recipe.adjustments, setAdjustmentOnce, setHslGlobal, straighten, setStraightenOnce]);
// The eyedropper's report: print what was read, point the ruler at that
// colour's band, hang the mixer's panel on the spot it came from, and put the
@@ -1459,7 +1470,9 @@ export function Workspace() {
});
const framesRow = (): ChipDef[] => [
...FRAMES.map((frame) => ({
// NO FRAME is not a chip of its own: clicking the frame already on the
// photo takes it off again, so the row carries one entry per frame.
...FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({
key: `fr:${frame.id}`,
label: t(frame.label),
active: frameId === frame.id,
@@ -1467,7 +1480,7 @@ export function Workspace() {
setOpenParam(null);
setOpenGroup(null);
remember();
setFrameId(frame.id);
setFrameId(frameId === frame.id ? 'none' : frame.id);
},
})),
groupChip('crop'),
@@ -1588,13 +1601,16 @@ export function Workspace() {
case 'hsl': {
// PICK arms the eyedropper; the eight band chips choose which band the
// ruler edits, and the ruler itself is opened by the three knobs below.
const cur = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
// Those three are the image's own move, not the band's: the band chips
// pick which colour the panel on the photo edits, while HUE/SAT/LUM
// below the divider move every hue in the frame at once.
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
const knob = (key: string, label: string, i: number): ChipDef => ({
key,
label,
value: cur[i] > 0 ? `+${cur[i]}` : String(cur[i]),
value: own[i] > 0 ? `+${own[i]}` : String(own[i]),
active: openParam === key,
amberValue: openParam !== key && cur[i] !== 0,
amberValue: openParam !== key && own[i] !== 0,
onClick: () => toggleParam(key),
});
return [
@@ -1612,6 +1628,9 @@ export function Workspace() {
onClick: () => setHslBand(b.id),
};
}),
// The divider keeps the three image-wide sliders out of the colour
// row: the row above picks a colour, these three move them all.
{ key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} },
knob('hsl.h', 'HUE', 0),
knob('hsl.s', 'SAT', 1),
knob('hsl.l', 'LUM', 2),
@@ -1634,7 +1653,12 @@ export function Workspace() {
const stripChips = (g: Exclude<GroupKey, 'wm'>): ChipDef[] => [
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick),
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) =>
// A hand-straightened frame is no longer on a clean quarter turn: the
// angle chips let go the moment the fine angle leaves 0, so the strip
// shows which of the two is actually steering the photo.
g === 'rotate' && straighten !== 0 && c.key !== 'rotate:reset' ? { ...c, active: false } : c
),
// The frame is on the photo but the crop is not in force until APPLY.
...(g === 'crop' && cropEditing
? [