import { BaseFilter, ColorAdjustments } from '../types'; import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic // Range). SkSL runtime effect over a child image shader. // // TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the // four zones of the tone mapping doc, and no two of them own the same part of // the ramp: // // BLACKS peak at 0.00, gone by 0.25 // SHADOWS peak at 0.25, gone by 0.50 // HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00 // WHITES peak at 1.00, gone by 0.75 // // The four masks below are those tents — the doc's smoothsteps, one per quarter // of the ramp — and the 0.50 midpoint is in none of them: it is the one value // every one of the four leaves where it was. // // Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the // pixel lands on its new luma with the chroma it had, so the hue is untouched // and a grey stays grey. The doc reaches the same place with a ratio // (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and // moves the hue the moment a channel clips; see the note on `k` below. // // These four masks are ADDED in the doc's own pseudo-shader, and measured that // way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per // unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp // should still be climbing. Read here instead as the four ANCHORS of one ramp — // knots at 0.00, 0.25, 0.50, 0.75 and 1.00, each moved by its own knob, each // held inside the knot before it, drawn straight in between — the same // measurement is monotone for every combination of the four at full deflection. // A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe // on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range // slider has in the program this layout copies, without the inversion. SHADOW is // the one knob held to half of that, because the knot it moves is the HEAD of // the quarter above it and not an end of the ramp: a band cannot be lifted at // its head and keep its slope at the same time, so the knob's travel is what has // to give — see the measured note on a1. // // WHITE and BLACK are not the per-channel toe and shoulder they were on the WB // tab any more. The doc puts the two points on the ends of the SAME ramp as the // other two, so they are the ends this ramp is drawn through, and nothing else // in the shader reads them. // // The ramp is drawn through the BASE LAYER, not through the pixel. The pixel's // own luma, run through a knot move, is a GLOBAL curve: every pixel at luma t // lands on the same o whatever is around it, so a knot lifted onto the band above // it (SHADOW's a1) is a band whose whole spread is squashed to the slope left // over — at SHADOW +100 a quarter of the ramp carries half its contrast, and on a // real frame 0.50 of it survived: the grey sheet the knob was reported for. What // the eye is reading there is LOCAL contrast, and a curve drawn through the pixel // cannot see it. // // So the curve is drawn through what the frame holds AROUND the pixel — a coarse // edge-aware blur of the luma, TONE_BASE_RADIUS of the frame wide (the // fix_shadow.md decomposition, Base x Detail) — and the neighbourhood's new luma // is added to the pixel's own difference from it: Base' + Detail. Base moves, // detail keeps its size: the same lift, on the same pixels, with the texture // inside the region left standing instead of drawn flat. Full deflection on the // same frame keeps 0.78 of the band's spread where the global move kept 0.57 // (shadow-live.mjs, before and after, over the deployed pass; shadow-band.py, // the numpy twin of this maths, put it at 0.78 to 0.80). // // The detail used to ride the gain instead — Base' * (Input / Base), the other // half of the same decomposition — and that is the reconstruction a tonal knob // must not use: the gain is a function of the neighbourhood, so the detail is // scaled by how dark or bright the region is, and a knob that takes the base // toward zero takes the texture with it. BLACK -100 did exactly that (see the // note in toneRamp), and on a monochrome frame, where all three channels ARE // the pixel's luma, it returned the blurred base outright. // // It costs nothing where there is no lift to make: with every knob on zero the // ramp at base IS base, so the difference is exactly zero and the pass is the // identity however coarse the base is. A caller that hands in no neighbourhood // at all (a mask, which has none) hands in the pixel's own image as the base and // gets the global move back — which is why the shared maths can take the base as // an argument and mean the same thing in both places. // // Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a // smoothstep there is an S-curve through the knots, so it bends the ramp by up // to six code values in the quarter-tones even with all four knobs on zero — and // this pass still runs for the stock split tones and for DR alone, where nothing // the user set asked for a contrast move. Straight segments keep a neutral // setting the exact identity. The smoothsteps are the four ZONE masks above, // which is where the doc's shape belongs: they weight DR and the split tones, // and nothing but their peak positions has to be smooth. // // The -HL highlight recovery that used to run in LINEAR light ahead of all this // is gone with it: HIGHLIGHT is one zone move now, in both directions. There is // still detail at the top to move — the develop's own knee compresses the two // stops the sensor holds above its white level into the frame (see // highlight-knee-check.mjs), so -WHITES pulls a plateau down onto 0.75 rather // than onto a flat 1.0. // // dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji // extended DR); 0/auto/DR100 = no extra curve. It moves the same four // knots the knobs move, so DR and a knob cannot fight over the middle and // DR cannot invert the ramp either — added as its own masked terms on top // it could, and did: see the fold noted on the knots below. // hl - highlight -1..1: moves the 0.75 anchor, + up toward white, - down. // sh - shadow -1..1: moves the 0.25 anchor, + up, - down. // wh - white point -1..1: moves the 1.00 anchor. + is free to pass 1.0 — that // is the move that clips a highlight toward white — and - pulls the head // of the ramp down under it. // bl - black point -1..1: moves the 0.00 anchor. + lifts the toe off the // floor (a faded black), - has nothing left to crush at 0. // 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(''); // How far a tonal-range knob moves its own knot, in ramp units. A quarter is // the reach the program this layout copies gives a slider: at full deflection // the toe can reach the quarter above it and the head can be rolled onto it — // and never past, because each knot is clamped inside the one after it. // // SHADOW and HIGHLIGHT move HALF of it. The knots they own (a1, a3) are the HEAD // of a quarter, so a unit of lift is a unit of slope that quarter loses, and at // a whole quarter the band is drawn flat — see the notes on a1 and a3. export const TONE_ANCHOR = 0.25; // How steep a bump of amplitude A gets, A excluded: the kernel (1-u^2)^2 tops out // at |dk/du| = 8/(3*sqrt(3)) = 1.5396 on each side of its peak (u = 1/sqrt(3)), // and u counts the distance to the knot in units of the bump's half-width. Over // the half-width of BLACK and WHITE that is 3.08*A, over the quarter-width of // SHADOW and HIGHLIGHT 6.16*A, and the two numbers are what the guard inside // toneRamp holds two moves sharing a half of the ramp under. export const TONE_BUMP_SLOPE_HALF = 3.0792; export const TONE_BUMP_SLOPE_QUARTER = 6.1584; // How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's // own width — the fix_shadow.md neighbourhood (it asks for 2%..5% of the width). // A fraction rather than a pixel count so the preview and the export look at the // same neighbourhood, and the measurement is flat across the range anyway: full // deflection on the sample frame keeps 0.77 of the band's spread at 0.7%, 0.80 at // 2.5%, 0.81 at 4.8%. // // The caller turns this into a BLUR, and it took a bug to make that a blur in // fact and not only in name. The base used to be nine point samples of the child // out at plus or minus this radius, and point samples are not an average: on a // frame with texture at the sampling scale — a waterfall, a mountainside — the // nine-tap luma aliases, the gain o(base)/base inherits the alias, and the // reconstruction paints it as mottle. Measured on a 1160x774 frame at // SHADOW +100, the high-frequency (9px high-pass) part of that gain field was // 0.063 against 0.005 for a real blur of the same radius — 13x. So the base is // now a real gaussian blur of the child, made by the caller (Skia's own // MakeBlur, see blurredBase() in exportEngine.ts) and read here as ONE tap. That // is also the cheaper pass: nine child evals walk the whole exposure/matrix // chain nine times, one eval does not. export const TONE_BASE_RADIUS = 0.025; // One gaussian sigma of the base blur, as a fraction of TONE_BASE_RADIUS. A box // of the same radius and a gaussian of this sigma carry the same weight at the // radius, so the neighbourhood is the one the radius has always named while the // cuts are smooth instead of hard. export const TONE_BASE_SIGMA = 0.35; // The tone and exposure maths, in ONE copy, because two passes ask it: the // whole-frame passes here and a gradient mask, which moves the same knobs on the // shape the user drew. What "HIGHLIGHT" or "EXPOSURE" means must not depend on // where the shape is, and it did: the frame moved the ramp's knots while a mask // ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp, and the // frame's exposure was a linear-light stop while a mask's was a stop on // sRGB-encoded values. That divergence is what the scratchpad compat doc §3.3 // warns the Android port about. `dr` is the whole frame's DYNAMIC RANGE; a mask // has no such knob and hands in 0, which is what DR's terms are worth when it is // off on the frame too. export const TONE_MATH_SKSL = ` // One knob's move, read at the position x on the ramp: 1 on the knob's own knot, // falling to 0 on the knot a half-width away and staying there — (1-u^2)^2 with u // the distance to the knot in half-widths. Level at u = 0 and at u = 1, so the // move joins the ramp without an angle at either end. float toneBump(float x, float knot, float half_width) { float u = (x - knot) / half_width; float v = max(0.0, 1.0 - u * u); return v * v; } // The ramp the pixel is rebuilt through: the identity plus four moves. Each knob // owns a bump that PEAKS on its knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT // on 0.75, WHITE on 1.00 — and is back on zero where the next one starts, a // quarter away (a half for the two ends, their knots being the ends of the ramp), // so the two bumps of a half meet on 0.50 at zero height AND zero slope: a knob // moves its own end of the ramp and nothing else, and 0.50 is fixed — the one // point all four sliders leave alone, which is what keeps a mid-grey a mid-grey // while the ends move around it. Every knob on zero leaves the sum empty, so the // whole thing is exactly the identity (see the note at the head of this file). // // Straight segments between the knots were the first cut, and the measurement is // what retired them: a segment meets its neighbour at an angle, and an angle in a // tone curve is a Mach band. On a 1024-step luma wedge (scratchpad probe, JPEG // 8-bit, second derivative through a ±1% box) BLACK +100 came back with 105 at // 0.030 while the stretch above it was untouched to the last bit — 0.0000 from // 0.25 up, the "transition stays grey" the knob was reported for — and HIGHLIGHT // -100 broke at 0.747 with 72, the seam between the lifted highlights and the // shadow under them. The kernel is (1-u^2)^2: level at the peak, so a knot moves // without a fold at its own top, level where it lands, so the join on 0.50 and // the toe on 0.00 stay clean, and C1 at both ends of a move and everywhere // between, which is the whole of what a Mach band asks for. // // DR moves the same four bumps instead of adding its own masked terms on top: it // lifts the toe and rolls the head exactly as before at t = 0 and t = 1 — // 0.12 and 0.18 at full strength — and half of each at the knots next to them, // so the terms land on the two ends and on the heads beside them as one move. // // Lightness takes the curve; the colour rides the ratio. The pixel moves to its // new luma, and it gets there by scaling its three channels by ONE number, // k = o / t: the doc's R_new = R_old * Luma_new / Luma_old. The differences move // with the light, so the hue cannot turn, the saturation cannot be drained by a // lift — an HSL saturation is a ratio of differences and never sees a common // scale — and a neutral, with no difference to carry, lands on o exactly. // // The scale used to be 1.0 instead: keep the chroma, move the luma. That holds // the hue and loses the colour, which is the complaint the ratio answers — the // scratchpad probe (sh-bl-hue.mjs) measured a dark red at 0.746 of saturation // coming back at 0.505 with SHADOW at +100, and at 0.370 with SHADOW and BLACK // both at +100. A chroma held under a rising luma IS a colour going grey, and it // is the two sliders that lift a shadow (SHADOW, BLACK) that raise the luma of a // dark pixel the furthest. // // What the ratio cannot do on its own is fit, and that is what the caps below are // for: past the ceiling a channel clips outright, the three stop being scaled // together and the hue goes with them (measured on the other probe, a skin tone // at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°). // Pulling the ONE scale back while the cube has room for no more costs saturation // instead: a pixel the cube is against arrives neutral before it arrives wrong, // and one the curve has driven all the way to 1.0 arrives at white. // // Below t = 0.0004 there is no ratio worth the name: dividing by what is left of // a pixel that has almost no light on it takes whatever cast the last code value // of 8-bit noise left there and multiplies it by the pedestal the BLACK knob just // lifted — colour noise, amplified to the size of the lift. The scale stays 1.0 // down there and the pixel takes the pedestal as the flat grey it is. // // ONE move of the light, and everything in this file that changes how bright a // pixel is goes through it: a tone knob, a mask's tone knob, and the exposure // knob on both. The luma lands on o and the channel differences ride along at // one shared scale k, so a knob named "change the brightness" changes the // brightness and nothing else — what a per-channel multiply cannot promise once // a channel reaches the ceiling, where the three clip by different amounts and // the hue goes with them. vec3 lightMove(vec3 c, float t, float o) { float k = t > 0.0004 ? o / t : 1.0; float hiC = max(max(c.r, c.g), c.b); float loC = min(min(c.r, c.g), c.b); if (hiC > t) k = min(k, (1.0 - o) / (hiC - t)); if (loC < t) k = min(k, o / (t - loC)); return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0); } vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) { // BLACK owns 0.00, the edge of the ramp, so its bump is the half-width one and // the toe opens to A while the quarter above it takes the fall of it. As a // bump the fall is a slope and never a plateau, which is what the report was // about: at +100 the lift used to be spent inside 0.25 and the stretch above it // came back identical to the untouched frame — the measured 0.0000 from 0.25 up // in the note at the top of the ramp. Here the mid-tones rise a little with the // toe and the corner that drew the seam at 0.25 is gone. float blackA = ${TONE_ANCHOR} * bl + dr * 0.12; // SHADOW rides HALF the anchor, and that is a measured ceiling rather than a // taste. The knot is the HEAD of the quarter above it, and the bump only ever // ADDS to the identity, so that quarter pays in slope for every unit the knot // rises: its mean slope is 1 - A / 0.25, and that band is where a waterfall's // spray and a sunlit rock sit. At the whole anchor the mean is 0 and the band // is drawn flat — on a real frame (DSCF1701, misty valley, 21% of its pixels in // the band) SHADOW +90 came back with 0.10 of the band's own spread and a mean // of 0.392 where it was 0.489, the milky white sheet the knob was reported for, // bright areas with their contrast gone. At half: 0.55 of the spread, mean // 0.441, and the toe still opens at 1.45x. Half also keeps the knob clear of // the guard below: at a whole-anchor rate the guard would take over past ±65 // and the top third of the slider would do nothing, which is why the RATE is // halved and not just a ceiling. float shadowA = ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06; // HIGHLIGHT rides half the anchor for the reason SHADOW does, and its knot is // the head of the 0.75..1.00 quarter: at a whole-anchor rate +100 (A = 0.25) // draws that quarter flat against the ceiling, its whole mean slope gone, and // the same knob the other way spends the quarter BELOW the knot instead. Half // draws neither, and the head still rolls from 0.625 to 0.875 over the travel. float highA = ${TONE_ANCHOR} * 0.5 * hl - dr * 0.09; float whiteA = ${TONE_ANCHOR} * wh - dr * 0.18; // Two bumps share each half of the ramp, and their steep sides can land on the // same stretch: past a total slope of 1 the sum would carry the curve // BACKWARDS — a fold, a worse band than the seams this replaced. Read at their // own steepest points, which is the loosest the pair can be, the two amplitudes // of a half are held under one number and given up together past it. A knob on // its own never reaches it — a full BLACK is 0.77, a full SHADOW 0.77, and DR, // which moves both bumps of a half at once, 0.74 below and 1.11 above for a // steepest sum of 1.00, so a full DR gives up a tenth of its head roll — so a // single slider has its whole travel, and only a pair pushed together gives // anything up: BLACK and SHADOW both at +100 arrive at 0.65 of their own lift // rather than folding. float holdLo = ${TONE_BUMP_SLOPE_HALF} * abs(blackA) + ${TONE_BUMP_SLOPE_QUARTER} * abs(shadowA); float holdHi = ${TONE_BUMP_SLOPE_QUARTER} * abs(highA) + ${TONE_BUMP_SLOPE_HALF} * abs(whiteA); float kLo = holdLo > 1.0 ? 1.0 / holdLo : 1.0; float kHi = holdHi > 1.0 ? 1.0 / holdHi : 1.0; blackA *= kLo; shadowA *= kLo; highA *= kHi; whiteA *= kHi; // The ramp is read at the BASE, so the curve's move is the neighbourhood's, // and the pixel keeps its own DIFFERENCE from that base — fix_shadow.md's // Base' + Detail, added and not scaled. The ratio Base' * (Input / Base) was // the first cut and it is what broke BLACK: multiplying by the neighbourhood's // gain o / base scales the detail by that gain, so the knob that takes o toward // zero takes the picture's texture with it. Measured on the sample frame at // BLACK -100 (tone-sim, the pass in plain JS at 1024px): the finest gradient // came back at 0.75 of the input under the ratio and at 0.98 under the sum, // while the frame darkened the same either way (mean 0.416 -> 0.359 both). On a // monochrome stock every channel IS the pixel's luma, so the ratio's error is // the whole frame there: the pass returned the blurred BASE, which is the soft // picture the knob was reported for. The bright side of the same move is kept — // the lift is still the neighbourhood's, and the band above a lifted knot still // keeps its spread (0.81 of it at SHADOW +100, where the global move kept 0.42). // // The pixel's own luma still travels as t: lightMove rebuilds the colour from // it, and it is the value the caps read. // // o is held inside the cube BEFORE the detail is added: a base the ramp has // pushed under the floor is a neighbourhood whose black point is below zero, // and clipping o first keeps the structure around it instead of carrying the // pedestal down onto every pixel in the region. float o = clamp(base + blackA * toneBump(base, 0.00, 0.50) + shadowA * toneBump(base, 0.25, 0.25) + highA * toneBump(base, 0.75, 0.25) + whiteA * toneBump(base, 1.00, 0.50), 0.0, 1.0); // A caller with no neighbourhood of its own (a mask) hands in the pixel as its // base, and the difference is then exactly zero: the target is the ramp at t, // the global move, which is what the ratio gave it too. float target = o + (t - base); return lightMove(c, t, clamp(target, 0.0, 1.0)); } // The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the // same constants colorUtils.planckianLinear uses on the WB side, and the reason // a stop is a stop here. EXPOSURE needs it — 2^ev is a multiplier on LIGHT — and // so does anything else that has to reach the linear domain. 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)); } // One EXPOSURE knob, wherever it is: the frame's own pass and a mask's knob. It // linearises, moves the LIGHT by 2^ev — a stop is a multiplier on light, and on // an sRGB-encoded value +1 EV would take a mid-grey 0.5 straight to a blown 1.0 // where a real stop gives 0.73 (measured: 0.6858 through here, and the plain // per-channel multiply puts the same 0.6858 on a grey, so a neutral is the knob // it always was) — and re-encodes. // // The linear domain decides WHERE the luma is going; the move is then made by // lightMove on the encoded values, where the tone ramp also works. That split is // measured, not chosen for symmetry: carrying the chroma in the LINEAR domain // drifts the hue of the encoded pixel by up to 12° (a saturated red at -1 EV // came back at 12.1°, a skin tone at +1 EV at 11.5° — the encoding is // per-channel, so equal ratios in linear are not equal ratios on screen), // against 0.00° this way. The knob whose whole promise is brightness must not be // the one that also moves a hue: a channel that would have clipped gives up // saturation instead, and a pixel the move has driven all the way to 1.0 is white // in all three channels at once. vec3 exposureMove(vec3 rgb, float ev) { vec3 c = clamp(rgb, 0.0, 1.0); float t = clamp(dot(c, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); // The linear domain says where the luma is going; the value that lands there // is applied as a RATIO on the pixel's own encoded luma, not pointed at // directly. toEncoded(luma_lin * 2^ev) is the target, and on a grey it IS the // pixel's new luma (a stop on a neutral is the stop it always was) — but the // transfer does not commute with the luma weights, so on a colour the two // differ by a couple of code values, and the knob on 0 EV would brighten the // frame instead of leaving it alone. As a ratio it is exactly 1 at 0 EV. float lin = max(dot(toLinear(c), vec3(0.2126, 0.7152, 0.0722)), 1e-6); float stop = toEncoded(vec3(min(1.0, lin * exp2(ev)))).r / toEncoded(vec3(lin)).r; return lightMove(c, t, clamp(t * stop, 0.0, 1.0)); } `; export const TONE_SKSL = ` uniform shader src; // The BASE layer's child: a blur of the image above, of TONE_BASE_RADIUS, made // by the caller. Read for its luma alone, and read ONCE per pixel — the // neighbourhood is the blur's, not a sampling loop's (see TONE_BASE_RADIUS). uniform shader base; 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); } ${TONE_MATH_SKSL} // The BASE layer: the light the frame carries where this pixel sits, at the // radius the caller handed in. One tap of a real blur of the same child the // pixel comes from, so it is an AVERAGE of the neighbourhood and not a handful // of point samples of it — that distinction is the whole bug (see // TONE_BASE_RADIUS). Luma, because the luma is the one quantity the ramp moves // and the colour rides the ratio afterwards; the blur being linear, blurring the // child and taking its luma is the same as blurring the luma. // // A caller with no neighbourhood to speak of hands in the child itself as the // base (see blurredBase()): the tap then lands exactly on t and the pass falls // back to the global move, which is what a shape with a mask's degenerate base // wants and what it got from a bx of zero before. float baseLuma(vec2 xy) { vec3 s = clamp(base.eval(xy).rgb, 0.0, 1.0); return clamp(dot(s, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); } vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); // NOTE: never name a local 'out' — it is a reserved SkSL qualifier. float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); // The four tents of the doc, one per quarter of the ramp: BLACKS peaks on // 0.00 and is gone by 0.25, SHADOWS peaks on 0.25 and is gone by 0.50, // HIGHLIGHTS peaks on 0.75 and is gone by 0.50 and 1.00, WHITES peaks on // 1.00 and is gone by 0.75. Each is the doc's own smoothstep, each is clipped // by subtracting the tent before it so the four never overlap and no luma is // ever counted twice, and the 0.50 midpoint is weighted by none of them: they // are the weights the stock split tones ride, which is why they are smooth and // why they stay out of the ramp below — nothing else in this shader reads them. float blMask = 1.0 - smoothstep(0.00, 0.25, t); float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0); float whMask = smoothstep(0.75, 1.00, t); float hlMask = clamp(smoothstep(0.50, 0.75, t) - whMask, 0.0, 1.0); // The four tonal-range knobs, on the shared ramp: see TONE_MATH_SKSL — the // same knots, the same hue-preserving rebuild, the same move a gradient mask // makes with the same four sliders. DR is the whole frame's, so it is spent // here and nowhere else. rgb = toneRamp(rgb, t, baseLuma(xy), bl, sh, hl, wh, dr); // 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. exposureMove linearises, moves the light by 2^ev, and re-encodes — // and it spends that stop on the LUMA, not on the three channels one at a time, // so this knob only ever changes how bright a pixel is: a channel that would // have clipped gives up saturation instead of dragging the hue (a gradient // mask's own EXPOSURE runs through the same function, on the mask's pixels). // // 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. export const EXPOSURE_SKSL = ` uniform shader src; uniform float ev; ${TONE_MATH_SKSL} vec4 main(vec2 xy) { vec4 c = src.eval(xy); return vec4(exposureMove(clamp(c.rgb, 0.0, 1.0), ev), 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. // // The range weight reads LUMINANCE, not the colour difference the black-halo // trade first used. A colour difference is loose on any coloured edge — two // sides of it can share a red and differ in green — so the reference blurred // across hair, branches and every rail, and that reference is exactly what the // blend subtracts: the wider the reference reaches, the more a contour reads as // detail, and clarity drew a light stroke down each one. On luminance the weight // is one decision per tap, at the doc's own scale CLARITY_RANGE_SIGMA, and an // edge of any hue stops the blur dead. (A four-times downsample of the source, // md section D, is not worth it at 15 taps: measure the cost of the full-res // pair first — ponytail: add the 4x pyramid only if a phone profile shows the // two 1x15 passes as the frame's cost.) export const CLARITY_BLUR_SKSL = ` uniform shader src; uniform float2 dir; const float3 CLARITY_LUM = vec3(0.2126, 0.7152, 0.0722); // One tap's luminance may sit this far from the centre's and still be counted: // a tenth of the range. Loose enough that a smooth gradient still averages, // tight enough that a contour one pixel wide is a wall to the blur. const float CLARITY_RANGE_SIGMA = 0.04; vec4 main(vec2 xy) { vec4 c = src.eval(xy); float lc = dot(c.rgb, CLARITY_LUM); 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); float d1 = (dot(a1.rgb, CLARITY_LUM) - lc) / CLARITY_RANGE_SIGMA; float d2 = (dot(a2.rgb, CLARITY_LUM) - lc) / CLARITY_RANGE_SIGMA; float r1 = exp(-d1 * d1); float r2 = exp(-d2 * d2); 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's detail against its own // blurred reference. Base is the reference B, Detail is the frame minus B, and // the pass returns B + Detail * (1 + amount * gain * midtone). Above zero the // detail comes back amplified; below it the knob is the mix back toward B, so // NEGATIVE CLARITY is the positive one's soften and not a second 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. // // The move is made ON LUMINANCE and then handed back to all three channels by // one scale, which is the trade's own rule C. Detail is what the eye reads as // structure, but it is not per-channel: sharpen red against a red-and-green edge // and red alone overshoots, which is what coloured fringing along every contour // was. One luminance value carries the whole pixel back with it, so hue is // untouchable — skin does not go sallow at the top of the knob, and a saturated // red or a cyan shadow keeps its ratio. The midtone weight is the doc's // M(L) = 4L(1-L): 0 at black and at white, 1 in the middle, so the knob deepens // the greys a picture is made of and leaves the burnt ends and the deepest // shadows where they are, which is also where a halo would otherwise show worst. export const CLARITY_BLEND_SKSL = ` uniform shader original; uniform shader blurred; uniform float strength; const float3 CLARITY_LUM = vec3(0.2126, 0.7152, 0.0722); // md section 5's 1.8, raised to 4.5: the range weight above now stops the blur // at a real edge, so the reference reaches less far and carries less detail than // the loose one did — the same knob has to be turned further to land where // CLARITY 10 sat before. The gain was picked by clarity-halo.mjs, whose stroke // is the pass pushing a pixel outside its own neighbourhood's range: at +10 this // lands 55/255 (lightroom_shadow.jpg) and 54/255 (DSCF1701.JPG) against the old // pass's 145 and 127 at the same knob, and 8.0 already reads 98 — the knob does // not need to go further to keep the flat areas moving. const float CLARITY_DETAIL_GAIN = 4.5; // The same 1e-4 the doc uses under both luminance terms: an epsilon in the // division that keeps a black pixel's ratio finite without moving any pixel a // full step. const float CLARITY_EPS = 0.0001; vec4 main(vec2 xy) { vec4 c = original.eval(xy); vec3 b = blurred.eval(xy).rgb; // Below zero there is no detail to amplify, only the reference to move toward. if (strength < 0.0) { return vec4(clamp(mix(c.rgb, b, clamp(-strength, 0.0, 1.0)), 0.0, 1.0), c.a); } float lo = dot(c.rgb, CLARITY_LUM); float lb = dot(b, CLARITY_LUM); float mid = clamp(4.0 * lb * (1.0 - lb), 0.0, 1.0); float nl = clamp(lb + (lo - lb) * (1.0 + strength * CLARITY_DETAIL_GAIN * mid), 0.0, 1.0); float scale = (nl + CLARITY_EPS) / (lo + CLARITY_EPS); return vec4(clamp(c.rgb * scale, 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 — brightest dark-channel pixel of a copy of it, the doc's 0.1% answer in // one readback (exportEngine's atmosphericLight). // // The pass itself is now only the doc's last line, `J = (I - A)/t + A`, on a // transmission the caller has already solved for. `dark` is what the caller // hands in as an image: the dark channel itself, read off a small copy of the // frame and interpolated back up, which is the smoothing the prior wants — see // the caller's dehazeDarkChannel for why it cannot be had from a patch read out // per pixel. Its cell is the patch, so a value per cell is a value per patch. // // What travels as an image is the dark channel and not t on purpose. A channel is // eight bits, so it can only carry 0..1 — and t is 1 + 0.95 at the negative end // of the knob, which would arrive here clipped to 1 and turn "put the scattered // light back" into a pass that does nothing. The dark channel is 0..1 by // construction, and the signed amount stays a uniform where it costs no range. // // Signedness is then in the expression. Positive folds t below 1 and takes the // scattered light out; negative folds it above 1 and the same expression scatters // it 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. export const DEHAZE_FLOOR_T = 0.1; export const DEHAZE_MAX_OMEGA = 0.95; // The patch the MASK's DEHAZE reads out of its own frame (gradientMask.ts) — // `taps` samples out at `step` each, two taps at 0.625% of the frame's width, 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. The frame-wide pass reads no patch at all any more. export const DEHAZE_PATCH_TAPS = 2; export const DEHAZE_PATCH_STEP = 0.00625; export const DEHAZE_SKSL = ` uniform shader img; uniform shader dark; uniform float3 air; uniform float floorT; uniform float maxT; uniform float amount; vec4 main(vec2 xy) { vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0); vec3 a = max(air, vec3(0.05)); float d = clamp(dark.eval(xy).r, 0.0, 1.0); float t = clamp(1.0 - amount * ${DEHAZE_MAX_OMEGA} * d, floorT, maxT); return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0); } `; export function dehazeUniformArray( air: [number, number, number], amount: number ): number[] { 'worklet'; return [air[0], air[1], air[2], DEHAZE_FLOOR_T, 1 + DEHAZE_MAX_OMEGA, amount]; } 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 — moves the 1.00 end of the ramp) bl: number; // -1..1 (adjustments.blacks / 10 — moves the 0.00 end of the ramp) 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. // // The `sh` and `hl` here are written at DOUBLE what the knob's own arithmetic // reads, which is not a taste either: SHADOW and HIGHLIGHT each move their knot // half an anchor per unit (see a1 and a3 in TONE_MATH_SKSL), so a stock that // wants the toe on 0.18 has to ask for -0.56 to land it there. The stock's crush // is the knot it puts the ramp on; the unit it is written in belongs to the // knob, not to the look. const FILM_TONE: Partial>> = { 'classic-chrome': { sh: -0.56 }, // 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.56 }, '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 are between the 0.25 and the 0.75 knots, so they keep // every step the matrix handed over — which is what 'deep black' costs in a // colour stock and does not have to cost here. // The values move the two end knots of the ramp: -0.24 puts the toe on 0.22 // and -0.10 rolls the head to 0.7375 (both SHADOW and HIGHLIGHT ride HALF an // anchor per unit, so a stock written against the knot asks for double). monochrome: { sh: -0.24, hl: -0.10 }, // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe // (-0.64 against Acros' -0.24, so 0.17 against 0.22) so the darks reach true // black, and a shoulder that LIFTS instead of rolling (-0.10 → +0.52, the // head going to 0.815), which is the whites step of the brief. The stretch // between the two inner knots (0.25 and 0.75) is still the identity, so the // long smooth stretch of the greys 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 move here. 'mono-high-contrast': { sh: -0.64, hl: 0.52 }, }; // The base layer's neighbourhood is the caller's business, not this function's: // it is a blurred CHILD of the shader (see TONE_SKSL), so only the caller knows // how big the frame is or whether there is a frame at all. A shape with a mask // has no frame to look at and hands in the image it is already shading, which // lands the base on the pixel and keeps the global ramp (see baseLuma). 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)); // WHITE and BLACK ride this pass with the other two, each as the end knot of // the same ramp (see TONE_SKSL). They are no longer a white-balance move and // are read by nothing else in the pipeline. 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 ); }