android: port the web studio's HSL selective colour mixer
The rail gains an HSL tab between FX and FRAME, and the tab opens the mixer ported from the web studio: eight hue bands, each with hue/sat/lum knobs on a -10..+10 ruler, plus three whole-image moves behind an IMAGE divider. Band weights are linear tents anchored on HSL_BANDS and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls a neutral red, so an ungated one would ride the RED band), while the whole-image LUM move is added after the gate and still lifts a picture that -SAT has pulled to grey. The mixer runs last in TONE_SKSL, so a band edit is judged on the colour the user actually sampled. The eyedropper is library-only: PICK arms a layer over the picture, a tap reads the rendered pixel off the canvas, points the ruler at the nearest band and hangs a panel carrying that band's three knobs. A live preview has no pixel to read, so in camera mode the chip greys out - the mixer itself still runs there, which is why the camera worklet's tone buffer had to grow from 10 slots to 40: a missed slot would have pushed NaN into every uniform and taken the whole tone pass down, not just HSL. HSL is a LITE feature (the web studio builds it for guests too), and MONOCHROME switches the mixer off outright - a mono stock has no hue to select. Checked on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by +30 deg, -10 on one band's SAT flattens that band and leaves the other seven alone, and a whole-image -SAT drops the camera preview's mean chroma 13.7 -> 0.4 with +LUM still lifting it 116 -> 166. Cut as 1.2.4 / versionCode 6.
This commit is contained in:
+149
-2
@@ -1,4 +1,5 @@
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps } from './colorUtils';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader.
|
||||
@@ -29,6 +30,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
|
||||
// (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.
|
||||
// hslOn/hslH/hslS/hslL - 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 band owns which pixel is decided HERE, per pixel
|
||||
// from the pixel's hue — so unlike everything above, the eight bands are
|
||||
// not one global move and cannot live in a colour matrix. See the band
|
||||
// block at the end of TONE_SKSL.
|
||||
// gh/gs/gl - the mixer's whole-image moves: those same three quantities applied
|
||||
// to the WHOLE frame, so they are just the accumulator's seed values and
|
||||
// every hue takes them at full weight. Lightness is NOT gated on
|
||||
// saturation (unlike the bands), so a frame pulled to grey by -SAT still
|
||||
// answers +LUM.
|
||||
// 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
|
||||
@@ -36,6 +48,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
|
||||
// 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.
|
||||
// The mixer's eight band lines inside TONE_SKSL, generated from HSL_BANDS so the
|
||||
// anchors and gaps in the shader are the numbers the chips were built from. Each
|
||||
// line reads its band's three values at a CONSTANT index — SkSL can only index a
|
||||
// uniform array by a constant, so this block 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;
|
||||
@@ -50,6 +73,57 @@ 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 — exactly what scaling R,G,B gets
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
// How much of a hue one band owns: full at the band's anchor, falling linearly
|
||||
// to zero at the anchors next door (the gaps are uneven — red is 30 deg from
|
||||
// orange and 40 deg from magenta). The linearity is the point: two neighbouring
|
||||
// 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 in a gap, and a hue
|
||||
// sitting on an anchor takes its band's full value instead of part 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);
|
||||
@@ -102,7 +176,36 @@ vec4 main(vec2 xy) {
|
||||
float mn = min(min(rgb.r, rgb.g), rgb.b);
|
||||
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
|
||||
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 on
|
||||
// the colour the user actually sampled from the render.
|
||||
//
|
||||
// Grey pixels are dropped before any weight is read: rgb2hsl hands them hue 0,
|
||||
// so without the gate EVERY neutral pixel in the frame would be taken for pure
|
||||
// red and drift with the red band. Below 8% saturation there is also no hue
|
||||
// worth moving.
|
||||
//
|
||||
// The three accumulators are band values scaled by how much of the hue each
|
||||
// band owns, so a hue halfway between two anchors takes a proportional mix of
|
||||
// both edits — the blend the weights already add up to. Hue is a turn (plus or
|
||||
// minus 30 deg at full), saturation a scale (0 = grey at -10) and lightness an
|
||||
// additive offset (plus or minus 0.25 at full), which is why the ramp cannot
|
||||
// invert.
|
||||
if (hslOn > 0.5) {
|
||||
vec3 hsl = rgb2hsl(rgb);
|
||||
float gate = smoothstep(0.0, 0.08, hsl.y);
|
||||
float hd = hsl.x * 360.0;
|
||||
// The whole-image moves are the seed: every hue takes the hue turn and the
|
||||
// saturation scale at full weight, and the bands add their share on top.
|
||||
// Lightness is added below OUTSIDE the gate, so it still lifts a colour that
|
||||
// -SAT has already pulled 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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -179,6 +282,13 @@ export interface ToneUniforms {
|
||||
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 a band or a whole-image move is set (0 = skip the mixer)
|
||||
hslH: number[]; // 8 x -1..1 in HSL_BANDS order (+/-30 deg hue at full)
|
||||
hslS: number[]; // 8 x -1..1 (saturation scale, -1 = grey)
|
||||
hslL: number[]; // 8 x -1..1 (additive lightness, +/-0.25 at full)
|
||||
gh: number; // -1..1 whole-image hue turn (+/-30 deg 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:
|
||||
@@ -221,6 +331,31 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
!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 keeps three
|
||||
// zeroes and only costs its own slots.
|
||||
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 stock black-and-white has no hue to select on.
|
||||
if (!colour) hslOn = 0;
|
||||
// The mixer's whole-image move: every hue takes it 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),
|
||||
@@ -230,17 +365,29 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
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.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
|
||||
return [
|
||||
u.dr, u.hl, u.sh, u.vib,
|
||||
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
|
||||
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 ||
|
||||
|
||||
Reference in New Issue
Block a user