f13fc37b5a
Layout - the panel is a cascade of columns: the rail's tabs, the tab's chips, the open chip's sub-chips, then the ruler. A child column no longer hides the column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one per row instead of wrapping - FRAME's WATERMARK opens its own column, so the frame chips stay put - CREATE RECIPES gets the wide column its two-up form needs WB colour swatches - the ruler draws a colour box under the slider that follows the value: COLOR TEMP is the Kelvin colour (Tanner Helland), TINT runs green -10 -> neutral 0 -> magenta +10 HDF EFFECT - knee 0.55..0.85 -> 0.45..0.75, blur 0.004+0.015n -> 0.006+0.024n of the width, screen alpha 0.15+0.35n -> 0.28+0.52n: a wide halo on the highlights instead of a hairline glow. Web copy of toneShader only — the phone keeps its own tuning. Tabs - rail order is PRESETS, FAVORITED, WB, LIGHT, FX, FRAME, CREATE RECIPES
257 lines
12 KiB
TypeScript
257 lines
12 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.
|
|
// 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 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);
|
|
// 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)
|
|
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-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 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),
|
|
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.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.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
|
|
);
|
|
}
|