diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 14d70d1..9dd5fdc 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -80,6 +80,8 @@ export interface ColorAdjustments { wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens) shadow: number; // -10 to +10 (+ lifts shadows, - deepens) + whites?: number; // -10 to +10 (white point: the very top of the tone curve) + blacks?: number; // -10 to +10 (black point: the very toe of the tone curve) denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back) clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index f32d0a0..28ed06f 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -25,6 +25,9 @@ import { BaseFilter, ColorAdjustments } from '../types'; // (Fuji extended DR); 0/auto/DR100 = no extra curve. // hl - highlight -1..1: + lifts toward white, - rolls the bright side down. // sh - shadow -1..1: + lifts the dark side, - deepens it. +// wh - white point -1..1: + pushes the top of the ramp toward white, - rolls +// it down. Cubic end-weight, so it never fights the HL knee. +// bl - black point -1..1: + lifts the toe (faded black), - crushes it. // vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader // (rather than the colour matrix) because it needs per-pixel chroma: // already-vivid pixels move least, so skins/skies deepen without the neon @@ -41,6 +44,8 @@ uniform shader src; uniform float dr; uniform float hl; uniform float sh; +uniform float wh; +uniform float bl; uniform float vib; uniform float shTr; uniform float shTg; @@ -61,6 +66,14 @@ vec4 main(vec2 xy) { // Dynamic range: gentle shadow lift + highlight roll (protect brights). o += dr * 0.12 * shMask * (1.0 - t); o -= dr * 0.18 * hlMask * t; + // White/black point. The HL/SH knees above already spend the slope budget + // (each just under 1.0), so these two use cubic end-weights instead of + // another smoothstep: only the last/first stretch of the ramp moves, and the + // derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve + // can never invert. + lifts that end (faded blacks / brighter whites), + // - digs it in (true black / rolled white). + o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t); + o += wh * 0.18 * t * t * t; o = clamp(o, 0.0, 1.0); // Lightness takes the curve, hue stays: the colour difference is gained // only part-way so darkening cannot collapse a colour to black and lifting @@ -176,6 +189,8 @@ export interface ToneUniforms { dr: number; // 0..1 hl: number; // -1..1 (adjustments.highlight / 10) sh: number; // -1..1 (adjustments.shadow / 10) + wh: number; // -1..1 (adjustments.whites / 10 — white point) + bl: number; // -1..1 (adjustments.blacks / 10 — black point) vib: number; // -1..1 (adjustments.vibrance / 10) shT: [number, number, number]; // shadow split-tone RGB bias, -1..1 hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1 @@ -208,6 +223,8 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300; const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10)); const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10)); + const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10)); + const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10)); const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10)); const film = (baseFilter && FILM_TONE[baseFilter]) || {}; const shT: [number, number, number] = film.shT ?? [0, 0, 0]; @@ -224,6 +241,8 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), + wh, + bl, vib, shT, hlT, @@ -235,7 +254,7 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): // Flat uniform buffer for `makeShaderWithChildren` / `` — the // order must match TONE_SKSL's declarations. export function toneUniformArray(u: ToneUniforms): number[] { - return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb]; + 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]; } export function toneIsActive(u: ToneUniforms): boolean { @@ -243,6 +262,8 @@ export function toneIsActive(u: ToneUniforms): boolean { u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || + u.wh !== 0 || + u.bl !== 0 || u.vib !== 0 || u.shT[0] !== 0 || u.shT[1] !== 0 || diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 309d5de..1394d5e 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -193,6 +193,12 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } font-weight: 600; } .create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +/* Collapsible groups: native
, so only the caret is ours. */ +.create-sec { display: flex; flex-direction: column; gap: 6px; } +.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; } +.create-sec > summary::-webkit-details-marker { display: none; } +.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; } +.create-sec[open] > summary::before { transform: rotate(90deg); } .create-num { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .create-num span { font-size: 10px; letter-spacing: 0.12em; color: var(--text-dim); font-weight: 600; } .create-num.wide { grid-column: 1 / -1; } diff --git a/docker/frontend/src/ui/RecipeCreatePanel.tsx b/docker/frontend/src/ui/RecipeCreatePanel.tsx index a1d61aa..7fbc700 100644 --- a/docker/frontend/src/ui/RecipeCreatePanel.tsx +++ b/docker/frontend/src/ui/RecipeCreatePanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { ChipRow, type ChipDef } from './ChipColumn'; import { useI18n } from '../i18n/I18nProvider'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../../shared/utils/defaultRecipes'; @@ -51,6 +51,17 @@ const wbKeyOf = (s: ColorAdjustments) => return p.temperature === s.temperature && p.tint === s.tint; }) ?? 'custom'; +// One collapsible group. A native
— no state, no library, and the +// browser keeps the open/closed flag itself (the `open` prop only seeds it). +function Fold({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + export function RecipeCreatePanel({ currentBase, current, @@ -89,6 +100,9 @@ export function RecipeCreatePanel({ wbKey: wbKeyOf(s), highlight: String(s.highlight), shadow: String(s.shadow), + whites: String(s.whites ?? 0), + blacks: String(s.blacks ?? 0), + exposure: String(s.exposure), color: String(s.saturation), nr: String(s.denoise), sharp: String(s.sharpening), @@ -108,6 +122,9 @@ export function RecipeCreatePanel({ const [wbBlue, setWbBlue] = useState(String(seedAdj.wbBlue ?? 0)); const [fHigh, setFHigh] = useState(String(seedAdj.highlight)); const [fShadow, setFShadow] = useState(String(seedAdj.shadow)); + const [fWhites, setFWhites] = useState(String(seedAdj.whites ?? 0)); + const [fBlacks, setFBlacks] = useState(String(seedAdj.blacks ?? 0)); + const [fExposure, setFExposure] = useState(String(seedAdj.exposure)); const [fColor, setFColor] = useState(String(seedAdj.saturation)); const [fNr, setFNr] = useState(String(seedAdj.denoise)); const [fSharp, setFSharp] = useState(String(seedAdj.sharpening)); @@ -125,6 +142,9 @@ export function RecipeCreatePanel({ setWbKey(v.wbKey); setFHigh(v.highlight); setFShadow(v.shadow); + setFWhites(v.whites); + setFBlacks(v.blacks); + setFExposure(v.exposure); setFColor(v.color); setFNr(v.nr); setFSharp(v.sharp); @@ -146,6 +166,9 @@ export function RecipeCreatePanel({ wbBlue: int(wbBlue, 0, -9, 9), highlight: int(fHigh, simAdj.highlight, -10, 10), shadow: int(fShadow, simAdj.shadow, -10, 10), + whites: int(fWhites, simAdj.whites ?? 0, -10, 10), + blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10), + exposure: int(fExposure, simAdj.exposure, -10, 10), saturation: int(fColor, simAdj.saturation, -10, 10), denoise: int(fNr, simAdj.denoise, -10, 10), sharpening: int(fSharp, simAdj.sharpening, -10, 10), @@ -182,7 +205,7 @@ export function RecipeCreatePanel({ const { baseFilter, adjustments } = build(); onPreview({ baseFilter, adjustments }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sim, dr, grain, cc, ccb, wbKey, wbRed, wbBlue, fHigh, fShadow, fColor, fNr, fSharp, fClarity, fEv]); + }, [sim, dr, grain, cc, ccb, wbKey, wbRed, wbBlue, fHigh, fShadow, fWhites, fBlacks, fColor, fNr, fSharp, fClarity, fExposure, fEv]); const opts = ( prefix: string, @@ -232,71 +255,80 @@ export function RecipeCreatePanel({ /> - SIMULATION - ({ - key: s.id, - label: s.name, - active: sim === s.baseFilter, - onClick: () => selectSim(s.baseFilter), - }))} - /> + + ({ + key: s.id, + label: s.name, + active: sim === s.baseFilter, + onClick: () => selectSim(s.baseFilter), + }))} + /> + - DYNAMIC RANGE - + + +
{[ { label: 'HIGHLIGHT', value: fHigh, set: setFHigh }, { label: 'SHADOW', value: fShadow, set: setFShadow }, + { label: 'WHITE', value: fWhites, set: setFWhites }, + { label: 'BLACK', value: fBlacks, set: setFBlacks }, { label: 'COLOR', value: fColor, set: setFColor }, { label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' }, { label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' }, { label: 'CLARITY', value: fClarity, set: setFClarity }, - { label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' }, + { label: 'EXPOSURE', value: fExposure, set: setFExposure }, + { label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' }, ].map((f) => num(f.label, f.value, f.set, f.ph))}
- GRAIN EFFECT - + + + - COLOR CHROME EFFECT - + + + - COLOR CHROME EFFECT BLUE - + + + - WHITE BALANCE - ({ v: w.key, d: w.label })), wbKey, setWbKey), - ...(wbKey === 'custom' - ? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }] - : []), - ]} - /> - -
- WB SHIFT - RED - setWbRed(e.target.value)} + + ({ v: w.key, d: w.label })), wbKey, setWbKey), + ...(wbKey === 'custom' + ? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }] + : []), + ]} /> - BLUE - setWbBlue(e.target.value)} - /> -
+ +
+ WB SHIFT + RED + setWbRed(e.target.value)} + /> + BLUE + setWbBlue(e.target.value)} + /> +
+
ISO