281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { ColorAdjustments, BaseFilter } from '../types';
|
|
|
|
// Kelvin to RGB compensation for White Balance.
|
|
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
|
|
// the scene light is COLD and compensates with WARM tones (red/orange).
|
|
// When WB is set LOW (e.g. 2500K), the camera compensates with COOL tones (blue).
|
|
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
|
|
const k = Math.min(10000, Math.max(2500, kelvin));
|
|
let r = 1.0, g = 1.0, b = 1.0;
|
|
|
|
if (k < 5500) {
|
|
// 2500K → 5500K: Warm light → need blue/cyan compensation
|
|
const factor = (5500 - k) / 3000;
|
|
r = 1.0 - factor * 0.45;
|
|
g = 1.0 - factor * 0.15;
|
|
b = 1.0 + factor * 0.50;
|
|
} else {
|
|
// 5500K → 10000K: Cool light → need warm/amber compensation
|
|
const factor = (k - 5500) / 4500;
|
|
r = 1.0 + factor * 0.55;
|
|
g = 1.0 + factor * 0.20;
|
|
b = 1.0 - factor * 0.40;
|
|
}
|
|
|
|
return { r, g, b };
|
|
}
|
|
|
|
// 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;
|
|
const saturation = adj.saturation;
|
|
const temperature = adj.temperature;
|
|
const tint = adj.tint;
|
|
const colorChrome = adj.colorChrome || 'none';
|
|
const colorChromeBlue = adj.colorChromeBlue || 'none';
|
|
|
|
// 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: high contrast, desaturated reds/greens, warm/amber cast
|
|
matrix = [
|
|
1.15, -0.05, -0.05, 0, 0.05,
|
|
-0.05, 1.05, -0.05, 0, 0.02,
|
|
-0.08, -0.08, 0.95, 0, -0.02,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
} else if (baseFilter === 'provia') {
|
|
// Provia: standard natural colors, balanced tones
|
|
matrix = [
|
|
1.02, 0, 0, 0, 0,
|
|
0, 1.02, 0, 0, 0,
|
|
0, 0, 1.02, 0, 0,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
} else if (baseFilter === 'velvia') {
|
|
// Velvia: vivid colors, high saturation, deep blacks
|
|
matrix = [
|
|
1.25, -0.05, -0.05, 0, -0.02,
|
|
-0.05, 1.25, -0.05, 0, -0.02,
|
|
-0.05, -0.05, 1.25, 0, -0.02,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
} else if (baseFilter === 'classic-chrome') {
|
|
// Classic Chrome: muted/deep color with strong tone — desaturated reds and
|
|
// greens, slight warm amber in highlights, shadows stay soft (approx).
|
|
matrix = [
|
|
1.12, -0.03, -0.06, 0, 0.04,
|
|
-0.04, 1.0, -0.03, 0, 0.03,
|
|
-0.05, -0.06, 0.86, 0, -0.02,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
} else if (baseFilter === 'astia') {
|
|
// Astia: soft, gentle, flattering — low contrast, pastel-leaning colors.
|
|
matrix = [
|
|
1.0, 0.02, 0.02, 0, 0.01,
|
|
0.01, 1.0, 0.02, 0, 0.02,
|
|
0.01, 0.01, 1.02, 0, 0.02,
|
|
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') {
|
|
// Leica digital look (M/Q/SL): neutral-but-warm, deep yet open shadows,
|
|
// restrained saturation with deep reds and true greens — never the punchy
|
|
// chroma of Velvia or the amber of Classic Neg. A linear matrix cannot do
|
|
// the smooth highlight rolloff (that's the tone shader's job), so this only
|
|
// carries the mild contrast/warmth/colour balance half:
|
|
// - gentle contrast around mid-grey (deep blacks, no lifted blacks)
|
|
// - slight warm cast in highlights (R up, B down), near-neutral white
|
|
// - reds a touch deeper, blues slightly muted towards natural skies
|
|
// - green mostly untouched (Leica greens stay true, not olive)
|
|
matrix = [
|
|
1.08, -0.02, -0.01, 0, -0.025,
|
|
-0.015, 1.06, -0.01, 0, -0.017,
|
|
-0.02, -0.015, 1.04, 0, -0.013,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
} else if (baseFilter === 'monochrome') {
|
|
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
|
|
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,
|
|
];
|
|
}
|
|
|
|
// 3. Apply Saturation adjustment (standard color matrix transformation)
|
|
if (baseFilter !== 'monochrome' && 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 effect (enhances deep colors without shifting white point)
|
|
if (baseFilter !== 'monochrome' && colorChrome !== 'none') {
|
|
const factor = colorChrome === 'strong' ? 0.25 : 0.12;
|
|
// Boost reds and blues slightly, deep shadows get more saturated
|
|
const chromeMat = [
|
|
1 + factor, -factor/2, -factor/2, 0, 0,
|
|
-factor/2, 1, -factor/2, 0, 0,
|
|
-factor/2, -factor/2, 1 + factor, 0, 0,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
matrix = multiplyMatrices(chromeMat, matrix);
|
|
}
|
|
|
|
// 4b. Color Chrome Effect Blue — deep-blue richness boost (blue channel only)
|
|
if (baseFilter !== 'monochrome' && colorChromeBlue !== 'none') {
|
|
const factor = colorChromeBlue === 'strong' ? 0.35 : 0.15;
|
|
const blueMat = [
|
|
1, 0, 0, 0, 0,
|
|
0, 1, 0, 0, 0,
|
|
0, 0, 1 + factor, 0, 0,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
matrix = multiplyMatrices(blueMat, matrix);
|
|
}
|
|
|
|
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
|
if (baseFilter !== 'monochrome') {
|
|
const rgbTemp = kelvinToRGB(temperature);
|
|
// Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B)
|
|
const tintR = tint > 0 ? (tint / 10) * 0.08 : 0;
|
|
const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0;
|
|
const tintB = tint > 0 ? (tint / 10) * 0.08 : 0;
|
|
|
|
const wbMat = [
|
|
rgbTemp.r + tintR, 0, 0, 0, 0,
|
|
0, rgbTemp.g + tintG, 0, 0, 0,
|
|
0, 0, rgbTemp.b + tintB, 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 (baseFilter !== 'monochrome' && (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);
|
|
}
|
|
|
|
// Compute a tint color + opacity from the matrix for camera overlay preview
|
|
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
|
|
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
|
|
const apply = (r: number, g: number, b: number, a: number) => {
|
|
const row = (offset: number) =>
|
|
matrix[offset + 0] * r + matrix[offset + 1] * g + matrix[offset + 2] * b + matrix[offset + 3] * a + matrix[offset + 4];
|
|
return { r: row(0), g: row(5), b: row(10), a: row(15) };
|
|
};
|
|
|
|
// Apply matrix to neutral white to get the filter's color cast
|
|
const white = apply(1, 1, 1, 1);
|
|
// Clamp and compute perceived intensity to determine opacity
|
|
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
|
const intensity = (white.r + white.g + white.b) / 3;
|
|
// Opacity is higher when the filter deviates from neutral
|
|
const deviation = Math.abs(white.r - intensity) + Math.abs(white.g - intensity) + Math.abs(white.b - intensity);
|
|
const opacity = Math.min(0.35, deviation * 2.5);
|
|
return { r: clamp(white.r), g: clamp(white.g), b: clamp(white.b), a: opacity };
|
|
}
|
|
|
|
// 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;
|
|
}
|