7e47a153b8
A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL: linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239. The pass sits between the matrix and the tone shader, and the tone / cinema / curve / glow / halation children all sample through it, so HIGHLIGHT finally sees the value exposure produced instead of the one before it. Recovery keeps `L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the colour comes back as `color * (luma_new / luma)`: a blown white stays white (255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside it do not move. AUTO is the histogram the LIGHT tab already draws: weighted mean luminance (guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the 83.3 predicted, idempotent on a second press. A stock's own bias rides the same pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so -1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177). ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the two copies have to move together; recipes saved before this commit (EXPOSURE 2, HIGHLIGHT +-1) render under the new stop semantics. Verified on the rebuilt container (BASE=http://localhost:8090): - web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio 2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console errors) - sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1 returning 0.4177, bias 0.0382) - regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0, heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch 33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0, save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing. - npx tsc --noEmit clean.
479 lines
23 KiB
TypeScript
479 lines
23 KiB
TypeScript
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 <Shader> 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 <Shader uniforms>, 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)
|
||
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<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||
'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` / `<Shader uniforms>` — 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
|
||
);
|
||
}
|