diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 7358792..a0a6fe6 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -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>; + // 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. } diff --git a/docker/frontend/shared/utils/defaultRecipes.ts b/docker/frontend/shared/utils/defaultRecipes.ts index 700978f..8fdd7c9 100644 --- a/docker/frontend/shared/utils/defaultRecipes.ts +++ b/docker/frontend/shared/utils/defaultRecipes.ts @@ -20,6 +20,9 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = { colorChromeBlue: 'none', dynamicRange: 'auto', sharpening: 0, + hslHue: 0, + hslSat: 0, + hslLum: 0, exposureCompensation: 0, }; diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index e39028d..d617e81 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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, ]; } diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index bc7572e..d73361a 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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): 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 ? [