web: the eyedropper reads a colour and the mixer moves that hue band

This commit is contained in:
2026-09-18 17:41:45 +07:00
parent 8a889db069
commit 1b71c0196f
11 changed files with 449 additions and 7 deletions
+87 -1
View File
@@ -1,4 +1,90 @@
import { ColorAdjustments, BaseFilter } from '../types';
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
// The eight bands of the selective-colour mixer, and the anchor hue each one
// stands for (degrees on the HSL wheel). The anchors are unevenly spaced on
// purpose: red/orange/yellow sit 30° apart because the eye separates them
// finely, while the blue side is one 60° stretch. TONE_SKSL builds the same
// eight weights from this table, so the anchor a band is edited at and the
// anchor the shader masks at can never drift apart.
//
// `label` is what the chip prints — the phone's own strings there too, so it is
// not in the i18n dictionary.
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
{ id: 'red', hue: 0, label: 'RED' },
{ id: 'orange', hue: 30, label: 'ORANGE' },
{ id: 'yellow', hue: 60, label: 'YELLOW' },
{ id: 'green', hue: 120, label: 'GREEN' },
{ id: 'aqua', hue: 180, label: 'AQUA' },
{ id: 'blue', hue: 240, label: 'BLUE' },
{ id: 'purple', hue: 280, label: 'PURPLE' },
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
];
// Hue (degrees) the two neighbours of each band sit at, as TONE_SKSL needs
// them. Derived here so the two tables cannot disagree: the tent weight of a
// band falls to 0 at its neighbours' anchors, which makes the eight weights a
// partition of unity on the wheel (they sum to 1 at every hue, so a pixel is
// never counted twice and never falls in a gap).
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
const n = HSL_BANDS.length;
return HSL_BANDS.map((b, i) => {
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
const next = HSL_BANDS[(i + 1) % n].hue;
// Red (0°) wraps: its left neighbour is magenta at 320°, 40° back.
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
});
}
// 0..255 sRGB to HSL. Hue in degrees, saturation and lightness 0..1 — the
// reverse of hslToRgb below, and what the eyedropper readout prints.
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const mx = Math.max(R, G, B);
const mn = Math.min(R, G, B);
const l = (mx + mn) / 2;
const d = mx - mn;
if (d < 1e-6) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let h: number;
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
else if (mx === G) h = ((B - R) / d + 2) * 60;
else h = ((R - G) / d + 4) * 60;
return { h, s, l };
}
// The band a sampled hue belongs to: the nearest anchor. Bands overlap in the
// shader, so this only decides which one the ruler is pointed at.
export function nearestHslBand(hue: number): HslBandId {
const wrapped = ((hue % 360) + 360) % 360;
let best = HSL_BANDS[0];
let bestD = 361;
for (const band of HSL_BANDS) {
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
if (d < bestD) {
bestD = d;
best = band;
}
}
return best.id;
}
// A band triple as stored: integers in -10..10, and an all-zero band is dropped
// so the recipe only carries what the user moved.
const clamp10 = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0);
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const out: Partial<Record<HslBandId, HslBand>> = {};
for (const band of HSL_BANDS) {
const v = (raw as Record<string, unknown>)[band.id];
if (!Array.isArray(v) || v.length !== 3) continue;
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
}
return Object.keys(out).length ? out : undefined;
}
// Kelvin to RGB compensation for White Balance.
// The WB setting names the light the camera is told to expect, so its gain is
@@ -1,5 +1,6 @@
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
import { sanitizeHslBands } from './colorUtils';
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
// recipe JSON, so a shared recipe imports back into the app on another phone.
@@ -94,6 +95,11 @@ export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
// The mixer is the one adjustment that arrives as a nested object, so it is
// the one that needs its own gate: unknown band ids go, every value is
// clamped to the knob range, and a band left at zero is dropped rather than
// carried around.
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
}
+126 -2
View File
@@ -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.
@@ -41,6 +42,24 @@ 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.
// 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.
// 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;
@@ -57,6 +76,54 @@ 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];
// 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);
@@ -117,7 +184,31 @@ 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
// 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;
vec3 acc = vec3(0.0);
${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 * 0.25, 0.0, 1.0);
rgb = hsl2rgb(hsl);
}
return vec4(clamp(rgb, 0.0, 1.0), c.a);
}
`;
@@ -198,6 +289,10 @@ 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 any band is moved (0 skips the whole 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)
}
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
@@ -242,6 +337,26 @@ 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 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;
return {
dr,
hl: hl + (film.hl ?? 0),
@@ -253,17 +368,26 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
hlT,
cc: chromeDepth(adj.colorChrome),
ccb: blueDepth(adj.colorChromeBlue),
hslOn,
hslH,
hslS,
hslL,
};
}
// 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];
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,
];
}
export function toneIsActive(u: ToneUniforms): boolean {
return (
u.hslOn !== 0 ||
u.dr !== 0 ||
u.hl !== 0 ||
u.sh !== 0 ||