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> = { '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; }