Files
RecipesCam/docker/frontend/shared/utils/toneShader.ts
T
3dtours d7d7be2c6b web: add CLASSIC VIVIDIPES, spliced at the blue row
Classic Chrome's blue row, verbatim, with the red and green rows taken from
VELVIPES: skies keep the muted teal/cyan lean while everything that is not
blue reads loud. The shadow crush rides along from FILM_TONE, since it belongs
to the stock rather than to a row. CLASSIC CHRIPES stays untouched beside it.
2026-09-18 16:09:09 +07:00

283 lines
14 KiB
TypeScript

import { BaseFilter, ColorAdjustments } from '../types';
// 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 only the top (0.65..1.00) so it leaves the greys
// and midtones alone (a 0.45 knee dragged a mid-grey down) while SH rides the
// lower half (0.00..0.55), and the 0.50 midpoint never moves. Earlier narrow
// knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo
// without true whites or blacks.
//
// Both knobs are pure additive shifts of the luma. That keeps the curve
// monotonic (steepest knee slope 4.29/unit * 0.22 = 0.94, and the two knees
// never 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.
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;
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.65, 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.
float o = t + hl * 0.22 * hlMask + 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);
return vec4(clamp(mix(vec3(l2), rgb, kv), 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')
}
// 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 },
};
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 = baseFilter !== 'monochrome';
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;
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),
};
}
// 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];
}
export function toneIsActive(u: ToneUniforms): boolean {
return (
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
);
}