import { BaseFilter, ColorAdjustments } from '../types'; // Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). // SkSL runtime effect over a child image shader. // // Lightness/chroma split: the curve moves the luma and the colour difference // (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B // by one gain keeps the *ratio* but crushes absolute chroma — that is what // turned saturated blues black under -SH and bright colours grey under -HL. // // Wide, soft knees so the knobs reach like a tone curve instead of biting only // at the very ends: HL rides only the top (0.65..1.00) so it leaves the greys // and midtones alone (a 0.45 knee dragged a mid-grey down) while SH rides the // lower half (0.00..0.55), and the 0.50 midpoint never moves. Earlier narrow // knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo // without true whites or blacks. // // Both knobs are pure additive shifts of the luma. That keeps the curve // monotonic (steepest knee slope 4.29/unit * 0.22 = 0.94, and the two knees // never overlap), so a brighter input can never come out darker. The earlier // multiplicative form was NOT monotonic: with hl=-1 a grey 0.73 came out // darker than 0.80. // // 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. // shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied // to the shadows and/or the highlights only. A 4x5 colour matrix cannot do // this — it is one linear map, so any cast it applies must also hit the // midtones and the opposite end. Classic Neg wants green/cyan shadows with // warm highlights at once, so the stock ships these values and the pass // stays active for it even when every user knob is 0. All-zero still = no // pass. export const TONE_SKSL = ` uniform shader src; uniform float dr; uniform float hl; uniform float sh; uniform float vib; uniform float shTr; uniform float shTg; uniform float shTb; uniform float hlTr; uniform float hlTg; uniform float hlTb; uniform float cc; uniform float ccb; vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); float hlMask = smoothstep(0.65, 1.00, t); float shMask = 1.0 - smoothstep(0.00, 0.55, t); // NOTE: never name a local 'out' — it is a reserved SkSL qualifier. float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask; // Dynamic range: gentle shadow lift + highlight roll (protect brights). o += dr * 0.12 * shMask * (1.0 - t); o -= dr * 0.18 * hlMask * 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 // cannot blow a dark saturated colour out to white. float cg = clamp(o / max(t, 0.0004), 0.55, 1.35); rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0); // 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 // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0); // Color Chrome / Color Chrome FX Blue: the two stock-dialed colour effects // DEEPEN what is already chromatic and leave neutrals exactly where they are // (Fuji: "deeper tone in highly saturated colour"; FX Blue does it for the // blue/cyan side only). Both therefore need per-pixel chroma — a 4x5 colour // matrix is one linear map, so any gain it applies also moves greys, and a // blue-only gain drags the whole white point. float mxc = max(max(rgb.r, rgb.g), rgb.b); float mnc = min(min(rgb.r, rgb.g), rgb.b); // Chroma ratio with a small floor: a near-black pixel with a hair of cast // has ratio 1.0 but no colour to deepen, and must stay put. float ccChroma = (mxc - mnc) / max(mxc, 0.10); // Color Chrome rides the chroma itself: a muted colour barely moves, a vivid // one gains density. The 0.25 knee keeps skin, haze and pastels untouched. float ccMask = cc * smoothstep(0.25, 0.85, ccChroma); // FX Blue: only where blue clearly leads red AND green (so magenta/purple // stay out), and richest in a bright blue — a dark blue has no tonality left // to deepen. float ccbBlue = clamp((rgb.b - rgb.r) * 2.0, 0.0, 1.0) * clamp((rgb.b - 0.5 * (rgb.r + rgb.g) + 0.05) * 3.0, 0.0, 1.0); float ccbMask = ccb * ccbBlue * smoothstep(0.15, 0.60, ccChroma) * smoothstep(0.20, 0.70, t); float deep = clamp(ccMask + ccbMask, 0.0, 1.0); // Density = lightness down with the colour difference riding along, so hue is // preserved and the colour cannot collapse toward black (same reason the tone // curve above keeps chroma). A touch of chroma is given up as it deepens. float l3 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); rgb = clamp(vec3(l3 * (1.0 - 0.28 * deep)) + (rgb - vec3(l3)) * (1.0 - 0.10 * deep), 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); } `; // Bright Pass Filter for the HDF EFFECT pass (HDF), SkSL over a child image // shader — the pattern TONE_SKSL above already proved on device. // // Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark // *itself*: a saturated blue (B = 1.0) came out of it fully lit, so a dark blue // shadow bloomed and a dark saturated colour smeared its hue into the darks. // Photoshop's Bright Pass filters on the LUMINANCE instead: one knee decides how // much light a pixel carries, and one gain scales all three channels, so below // the knee the output is exactly 0.0 (Screen against black = no-op, the shadows // are untouched) and above it every channel keeps its ratio — the hue cannot // drift, only the brightness blooms. // // Knee t0..t1 = 0.45..0.75. The web demo plays the effect up, so the knee sits // lower and wider than the phone's 0.55..0.85: the bloom now catches the bright // end of the midtones (a lit face, a window) instead of only true speculars, // which is what makes it read on a photo that has no blown white. export const GLOW_T0 = 0.45; export const GLOW_T1 = 0.75; export const GLOW_SKSL = ` uniform shader src; uniform float t0; uniform float t1; vec4 main(vec2 xy) { vec4 c = src.eval(xy); float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722)); return vec4(c.rgb * smoothstep(t0, t1, luma), c.a); } `; // Flat uniform buffer for `makeShaderWithChildren` — same order as GLOW_SKSL's // declarations (t0, t1). export function glowUniformArray(): number[] { 'worklet'; return [GLOW_T0, GLOW_T1]; } // Same values for the declarative path, which indexes uniforms by // NAME (a flat array is only valid for the JS makeShaderWithChildren API). export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 }; // CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4 // builds with MakeMatrixConvolution, re-expressed as a plain shader because RN // Skia 2.6 exposes no convolution image filter to the declarative JSX writer. // `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so // the preview, the camera worklet and the file all sharpen at the same radius. export const CLARITY_SKSL = ` uniform shader src; uniform float a; uniform float2 px; vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec4 s = src.eval(xy + float2(0.0, -px.y)) + src.eval(xy + float2(0.0, px.y)) + src.eval(xy + float2(-px.x, 0.0)) + src.eval(xy + float2( px.x, 0.0)); return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a); } `; // Named uniforms for , same names as CLARITY_SKSL declares. export function clarityUniforms(a: number, pxX: number, pxY: number) { return { a, px: [pxX, pxY] }; } 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) shT: [number, number, number]; // shadow split-tone RGB bias, -1..1 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') } // Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix: // Classic Neg splits its tone (green-cyan darks / warm brights) and Classic // Chrome crushes the shadows hard while muting colour. Those two parts live // here instead of in the 4x5 matrix, which cannot move one end of the curve // without also moving the other. const FILM_TONE: Partial>> = { 'classic-chrome': { sh: -0.28 }, 'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] }, // Acros. A black-and-white stock IS its grey ramp, so this entry only shapes // the two ENDS and leaves the middle an identity: a smooth shadow toe that // reaches a true black (no film-base lift, no flat grey wash) and a highlight // shoulder that stops just short of white instead of clipping a cloud to // paper. Mid-tones see neither mask, so the ramp keeps every step the matrix // handed over — which is what 'deep black' costs in a colour stock and does // not have to cost here. // Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at // ~5% and -0.05 trims the top ~1%. monochrome: { sh: -0.12, hl: -0.05 }, }; export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): 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)); const film = (baseFilter && FILM_TONE[baseFilter]) || {}; const shT: [number, number, number] = film.shT ?? [0, 0, 0]; const hlT: [number, number, number] = film.hlT ?? [0, 0, 0]; // Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a // monochrome look, so both are forced off there: the effect is colour-only // (the preview/export matrix skips them for monochrome for the same reason). const colour = baseFilter !== 'monochrome'; const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) => !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45; const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) => !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5; return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT, cc: chromeDepth(adj.colorChrome), ccb: blueDepth(adj.colorChromeBlue), }; } // 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]; } export function toneIsActive(u: ToneUniforms): boolean { return ( u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0 || u.shT[0] !== 0 || u.shT[1] !== 0 || u.shT[2] !== 0 || u.hlT[0] !== 0 || u.hlT[1] !== 0 || u.hlT[2] !== 0 || u.cc !== 0 || u.ccb !== 0 ); }