1140 lines
60 KiB
TypeScript
1140 lines
60 KiB
TypeScript
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. Each knob is the MOVE its
|
||
// own Lightroom export made, measured off the nine reference files in the repo
|
||
// root and stored as a polyline on the encoded luma (TONE_KNOBS below): WHITE
|
||
// +100 takes everything above luma 0.50 to 1.0, BLACK -100 takes everything
|
||
// below 0.21 to 0.0, HIGHLIGHT +100 is a +0.24 lift peaking around luma 0.18 and
|
||
// dead by 0.72. Read the code values as `out = L + amt * curve(L)`, clamped, amt
|
||
// = knob / 10. The four are NOT the four zones of the tone mapping doc any more,
|
||
// a tent per quarter of the ramp with a quarter of travel each: that is the
|
||
// layout the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi" report
|
||
// came back on, WHITE ±100 a literal no-op among them.
|
||
//
|
||
// 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.
|
||
//
|
||
// The four moves are applied ONE AFTER THE OTHER — BLACK, SHADOW, HIGHLIGHT,
|
||
// WHITE — each clamped on the way to the next. The doc ADDED its four masks, 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. A composition of non-decreasing maps
|
||
// is non-decreasing by construction, so the ramp cannot fold however the four
|
||
// sliders are set, and the knot tables are cut so that L + move is monotone on
|
||
// every segment; highlight-knee-check.mjs sweeps both.
|
||
//
|
||
// WHITE and BLACK are not the per-channel toe and shoulder they were on the WB
|
||
// tab any more. They are the two ENDS of the same ramp the other two move, 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 knob that lifts a band onto
|
||
// the one above it 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. What smoothsteps are left in toneRamp weight the
|
||
// chroma boost, the noise floor and the highlight desaturation — gates on the
|
||
// result, none of them on the ramp itself.
|
||
//
|
||
// The -HL highlight recovery that used to run in LINEAR light ahead of all this
|
||
// is gone with it: HIGHLIGHT is one 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 true white 0.10 down under it
|
||
// rather than onto a flat plateau.
|
||
//
|
||
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji
|
||
// extended DR); 0/auto/DR100 = no extra curve. It is added to the same
|
||
// four amounts the sliders set (bl +0.12, sh +0.06, hl -0.09, wh -0.18),
|
||
// so DR rides the measured curves with them and cannot invert the ramp
|
||
// the way a set of masked terms of its own did.
|
||
// hl - highlight -1..1: the measured HIGHLIGHT move — ±0.24 at full, on the
|
||
// DEEP tones, and gone by luma 0.73. The slider's name and what it moves
|
||
// are two different things, and the measurement is what is shipped.
|
||
// sh - shadow -1..1: the measured SHADOW move. + is a broad lift that is
|
||
// hardest at the top of the ramp (+0.17 by luma 0.71); - is a bell
|
||
// deepest on the deep tones (-0.16 at 0.22) and dead by 0.73.
|
||
// wh - white point -1..1: the measured WHITE move. + drives everything above
|
||
// luma 0.50 to 1.0 — the move that clips a highlight toward white — and -
|
||
// pulls a true white 0.10 down under it.
|
||
// bl - black point -1..1: the measured BLACK move. + lifts the toe off the
|
||
// floor (+0.115 by luma 0.19, so a faded black); - clips everything below
|
||
// ~0.23 to 0.0 rather than only the very floor.
|
||
// 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 down the ramp the BLACK knob's Hunt-effect chroma mask reaches. The
|
||
// knob's own curve is measured (see TONE_KNOBS); this is the floor the chroma
|
||
// boost reads, and it is the doc's 0.18 — the middle grey it anchors the toe to.
|
||
export const TONE_BLACK_EDGE = 0.18;
|
||
|
||
// ONE TONAL KNOB, as the curve Lightroom actually draws. Measured, not derived.
|
||
//
|
||
// The four knobs were the doc's four compact windows (thay_doi_thong_so_giong_
|
||
// lightroom.md §2), and they did not survive contact with the exports the app is
|
||
// judged against. The doc puts every move inside a quarter of the ramp — BLACKS
|
||
// dead past 0.18, SHADOWS down on the floor, HIGHLIGHTS up on the shoulder — and
|
||
// at full deflection each was worth a few hundredths of luma on that quarter and
|
||
// literally nothing outside it: the "sau khi chỉnh sửa 4 thông số này không đúng,
|
||
// không thay đổi" report. The reference exports say something else entirely: on
|
||
// Main.jpg, WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43),
|
||
// BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), and HIGHLIGHT +100
|
||
// is a +0.24 lift peaking around luma 0.18 that has died by 0.72. Whatever the
|
||
// slider is called, the MOVE is what a knob has to reproduce, so the move is what
|
||
// is stored here.
|
||
//
|
||
// Every curve below is the per-pixel luma the Lightroom export landed on MINUS
|
||
// the base luma, binned by base luma and averaged, at ±100 — measured off the
|
||
// nine reference files in the repo root (Main.jpg, Main-highlight±100.jpg,
|
||
// Main-shadow±100.jpg, Main-white±100.jpg, Main-black±100.jpg), all genuine
|
||
// Lightroom Classic 14.2 exports of the same frame. Read the code values as:
|
||
// out = L + amt * curve(L), clamped, with amt = knob/10.
|
||
//
|
||
// They are drawn STRAIGHT between the knots, which is the same choice the ramp
|
||
// has always made (see the head of TONE_MATH_SKSL) and the right one here: the
|
||
// measurement is a polyline, the eye cannot see a 2-code-value corner, and a
|
||
// smoothstep through the knots would put an S-curve in the darks no export asked
|
||
// for. Knots are placed where the curve bends, ~0.008 of luma is the worst error
|
||
// anywhere against the measured grid, and the last knot is forced to 0.0 on 1.0
|
||
// so a bright frame (this reference has nothing above luma 0.73) cannot inherit a
|
||
// constant lift at the white end. WHITES -100 is the exception that proves it is
|
||
// the shoulder anchor: its tail is -0.10, not 0.
|
||
//
|
||
// Only ±100 was sampled, so a knob at 40 is 0.4 of the same curve. That is
|
||
// Lightroom's own read (its sliders are linear in the move) and it is the only
|
||
// thing the nine files can support. Re-measure with the harness in the checks:
|
||
// render the frame through TONE_SKSL at each knob's ±100 and diff.
|
||
export type TONE_KNOB = 'bl' | 'sh' | 'hl' | 'wh';
|
||
export interface ToneKnobCurve {
|
||
p: number[]; // knot positions, encoded luma, ascending, p[0] = 0 and p[last] = 1
|
||
v: number[]; // the move at +100, in luma, at each knot
|
||
}
|
||
export const TONE_KNOBS: Record<TONE_KNOB, { plus: ToneKnobCurve; minus: ToneKnobCurve }> = {
|
||
// BLACKS. + lifts from the very floor (0.004 at black, so a true 0 does not
|
||
// move) and peaks at 0.115 by luma 0.19. - is the clip: everything below 0.23
|
||
// lands on 0 (the first segment's slope is -0.97 against the ramp's 1.0), then
|
||
// the frame comes back up to meet the prints by 0.51.
|
||
bl: {
|
||
plus: { p: [0, 0.01, 0.025, 0.045, 0.185, 0.645, 0.71, 0.73, 1], v: [0.0042, 0.0131, 0.0649, 0.0876, 0.1155, 0.0442, 0.0301, 0.0138, 0] },
|
||
minus: { p: [0, 0.23, 0.34, 0.37, 0.51, 0.73, 1], v: [-0.0017, -0.2245, -0.253, -0.2502, -0.1518, -0.0707, 0] },
|
||
},
|
||
// SHADOWS. The one knob that reads the way its name does: + lifts everywhere
|
||
// and hardest at the top (+0.173 at 0.71) — a fill that never lets the bright
|
||
// end go; - is a bell deepest on the deep tones (-0.161 at 0.22, dead by 0.73).
|
||
sh: {
|
||
plus: { p: [0, 0.055, 0.185, 0.42, 0.57, 0.705, 0.73, 1], v: [0.0004, 0.0306, 0.063, 0.0941, 0.16, 0.1734, 0.1534, 0] },
|
||
minus: { p: [0, 0.155, 0.22, 0.29, 0.355, 0.395, 0.46, 0.73, 1], v: [-0.0017, -0.1324, -0.1613, -0.1365, -0.1313, -0.0926, -0.0879, 0.0001, 0] },
|
||
},
|
||
// HIGHLIGHTS. Not the shoulder at all: + is a broad lift of the DEEP tones
|
||
// (+0.084 on a true black, +0.237 at 0.19) and - is a band that takes the
|
||
// bright end down (-0.169 at 0.63) and dies on 1.0.
|
||
hl: {
|
||
plus: { p: [0, 0.005, 0.015, 0.19, 0.29, 0.355, 0.405, 0.485, 0.73, 1], v: [0.084, 0.0955, 0.2133, 0.2372, 0.1632, 0.1424, 0.0987, 0.0828, 0.0001, 0] },
|
||
minus: { p: [0, 0.22, 0.4, 0.5, 0.625, 0.695, 0.73, 1], v: [-0.0002, -0.0634, -0.0808, -0.1298, -0.1689, -0.1505, -0.0978, 0] },
|
||
},
|
||
// WHITES. + is the clipping point and nothing else: the move lands on 1.0
|
||
// exactly from luma 0.50 (the tail is 1 - L, drawn by the knots 0.465 -> 0.73
|
||
// -> 1.0) and lifts the deep tones to get there. - is the white anchor, moved
|
||
// down: -0.10 on 1.0, tapering to nothing at the floor.
|
||
wh: {
|
||
plus: { p: [0, 0.01, 0.025, 0.035, 0.05, 0.175, 0.22, 0.35, 0.385, 0.465, 0.73, 1], v: [0.0023, 0.0169, 0.1217, 0.1634, 0.2036, 0.4103, 0.464, 0.5295, 0.5647, 0.534, 0.27, 0] },
|
||
minus: { p: [0, 0.495, 0.7, 0.73, 1], v: [0, -0.0294, -0.0609, -0.0831, -0.1] },
|
||
},
|
||
};
|
||
|
||
// One knot table as a polyline: the first value, plus one hinge per segment.
|
||
// Written as a SUM of clamps rather than a loop because SkSL indexes arrays by
|
||
// constant only (see the mixer at the foot of TONE_SKSL), and generated from the
|
||
// table so the source and the shader cannot drift.
|
||
const knobChain = (c: ToneKnobCurve): string =>
|
||
c.v
|
||
.map((v, i) =>
|
||
i === 0
|
||
? v.toFixed(4)
|
||
: ` + (${(v - c.v[i - 1]).toFixed(4)}) * clamp((L - ${c.p[i - 1].toFixed(3)}) / ${(c.p[i] - c.p[i - 1]).toFixed(3)}, 0.0, 1.0)`
|
||
)
|
||
.join('');
|
||
|
||
// One knob, both directions, as the shader reads it: the move is the knot table
|
||
// on its own side of zero scaled by the amount, and the ramp is the move added
|
||
// to L and clamped. The clamp is what keeps a -- knob's own clip a plateau
|
||
// instead of a fold.
|
||
const KNOB_SKSL = (['bl', 'sh', 'hl', 'wh'] as const)
|
||
.map(
|
||
(k) => `float tone${k}(float L, float amt) {
|
||
return clamp(L + (amt >= 0.0 ? amt * (${knobChain(TONE_KNOBS[k].plus)}) : -amt * (${knobChain(TONE_KNOBS[k].minus)})), 0.0, 1.0);
|
||
}`
|
||
)
|
||
.join('\n');
|
||
|
||
// 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 = `
|
||
// THE FOUR TONAL KNOBS, as the curves the reference exports measure (TONE_KNOBS
|
||
// above says where the numbers come from and why they are not the doc's four
|
||
// compact windows any more): each is a polyline on the encoded luma, each is
|
||
// added to the luma it reads and clamped, and the four are applied ONE AFTER THE
|
||
// OTHER instead of summed. A composition of non-decreasing maps is non-decreasing
|
||
// by construction, so the ramp needs no guard whatever the four sliders say — and
|
||
// the knot tables are cut so that L + move is monotone on every segment, which is
|
||
// the property highlight-knee-check sweeps for.
|
||
${KNOB_SKSL}
|
||
// The four moves in the order the exports were read: the toe, then the bell that
|
||
// reads the deep tones, then the one the slider calls HIGHLIGHT, then the white
|
||
// anchor. Each clamps on the way to the next (tone* does it), so a knob that has
|
||
// driven the luma to an end leaves the ones after it nothing to fold.
|
||
float toneCurve(float L, float bl, float sh, float hl, float wh) {
|
||
L = tonebl(L, bl);
|
||
L = tonesh(L, sh);
|
||
L = tonehl(L, hl);
|
||
L = tonewh(L, wh);
|
||
return clamp(L, 0.0, 1.0);
|
||
}
|
||
// 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 safeT = max(t, 0.005);
|
||
float k = t > 0.0004 ? min(o / safeT, 3.5) : 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) / max(hiC - t, 0.0001));
|
||
if (loC < t) k = min(k, o / max(t - loC, 0.0001));
|
||
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) {
|
||
// Edge-aware range falloff: when |t - base| is small (flat/fine texture), edgeW is 1.0.
|
||
// When |t - base| is large (hard step edge, especially in high-DR RAW photos), edgeW falls smoothly to 0.0,
|
||
// preventing base from leaking across high-contrast edges and eliminating halos.
|
||
float diff = abs(t - base);
|
||
float edgeW = 1.0 - smoothstep(0.08, 0.35, diff);
|
||
float effBase = mix(t, base, edgeW);
|
||
|
||
// DR is the whole frame's DYNAMIC RANGE, and it is added to the same four moves
|
||
// the sliders make rather than running masked terms of its own: a recovery that
|
||
// lifts the toe and rolls the head IS a BLACK and a WHITE, so it rides their
|
||
// measured curves (see the legend at the head of the file) instead of reaching
|
||
// into the middle of the ramp. A mask has no such knob and hands in 0, which is
|
||
// what these terms are worth when DR is off on the frame too.
|
||
float o = toneCurve(effBase, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),
|
||
clamp(hl - dr * 0.09, -1.0, 1.0), clamp(wh - dr * 0.18, -1.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 = clamp(o + (t - effBase), 0.0, 1.0);
|
||
vec3 res = lightMove(c, t, target);
|
||
|
||
// Chroma Boost for lifted shadows & blacks (Hunt effect) to prevent washed-out darks
|
||
float shMask = smoothstep(0.02, 0.12, effBase) * (1.0 - smoothstep(0.25, 0.55, effBase));
|
||
float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0);
|
||
float blMask = uBl * uBl * uBl;
|
||
float boost = 1.0 + (shMask * max(sh, 0.0) * 0.35) + (blMask * max(bl, 0.0) * 0.25);
|
||
if (boost > 1.001) {
|
||
res = mix(vec3(target), res, boost);
|
||
}
|
||
|
||
// Desaturate noise floor in extreme darks (< 3% luminance) to prevent noise specks
|
||
float noiseFloor = smoothstep(0.0, 0.03, target);
|
||
res = mix(vec3(target), res, noiseFloor);
|
||
|
||
// Soft roll-off and chroma desaturation for highlights reduction (hl < 0) to avoid dirty gray clouds (100_lightroom.md §6)
|
||
if (hl < 0.0 && target > 0.70) {
|
||
float hlDesat = smoothstep(0.70, 0.98, target) * (-hl) * 0.35;
|
||
res = mix(res, vec3(target), hlDesat);
|
||
}
|
||
|
||
return clamp(res, 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 <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 };
|
||
|
||
// SHARPENING — thay_doi_thong_so_giong_lightroom.md §4.2, all four of its
|
||
// parameters in the one knob the panel has. The doc's SHARPENING is not an
|
||
// unsharp mask: it is an unsharp mask MASKED BY AN EDGE DETECTOR, which is what
|
||
// keeps a flat sky and a cheek from gaining grain, and CORED, which is what
|
||
// keeps a noise speckle from being amplified into a white dot.
|
||
//
|
||
// Image_sharp = Image + Amount x HighPass x Mask
|
||
//
|
||
// EDGE DETECTION is the doc's Sobel magnitude G = sqrt(Gx² + Gy²) on luminance,
|
||
// put through its soft threshold Mask = smoothstep(T, T + 0.1, G). This pass was
|
||
// the same 3x3 kernel with Mask = 1 everywhere and no coring, so every
|
||
// low-contrast pixel — every pixel of a flat sky, every pore of a face — took
|
||
// the same gain an eyelash did, and a rising SHARPENING raised the frame's noise
|
||
// with it. That is the doc's §1 complaint word for word ("khi Sharpen, ảnh nổi
|
||
// đầy sạn hạt cát").
|
||
//
|
||
// The HIGH-PASS is the doc's Radius, held at one image pixel (its 0.7-0.9px for
|
||
// a Retina panel), and it is a LUMINANCE high-pass carried by all three channels
|
||
// rather than a per-channel one: a per-channel kernel sharpens a red edge
|
||
// against a green one and draws a colour fringe down every contour.
|
||
//
|
||
// SHARPEN_CORE is the doc's Detail. Coring is SOFT (a ramp over the threshold,
|
||
// not a cliff), so a detail crossing it is not switched on and off from one
|
||
// pixel to the next.
|
||
//
|
||
// `amount` is signed the way the knob is: the negative side is the caller's
|
||
// blur and never reaches this shader. `px` is one ORIGINAL image pixel in the
|
||
// caller's canvas units, so the preview, the camera worklet and the file all
|
||
// sharpen at the same radius. The three constants are the doc's own shape; the
|
||
// doc leaves their value to the panel, and this panel has one SHARPENING knob,
|
||
// so they are fixed here. No frame has yet asked for a second one.
|
||
export const SHARPEN_CORE = 0.02;
|
||
export const SHARPEN_MASK_LO = 0.1;
|
||
export const SHARPEN_MASK_HI = SHARPEN_MASK_LO + 0.1;
|
||
|
||
export const SHARPEN_SKSL = `
|
||
uniform shader src;
|
||
uniform float a;
|
||
uniform float2 px;
|
||
const float3 SHARPEN_LUM = vec3(0.2126, 0.7152, 0.0722);
|
||
const float SHARPEN_CORE = ${SHARPEN_CORE};
|
||
const float SHARPEN_MASK_LO = ${SHARPEN_MASK_LO};
|
||
const float SHARPEN_MASK_HI = ${SHARPEN_MASK_HI};
|
||
float sharpenLum(vec2 p) {
|
||
return dot(clamp(src.eval(p).rgb, 0.0, 1.0), SHARPEN_LUM);
|
||
}
|
||
vec4 main(vec2 xy) {
|
||
vec2 dx = float2(px.x, 0.0);
|
||
vec2 dy = float2(0.0, px.y);
|
||
float yc = sharpenLum(xy);
|
||
float yl = sharpenLum(xy - dx);
|
||
float yr = sharpenLum(xy + dx);
|
||
float yt = sharpenLum(xy - dy);
|
||
float yb = sharpenLum(xy + dy);
|
||
float ytl = sharpenLum(xy - dx - dy);
|
||
float ytr = sharpenLum(xy + dx - dy);
|
||
float ybl = sharpenLum(xy - dx + dy);
|
||
float ybr = sharpenLum(xy + dx + dy);
|
||
float gx = (ytl + 2.0 * yl + ybl) - (ytr + 2.0 * yr + ybr);
|
||
float gy = (ytl + 2.0 * yt + ytr) - (ybl + 2.0 * yb + ybr);
|
||
float mask = smoothstep(SHARPEN_MASK_LO, SHARPEN_MASK_HI, sqrt(gx * gx + gy * gy));
|
||
float hp = yc - 0.25 * (yl + yr + yt + yb);
|
||
hp *= smoothstep(SHARPEN_CORE, 2.0 * SHARPEN_CORE, abs(hp));
|
||
vec4 c = clamp(src.eval(xy), 0.0, 1.0);
|
||
return vec4(clamp(c.rgb + a * hp * mask, 0.0, 1.0), c.a);
|
||
}
|
||
`;
|
||
|
||
// Named uniforms for <Shader uniforms>, same names as SHARPEN_SKSL declares.
|
||
export function sharpenUniforms(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 safeLo = max(lo, 0.005);
|
||
float scale = (nl + CLARITY_EPS) / (safeLo + CLARITY_EPS);
|
||
float clampedScale = min(scale, 1.0 + max(strength, 0.0) * 2.5);
|
||
vec3 res = c.rgb * clampedScale;
|
||
float noiseFloor = smoothstep(0.0, 0.03, nl);
|
||
res = mix(vec3(nl), res, noiseFloor);
|
||
return vec4(clamp(res, 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;
|
||
|
||
// NOISE REDUCTION — thay_doi_thong_so_giong_lightroom.md §4.1, the colour half.
|
||
// The eye is sensitive to a change in brightness and nearly blind to one in hue
|
||
// at the same scale, so the knob is spent where it costs no detail: the CHROMA
|
||
// comes from a blurred copy of the frame and the LUMA from the frame itself, and
|
||
// a strand of hair comes back exactly where it was. The split is this file's own
|
||
// lightness/chroma one, `rgb - luma`, the same the tone ramp's header describes.
|
||
//
|
||
// Until now the whole frame was blurred instead — `MakeBlur` on the draw, one
|
||
// sigma over all three channels — which is the doc's own §4 warning ("Noise
|
||
// Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da") written into the
|
||
// engine: the knob could not take a colour speckle out without taking the
|
||
// picture's edges with it.
|
||
//
|
||
// `amount` is the share of the blurred chroma: 0 leaves the pixel exactly as it
|
||
// was and 1 hands it the neighbourhood's hue with its own brightness still on
|
||
// it, so the two ends of the knob are the identity and the blur and nothing in
|
||
// between moves a pixel's luma at all.
|
||
//
|
||
// The LUMA half of §4.1 (its bilateral filter) is deliberately not here: it is
|
||
// the half that costs detail, and no frame has yet shown grain the chroma half
|
||
// left behind. ponytail: add it as a second child of this same pass if one does.
|
||
export const NR_SKSL = `
|
||
uniform shader sharp;
|
||
uniform shader blurred;
|
||
uniform float amount;
|
||
const float3 NR_LUM = vec3(0.2126, 0.7152, 0.0722);
|
||
vec4 main(vec2 xy) {
|
||
vec3 s = clamp(sharp.eval(xy).rgb, 0.0, 1.0);
|
||
vec3 b = clamp(blurred.eval(xy).rgb, 0.0, 1.0);
|
||
float ys = dot(s, NR_LUM);
|
||
float yb = dot(b, NR_LUM);
|
||
return vec4(clamp(vec3(ys) + mix(s - vec3(ys), b - vec3(yb), amount), 0.0, 1.0), 1.0);
|
||
}
|
||
`;
|
||
|
||
// How far the chroma filter reaches, as a fraction of the frame's width — the
|
||
// doc's 3..5 pixels of a full-resolution frame, which is 0.4% of it, so a
|
||
// preview and a file average the same share of the picture. The knob's own blur
|
||
// was 0.6 of a pixel at NOISE REDUCTION 100, which is under the doc's patch and
|
||
// under a colour speckle as well.
|
||
export const NR_CHROMA_SPAN = 0.004;
|
||
|
||
// 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);
|
||
vec3 res = (c - a) / t + a;
|
||
float origLum = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||
float newLum = dot(clamp(res, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
|
||
if (amount > 0.0 && origLum > 0.001) {
|
||
float satAdj = clamp(1.0 - (amount * 0.15 * (1.0 - t)), 0.75, 1.25);
|
||
res = mix(vec3(newLum), res, satAdj);
|
||
}
|
||
return vec4(clamp(res, 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`, `hl` and `wh` here are written in the KNOB's unit, not the look's: a
|
||
// stock that wants its toe on 0.18 asks the SHADOW knob for a move of -0.07 at
|
||
// luma 0.25, which is -0.47 on this ramp. They are solved against the MEASURED
|
||
// tables rather than tuned by eye. The stocks were written against 0.18 / 0.22 /
|
||
// 0.17 on the toe and 0.7375 / 0.815 on the head, and the ramp reads them at
|
||
// 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151 — the numbers
|
||
// highlight-knee-check pins. The Acros toe lands 0.0074 under its target because
|
||
// the HIGHLIGHT move reaches down to luma 0.22, so its shoulder lands on the toe
|
||
// as well.
|
||
//
|
||
// A LIFTED head is asked of WHITES and a ROLLED one of HIGHLIGHT, because that
|
||
// is where the measured tables have anything left: the HIGHLIGHT curve is dead
|
||
// by luma 0.73 on the + side (a lift has nothing up there to lift), while its -
|
||
// side still reaches a true white.
|
||
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||
'classic-chrome': { sh: -0.47 },
|
||
// 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.47 },
|
||
'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 names only the
|
||
// two ENDS: a 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. Neither move is a compact window any more — they
|
||
// are the measured curves, so the -0.1143 shoulder does reach down into the
|
||
// midtones as well — and nothing else is asked of the stock: the strength the
|
||
// greys need is its matrix slope (SIM_CONTRAST_BIAS in colorUtils), which is
|
||
// what 'deep black' costs in a colour stock and does not have to cost here.
|
||
// The values move the two end bands of the ramp: -0.20 asks the toe for 0.22
|
||
// (it reads 0.2126, the -0.1143 shoulder reaching down onto it) and -0.1143
|
||
// rolls the head to 0.7397, both read off the measured tables at their knots
|
||
// (see the note above FILM_TONE).
|
||
monochrome: { sh: -0.20, hl: -0.1143 },
|
||
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
|
||
// (0.17 against Acros' 0.22) so the darks reach true black, and a shoulder
|
||
// that LIFTS instead of rolling (+0.26 of WHITES, the head going to 0.815
|
||
// against Acros' 0.7397), 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.
|
||
//
|
||
// SHADOW is at FULL deflection here, and that is not a taste: the WHITES move
|
||
// this stock asks for is measured to lift the darks as well (+0.074 at luma
|
||
// 0.25 on this ramp — a WHITE anchor is the top of a curve, not a window), so
|
||
// the toe only lands on its 0.17 if the crush under it is everything the knob
|
||
// has. It reads 0.1735, which is the floor.
|
||
'mono-high-contrast': { sh: -1.0, wh: 0.26 },
|
||
};
|
||
|
||
// 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 ?? (adj as any).highlights ?? 0) / 10));
|
||
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? (adj as any).shadows ?? 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, as two of the four moves
|
||
// on the same luma (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` / `<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
|
||
);
|
||
}
|