3d273a3188
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).
357 lines
15 KiB
TypeScript
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;
|
|
}
|