import { BaseFilter, ColorAdjustments } from '../types'; import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // 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 the top (0.50..1.00) so it leaves the greys alone // (a knee that started lower dragged a mid-grey down) while SH rides the lower // half (0.00..0.55), and the 0.50 midpoint never moves. // // HL is Lightroom's highlight RECOVERY and it is weighted by the headroom that is // left, (1 - t): the move falls to zero as t reaches pure white, so a blown sky, // a lamp or a specular can be pulled back without the white going grey. The pull // peaks around t = 0.79 at 0.13 of the ramp for a full -10 — the edge of a cloud, // which is the detail there is to get back (measured, and checked for // monotonicity over the whole -1..1 rectangle of the two knobs). // // Both knobs are additive shifts of the luma. That keeps the curve monotonic // (worst slope +0.003 at t = 0.99 with HL +10 and SH -10, and the two knees // barely 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. // wh - white point -1..1: per channel, from the WB tab. + lifts the shoulder, // - rolls it down. Cubic weight in the channel's own value, so the // brighter channel of a highlight moves most. // bl - black point -1..1: per channel. + lifts the toe (faded black), - // crushes it; the darker channel of a shadow moves most. // 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. // hslOn/hslH/hslS/hslL - the selective-colour mixer: eight hue bands, each with // a hue shift, a saturation scale and a lightness offset (-1..1, from the // -10..10 knobs). Which pixels a band owns is decided HERE, per pixel, by // 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 — // SkSL indexes uniform arrays by constant only, which is why this is unrolled // rather than looped. const BAND_BLOCK = hslBandGaps() .map( (b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate; acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n` ) .join(''); export const TONE_SKSL = ` 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; uniform float shTb; uniform float hlTr; uniform float hlTg; uniform float hlTb; uniform float cc; uniform float ccb; 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 // does wrong. vec3 rgb2hsl(vec3 c) { float mx = max(max(c.r, c.g), c.b); float mn = min(min(c.r, c.g), c.b); float l = (mx + mn) * 0.5; float d = mx - mn; if (d < 0.00001) return vec3(0.0, 0.0, l); float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn); float h; if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); else if (mx == c.g) h = (c.b - c.r) / d + 2.0; else h = (c.r - c.g) / d + 4.0; return vec3(h / 6.0, s, l); } float hueChannel(float p, float q, float t) { t = fract(t); if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; if (t < 0.5) return q; if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; return p; } vec3 hsl2rgb(vec3 hsl) { if (hsl.y < 0.00001) return vec3(hsl.z); float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y; float p = 2.0 * hsl.z - q; return vec3( hueChannel(p, q, hsl.x + 1.0 / 3.0), hueChannel(p, q, hsl.x), hueChannel(p, q, hsl.x - 1.0 / 3.0) ); } // One band's ownership of a hue: full at the band's own anchor, falling // linearly to 0 at each neighbour's anchor (the gaps are uneven — red sits 30° // from orange and 40° from magenta). Linearity is the point: adjacent tents // cross at exactly 0.5 at the midpoint, so the eight weights sum to 1 at every // hue. No pixel is counted twice, no pixel falls between two bands, and a hue // sitting on an anchor gets that band's full value instead of a share of it. float bandW(float hue, float anchor, float gapL, float gapR) { float d = mod(hue - anchor + 180.0, 360.0) - 180.0; return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR); } 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.50, 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. // The (1.0 - t) headroom weight is the whole point of the highlight curve: at // t = 1.0 the // weight is 0, so the pull-back cannot touch a pure white (a sun, a bulb, a // specular) and cannot turn it grey. A LIFT gets the same weight, which rides // it into the upper midtones and leaves the clipping where it was. float o = t + hl * hlMask * (1.0 - t) + 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); // WHITE/BLACK points, per channel. The weight is cubic in the channel's own // distance from the end, so in the shadows the darker channels move most and // in the highlights the brighter ones do: the two points pull R, G and B // toward a common toe and shoulder, which is a white-balance move (it // neutralises a cast) and not another tone slider. Each channel's curve is // still monotonic — 1 - 3*0.18 = 0.46 at worst — so no value can invert. vec3 dk = 1.0 - rgb; rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 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); rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0); // Selective colour by hue band — the last move, so a band edit is judged // against the colour the user actually sampled off the render. // // A grey is dropped before the weights are read: rgb2hsl hands it hue 0, so // without this fade every neutral pixel in the frame would be treated as // pure red and slide with the red band. Below 8% saturation there is no hue // to move anyway. // // The three accumulators are the band values scaled by ownership, so a hue // landing between two anchors gets a proportional mix of the two edits — // the same blend the weights already sum to. Hue is a turn (±30° at full), // saturation is a scale (0 = grey at -10), lightness is additive (±0.25 at // full) so it cannot invert the ramp. if (hslOn > 0.5) { vec3 hsl = rgb2hsl(rgb); float gate = smoothstep(0.0, 0.08, hsl.y); float hd = hsl.x * 360.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 + gl) * 0.25, 0.0, 1.0); rgb = hsl2rgb(hsl); } return vec4(clamp(rgb, 0.0, 1.0), c.a); } `; // EXPOSURE / EV — the one pass that has to run in LINEAR light. // // A stop is a multiplier on LIGHT, and the old EV row multiplied sRGB-ENCODED // values: +1 EV took a mid-grey 0.5 straight to a blown 1.0 where a real stop // gives 0.73. Here the pixel is linearised, scaled by 2^ev, and re-encoded — // which is what a camera does when the shutter stays open twice as long: every // value keeps its ratio, the highlights roll instead of flattening, and the // HIGHLIGHT knob still has something to pull back afterwards. // // It sits between the graded image and the tone shader (see exportEngine step 3), // so `ev` carries the EXPOSURE knob, the stock's own bias and the EV knob added // up in stops — the caller hands in one number. // // The transfer pair is the accurate one (0.04045/12.92 + 2.4, and its inverse): // the same constants colorUtils.planckianLinear uses on the WB side. export const EXPOSURE_SKSL = ` uniform shader src; uniform float ev; vec3 toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); } vec3 toEncoded(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); } vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 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] }; } // CLARITY's reference image B, one axis at a time — the multiple-pass // architecture of ki_n_tr_c_multiple_passes_cho_webgpu.md: a single 15x15 kernel // reads 225 pixels per pixel, a separable pair (1x15 then 15x1) reads 30. The // kernel is the doc's bilateral filter: the gaussian weight falls off along the // axis, and a range weight kills a tap whose colour is nothing like the centre's, // so an edge is not blurred across and the reference does not ghost it. // `dir` is one tap's step in the caller's units (px along ONE axis, the other // component 0), so a preview and the file blur the same fraction of the frame. // SkSL has no dynamic loop bound here, so the 15 taps are the doc's own count. export const CLARITY_BLUR_SKSL = ` uniform shader src; uniform float2 dir; vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 sum = c.rgb; float total = 1.0; for (int i = 1; i <= 15; i++) { float fi = float(i); float g = exp(-0.5 * (fi / 5.0) * (fi / 5.0)); vec4 a1 = src.eval(xy + dir * fi); vec4 a2 = src.eval(xy - dir * fi); vec3 d1 = a1.rgb - c.rgb; vec3 d2 = a2.rgb - c.rgb; float r1 = exp(-dot(d1, d1) * 24.0); float r2 = exp(-dot(d2, d2) * 24.0); sum += g * (r1 * a1.rgb + r2 * a2.rgb); total += g * (r1 + r2); } return vec4(sum / total, c.a); } `; // CLARITY, pass 3 of the doc's architecture: the frame against its own blurred // reference — `orig + (orig - B) * strength` above zero, the mix back toward B // below it. One reference, one pass, both directions of one knob: a NEGATIVE // CLARITY is the positive one's soften, not a second kind of blur picked for the // sign (that mist had another radius than the reference the positive side reads, // so -10 and +10 were two different neighbourhoods and a MASK's CLARITY could // not be the frame's own move). Clamped because a file cannot hold more than // white. Runs on the ENCODED pixels like every other grade here (only // EXPOSURE_SKSL is linear light, see colorUtils.exposureStops) — the doc's // formula is written for linear light, and moving the whole renderer there is a // bigger change than this pass. export const CLARITY_BLEND_SKSL = ` uniform shader original; uniform shader blurred; uniform float strength; vec4 main(vec2 xy) { vec4 c = original.eval(xy); vec3 b = blurred.eval(xy).rgb; vec3 d = c.rgb - b; vec3 out_rgb = strength >= 0.0 ? c.rgb + d * strength : mix(c.rgb, b, clamp(-strength, 0.0, 1.0)); return vec4(clamp(out_rgb, 0.0, 1.0), c.a); } `; // Strength that keeps CLARITY 10 where the 3x3 kernel had it: that kernel was // `c*(1+4a) - a*sum` with a = 0.8, i.e. `c + 3.2*(c - mean4)`, so the same 3.2 // lands the same local contrast through the wider bilateral reference. The gain // is for the POSITIVE side only: below zero the knob reads as its own fraction // of the reference (0..1, the same units MASK's CLARITY uses on it). export const CLARITY_GAIN = 3.2; // DEHAZE — raw_parameter_processing_gradient_mask_algorithms.md, section 3.2. // Haze is scattered light: it lifts the DARKEST channel of every patch, which is // the Dark Channel Prior. The dark channel is the MINIMUM of min(r,g,b)/A over // the patch, and that minimum is the whole prior: a patch holding anything // genuinely dark — a shadow, a black frame line — reads 0 and is left alone, // while only a patch with no dark pixel in it at all is haze and gets corrected. // The patch AVERAGE this pass used to read instead (the bilateral reference) // called every patch hazy, so the positive end ground the frame down instead of // taking haze out. `air` is the atmospheric light the caller estimated from the // frame, `step` one tap of the patch in the caller's own pixels — a fraction of // the frame's width, so the preview and the file look at the same neighbourhood // (DEHAZE_PATCH_STEP). // // `amount` is signed. Positive pushes the transmission below 1 and // `J = (I - A)/t + A` takes the scattered light out; negative pushes it above 1 // and the same expression scatters light back in, which is what a negative // DEHAZE is for. The floor keeps a flat sky from dividing by zero, and the // ceiling is the largest amount the knob can ask for either way. // Ray marching the doc's A estimate would need the histogram; the caller reads a // 32x32 copy of the frame instead and takes its brightest dark-channel pixel — // the same 0.1% answer, in one readback (see exportEngine's atmosphericLight). export const DEHAZE_FLOOR_T = 0.1; export const DEHAZE_MAX_OMEGA = 0.95; // The dark channel's patch, and the two ends of the transmission t. The patch is // `taps` samples out at `step` each — two taps at 0.625% of the frame's width is // a 2.5%-wide neighbourhood, the DCP's own 15-pixel patch on a 600-pixel frame // and the same fraction of a 4000-pixel export. Five by five samples rather than // fifteen by fifteen because the doc's 225 reads per pixel is what // CLARITY_BLUR_SKSL above already refused, and the prior only needs a patch the // haze is flat over. export const DEHAZE_PATCH_TAPS = 2; export const DEHAZE_PATCH_STEP = 0.00625; export const DEHAZE_SKSL = ` uniform shader img; uniform float3 air; uniform float amount; uniform float floorT; uniform float stepPx; vec4 main(vec2 xy) { vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0); vec3 a = max(air, vec3(0.05)); float dark = 1.0; for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) { for (int i = -${DEHAZE_PATCH_TAPS}; i <= ${DEHAZE_PATCH_TAPS}; i++) { vec3 p = clamp(img.eval(xy + vec2(float(i), float(j)) * stepPx).rgb, 0.0, 1.0); dark = min(dark, min(min(p.r / a.r, p.g / a.g), p.b / a.b)); } } float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0 + ${DEHAZE_MAX_OMEGA}); return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0); } `; export function dehazeUniformArray( air: [number, number, number], amount: number, stepPx: number ): number[] { 'worklet'; return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T, stepPx]; } // One tap of that patch in the pixels of a frame this wide. export function dehazePatchStep(width: number): number { 'worklet'; return Math.max(1, width * DEHAZE_PATCH_STEP); } 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) wh: number; // -1..1 (adjustments.whites / 10 — WB white point, per channel) bl: number; // -1..1 (adjustments.blacks / 10 — WB black point, per channel) 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') 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: // 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 Vivid is Classic Chrome's sibling — the shadow crush belongs to the // stock, not to the matrix rows, so it comes along. 'classic-vivid': { 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 }, // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe // (-0.32 against Acros' -0.12) so the darks reach true black, and a shoulder // that LIFTS instead of rolling (-0.05 → +0.26), which is the whites step of // the brief. Midtones see neither mask, so the long smooth stretch between // the two ends survives — that is what keeps a hard push off the posterised // look, and the strength the stock needs on the greys is its matrix slope // (SIM_CONTRAST_BIAS in colorUtils), not another mask here. 'mono-high-contrast': { sh: -0.32, hl: 0.26 }, }; 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 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]; 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 = !isMonochromeBase(baseFilter); 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; // Selective colour: one slot per band, in HSL_BANDS order, so the flat buffer // lines up with the shader's arrays. A band the user has not moved holds // three zeroes and costs nothing but its slot. const bands = adj.hslBands ?? {}; const tenth = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0; const hslH: number[] = []; const hslS: number[] = []; const hslL: number[] = []; let hslOn = 0; for (const band of HSL_BANDS) { const v = bands[band.id]; const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0]; hslH.push(h); hslS.push(s); hslL.push(l); if (h || s || l) hslOn = 1; } // 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), sh: sh + (film.sh ?? 0), wh, bl, vib, shT, hlT, cc: chromeDepth(adj.colorChrome), ccb: blueDepth(adj.colorChromeBlue), hslOn, hslH, hslS, hslL, gh, gs, gl, }; } // 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.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.gh, u.gs, u.gl, ]; } export function toneIsActive(u: ToneUniforms): boolean { return ( u.hslOn !== 0 || 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 || u.shT[2] !== 0 || u.hlT[0] !== 0 || u.hlT[1] !== 0 || u.hlT[2] !== 0 || u.cc !== 0 || u.ccb !== 0 ); }