Files
RecipesCam/src/utils/colorUtils.ts
T
3dtours 3d273a3188 Add the B&W HIGH CONTRAST film sim next to ACRIPES
A second black-and-white stock, so it gets its own baseFilter rather than
borrowing Acros': the PHOTO STYLE chips are keyed by baseFilter and the two
greys have to sit side by side.

The look is the B&W MIX plus a push at both ends. The mix rides the matrix — a
non-BT.709 row set (0.38/0.56/0.06, identical rows, sum 1.00) so a red roof
reads bright, a blue sky deep, and the separation is contrast before any curve.
The push rides FILM_TONE (shadow -0.32, highlight +0.26) so the ends move
without touching the midtones, and the midtone slope is SIM_CONTRAST_BIAS (4
contrast units). Every knob still opens at neutral: a sim is colour and tone
only.

Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, the R/B fine-tune) and the
chrome/hue-mixer gate can never half-apply to one of them.

Mirrors the web app's rail (c403dd0).
2026-09-22 19:25:54 +07:00

357 lines
15 KiB
TypeScript

import { ColorAdjustments, BaseFilter } from '../types';
// Kelvin to RGB compensation for White Balance.
// The WB setting names the light the camera is told to expect, so its gain is
// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a
// daylight scene (5500K) and the camera thinks the light is bluer than it is
// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at
// 5500K, the app's neutral reference.
//
// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin:
// - moving the three channels independently (the old one lifted green 20% at
// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow-
// green rather than orange. Here every gain is divided by its own Rec.709
// luma, so a neutral pixel keeps its level and only the cast moves.
// - the excursion was arbitrary. These are the physical ratios of the sRGB
// blackbody colours, tamed by a square root: the raw linear-light ratio
// (2.1x on blue at 2500K) over-corrects once it lands on already tone-mapped
// pixels, which is exactly the domain a 4x5 colour matrix works in.
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
const k = Math.min(10000, Math.max(2500, kelvin));
const light = planckianSRGB(k);
const ref = planckianSRGB(5500);
return normalizeGainLuma([
Math.sqrt(ref.r / Math.max(light.r, 1e-4)),
Math.sqrt(ref.g / Math.max(light.g, 1e-4)),
Math.sqrt(ref.b / Math.max(light.b, 1e-4)),
]);
}
// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit
// of the Planckian locus; exact enough from 1000K up, and exactly neutral where
// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the
// caller.
function planckianSRGB(kelvin: number): { r: number; g: number; b: number } {
const t = kelvin / 100;
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
const g =
t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
const norm = (v: number) => Math.min(1, Math.max(0, v / 255));
return { r: norm(r), g: norm(g), b: norm(b) };
}
// Divide a set of channel gains by their own luma: the CAST moves, the exposure
// does not. Channels still clip where they must — this only keeps a neutral
// pixel sitting at its neutral level.
function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } {
const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2];
const s = luma > 0 ? 1 / luma : 1;
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// A brightness a stock owns, in Contrast-slider units. The knob itself stays
// at 0 (a sim is colour only), so the extra slope is added here, where the
// knob's own gain/offset mapping already lives.
const SIM_CONTRAST_BIAS: Partial<Record<BaseFilter, number>> = {
'mono-high-contrast': 4,
};
// Black-and-white stocks. Their grey ramp is the whole look, so every
// colour-only stage below — saturation, white balance, the R/B fine-tune — is
// skipped for them. One list, asked through one function, so a second B&W
// stock cannot end up half excluded (a stray Kelvin gain on a grey ramp shows
// up as a tint).
const MONO_BASES: BaseFilter[] = ['monochrome', 'mono-high-contrast'];
export const isMonochromeBase = (baseFilter: BaseFilter | undefined): boolean =>
!!baseFilter && MONO_BASES.includes(baseFilter);
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the
// exposure-compensation EV gain are intentionally NOT part of the matrix —
// dynamicRange/highlight/shadow run as a tone shader (see toneShader.ts),
// and EV is applied either as hardware AE bias (camera) or by the caller via
// applyExposureGain (library), never doubled.
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
const exposure = adj.exposure;
const contrast = adj.contrast + (SIM_CONTRAST_BIAS[baseFilter] ?? 0);
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
// colorChrome/colorChromeBlue are deliberately unused here — the chrome
// effects run in the tone shader (per-pixel chroma), not the matrix.
// 1. Start with Identity Matrix
let matrix = [
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 1, 0,
];
// 2. Base Filters
if (baseFilter === 'classic-neg') {
// Classic Neg (Superia-like): restrained colour, higher contrast around
// mid-grey, reds a touch deeper. Its signature split tone — green/cyan
// darks drifting to warm brights — cannot live in a linear matrix (one cast
// would hit both ends), so it ships in FILM_TONE (toneShader.ts) instead.
matrix = [
0.9961, 0.0773, 0.0078, 0, -0.0406,
0.0226, 1.0298, 0.0076, 0, -0.0300,
0.0224, 0.0750, 0.9520, 0, -0.0247,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'provia') {
// Provia (Standard): the reference stock — balanced, faithful, easy on the
// eye. Mild saturation and a shallow contrast lift around mid-grey, with no
// channel cast, so neutrals stay neutral and only the chroma deepens.
matrix = [
1.1492, -0.0901, -0.0091, 0, -0.0250,
-0.0268, 1.0859, -0.0091, 0, -0.0250,
-0.0268, -0.0901, 1.1669, 0, -0.0250,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'velvia') {
// Velvia (Vivid): the loud one — saturation pushed very hard onto green,
// blue and red alike, on top of strong contrast with deep, dense darks.
// Saturated reds, skies and greens are meant to ride the clip; that hard
// edge is the look.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550,
-0.0772, 1.2355, -0.0261, 0, -0.0661,
-0.0775, -0.2603, 1.4756, 0, -0.0689,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-chrome') {
// Classic Chrome: magazine-reportage look. Saturation is pulled well down
// while the G/B rows are crossed so blue skies lean teal/cyan (green comes
// up towards blue, with blue kept high). The heavy shadow crush that goes
// with it is the `sh` entry in FILM_TONE — a matrix alone cannot deepen one
// end of the curve without dragging the rest.
matrix = [
0.7792, 0.2188, 0.0220, 0, -0.0100,
0.0652, 0.7186, 0.2362, 0, -0.0100,
0.0652, 0.3973, 0.5575, 0, -0.0100,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia (Soft): gentler than standard — contrast below neutral (shadows
// lift, highlights ease off) and colour held back, with a faint warm/pink
// bias that flatters skin. The outdoor-portrait stock.
matrix = [
0.9162, 0.0201, 0.0020, 0, 0.0428,
0.0058, 0.9030, 0.0020, 0, 0.0386,
0.0059, 0.0197, 0.8944, 0, 0.0400,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'eterna') {
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
matrix = [
0.94, 0.02, 0.02, 0, 0.02,
0.02, 0.96, 0.02, 0, 0.02,
0.02, 0.02, 0.94, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica') {
// LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation
// push. What makes this file read as one is the separation BETWEEN the
// hues, so the deepening lives in the off-diagonal terms, not in a global
// chroma gain:
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
// every saturated colour gains ~5% through channel separation; Velvia
// pushes 39%, this stays quiet ("dậm đà, không gắt").
// - a small uniform offset deepens the darks: solid shadows that keep their
// texture, no lifted black and no crushed toe.
// - red up / blue down with green held at 1.00 is the warm European cast:
// it lands on skin as warmth, never as the yellow-green a warm gain on
// all three channels would give.
matrix = [
1.060, -0.045, -0.010, 0, -0.010,
-0.030, 1.055, -0.025, 0, -0.008,
-0.025, -0.020, 1.040, 0, -0.005,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica-vivid') {
// LC STREETLIFE VIVID — the same slanted channel separation pushed into the
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
// VIVID is a brighter read of one look rather than a second, neutral one.
// No positive lift here: the brightness is the sim's own exposure value
// (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of
// the clip — white lands at most at 1.000 on the red row.
matrix = [
1.125, -0.100, -0.010, 0, -0.015,
-0.055, 1.110, -0.055, 0, -0.005,
-0.050, -0.070, 1.100, 0, -0.005,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Acros: pure BT.709 grayscale, zero colour leakage, and NO gain or offset.
// The weights sum to 1.0 and the offset is 0, so this matrix is a straight
// luminance readout: nothing clips at either end and every grey step the
// sensor recorded survives. (An earlier 1.10x / -0.05 version plugged every
// black below ~5% and blew every white above ~95% — exactly the fine
// graduation this stock is prized for.) The deep black and the highlight
// shoulder live in FILM_TONE (toneShader.ts), where the curve can be shaped
// one end at a time without touching the midtones. (Grain/sharpness stay
// the user's call — a film sim here is the colour grade only.)
const r = 0.2126;
const g = 0.7152;
const b = 0.0722;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'mono-high-contrast') {
// B&W HIGH CONTRAST — Acros taken through a warm filter, then pushed at the
// ends (the push itself lives in FILM_TONE, and its midtone slope in
// SIM_CONTRAST_BIAS above).
//
// The mix is the B&W MIX step: the channel weights are the matrix's whole
// job here, and they are deliberately not BT.709. Red is nearly doubled
// against its luminance share — so a red roof reads bright, a blue sky and
// foliage read deep, and the separation between them is what gives the ramp
// its contrast before any curve is applied.
//
// ponytail: the weights ride the CHANNELS, while the brief that asked for
// this stock names HUE groups (red 1.15, orange 1.20, blue 0.70). A 4x5
// matrix cannot be hue-selective; if those exact factors are ever wanted,
// that is a per-pixel HSL pass in the tone shader, not a row edit here.
//
// The three rows are identical and sum to 1.00, so a neutral stays the
// neutral the sensor recorded and nothing clips at either end.
const r = 0.38;
const g = 0.56;
const b = 0.06;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
}
// 3. Apply Saturation adjustment (standard color matrix transformation)
if (!isMonochromeBase(baseFilter) && saturation !== 0) {
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
const invS = 1 - s;
const r = 0.213 * invS;
const g = 0.715 * invS;
const b = 0.072 * invS;
const satMat = [
r + s, g, b, 0, 0,
r, g + s, b, 0, 0,
r, g, b + s, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(satMat, matrix);
}
// 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the
// already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a
// matrix gain on R/B is a global saturation push that also moves greys, and a
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
// consumed by getToneUniforms instead.
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (!isMonochromeBase(baseFilter)) {
const rgbTemp = kelvinToRGB(temperature);
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
// (red and blue up, green down), - pushes green (green up, red and blue
// down). The old version only ever ADDED a channel (magenta lifted R+B,
// green lifted G), so the knob brightened the frame instead of tinting it.
// Normalising the product keeps that at a tint shift, not an exposure one.
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
const wbMat = [
wbGain.r, 0, 0, 0, 0,
0, wbGain.g, 0, 0, 0,
0, 0, wbGain.b, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(wbMat, matrix);
}
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
// axes are independent channel gains on top of the Kelvin/tint preset: each
// step nudges its channel ~1.1% toward/away from neutral, matching the
// perceptible size of one Fuji fine-tune step (-9..+9).
const wbRed = adj.wbRed ?? 0;
const wbBlue = adj.wbBlue ?? 0;
if (!isMonochromeBase(baseFilter) && (wbRed !== 0 || wbBlue !== 0)) {
const shiftMat = [
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(shiftMat, matrix);
}
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
if (exposure !== 0 || contrast !== 0) {
const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
const expOffset = (exposure / 10) * 0.15;
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
const conOffset = 0.5 * (1 - conScale);
const adjMat = [
expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale,
0, expScale * conScale, 0, 0, expOffset + conOffset * expScale,
0, 0, expScale * conScale, 0, expOffset + conOffset * expScale,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(adjMat, matrix);
}
return matrix;
}
// Apply photographic EV compensation (stops) as a linear gain: 2^EV on every
// RGB channel (offsets scale too, matching true exposure). Only used for
// stills that were NOT exposed with a hardware AE bias (library images).
export function applyExposureGain(matrix: number[], evStops: number): number[] {
if (!evStops) return matrix;
const s = Math.pow(2, evStops);
const gainMat = [
s, 0, 0, 0, 0,
0, s, 0, 0, 0,
0, 0, s, 0, 0,
0, 0, 0, 1, 0,
];
return multiplyMatrices(gainMat, matrix);
}
// 4x5 Matrix multiplication utility: A * B
function multiplyMatrices(a: number[], b: number[]): number[] {
const result = new Array(20).fill(0);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
result[r * 5 + c] =
a[r * 5 + 0] * b[0 * 5 + c] +
a[r * 5 + 1] * b[1 * 5 + c] +
a[r * 5 + 2] * b[2 * 5 + c] +
a[r * 5 + 3] * b[3 * 5 + c];
}
// Handle the 5th column translation
result[r * 5 + 4] =
a[r * 5 + 0] * b[0 * 5 + 4] +
a[r * 5 + 1] * b[1 * 5 + 4] +
a[r * 5 + 2] * b[2 * 5 + 4] +
a[r * 5 + 3] * b[3 * 5 + 4] +
a[r * 5 + 4];
}
return result;
}