fix(sim): rebuild the six film-sim color matrices to match the stock looks

Provia/Velvia/Astia/Classic Chrome/Classic Neg/Acros each get their own
primaries+cross-talk matrix measured against the pipeline, plus a split-tone
stage (shadow/highlight tint uniforms) for Classic Chrome's teal and Classic
Neg's green-cyan shadows vs warm highlights.
This commit is contained in:
2026-09-10 20:57:19 +07:00
parent 93f48537f8
commit 4bb2bda5c8
5 changed files with 139 additions and 53 deletions
+54 -5
View File
@@ -1,4 +1,4 @@
import { ColorAdjustments } from '../types';
import { BaseFilter, ColorAdjustments } from '../types';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader.
@@ -28,13 +28,26 @@ import { ColorAdjustments } from '../types';
// 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. All-zero still = no pass.
// 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;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
@@ -52,6 +65,10 @@ vec4 main(vec2 xy) {
// 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);
// 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);
@@ -68,17 +85,49 @@ export interface ToneUniforms {
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
}
export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
// 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] },
};
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));
return { dr, hl, sh, vib };
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];
return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT };
}
// 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]];
}
export function toneIsActive(u: ToneUniforms): boolean {
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0;
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
);
}