import { ColorAdjustments } from '../types'; // Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). // SkSL runtime effect over a child image shader, driven by luma so hue is // preserved (single multiplicative factor per pixel). // // Soft-knee masks keep the knobs independent, like camera tone curves: // hl affects ONLY the bright end (knee 0.80..1.00), // sh affects ONLY the dark end (knee 0.00..0.30), // leaving the whole 0.30..0.80 mid/upper band untouched. Earlier knees // (0.40..0.95 / 0.05..0.55, then 0.55..1.0 / 0.0..0.45, then 0.70..1.0) still // reached mid/upper grays, so HL read as a global gray/brightness change // instead of isolating the bright end — on-device testing confirmed it. // // HL is multiplicative on BOTH signs so +/− act on the same region: // + scales 0.85+ up toward white (natural clip at 1.0), − scales them down. // A + additive lift using (1-t) dies at t→1.0 and never touched the specular // end — that asymmetry made +HL look like a mid-tone push, not a highlight // change. (SH keeps its original form; additive + worked there.) // // dr - DR strength 0..1: lifts shadows slightly and rolls highlights // (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. // 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 // clip a plain Saturation boost causes. All-zero still = no pass. export const TONE_SKSL = ` uniform shader src; uniform float dr; uniform float hl; uniform float sh; uniform float vib; vec4 main(vec2 xy) { vec4 c = src.eval(xy); float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); float hlMask = smoothstep(0.80, 1.0, t); float shMask = 1.0 - smoothstep(0.0, 0.30, t); // NOTE: never name a local 'out' — it is a reserved SkSL qualifier. float o = t; // Highlight: bright end only, multiplicative on both signs (symmetric region). if (hl >= 0.0) { o *= 1.0 + hl * 0.30 * hlMask; } else { o *= 1.0 + hl * 0.45 * hlMask; } // Shadow: dark end only. if (sh >= 0.0) { o += sh * 0.80 * shMask * (1.0 - t); } else { o *= 1.0 + sh * 0.80 * shMask; } // Dynamic range: gentle shadow lift + highlight roll (protect brights). o += dr * 0.12 * shMask * (1.0 - t); o -= dr * 0.18 * hlMask * t; float f = clamp(o / max(t, 0.0004), 0.0, 4.0); vec3 rgb = clamp(c.rgb * f, 0.0, 1.0); // Vibrance: push the LESS-saturated pixels harder than the vivid ones. float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); float mx = max(max(rgb.r, rgb.g), rgb.b); float mn = min(min(rgb.r, rgb.g), rgb.b); float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0; float kv = 1.0 + vib * 0.75 * (1.0 - chroma); return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a); } `; export interface ToneUniforms { // All zero → no tone adjustment needed (caller can skip the shader pass). dr: number; // 0..1 hl: number; // -1..1 (adjustments.highlight / 10) sh: number; // -1..1 (adjustments.shadow / 10) vib: number; // -1..1 (adjustments.vibrance / 10) } export function getToneUniforms(adj: ColorAdjustments): ToneUniforms { const drRaw = adj.dynamicRange ?? 'auto'; 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 vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10)); return { dr, hl, sh, vib }; } export function toneIsActive(u: ToneUniforms): boolean { return u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0; }