Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library

Both flavors build from one JS tree: `lite` hands out the free apk
(com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label
"RecipesCam Pro"). Release is signed from android/keystore.properties, both
files gitignored - lose either and no later build can install over this one.
The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs
rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor
alone would ship a Pro apk that thinks it is Lite.

The dev scaffolding goes: the three src/dev probes and their App effects, the
EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix
side-by-side install hook. 'balanced' stays the shipped capture mode, which is
what those probes measured on the 12S Ultra.

Looking at the picture: WB now converts kelvin through the Planckian locus into
a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old
one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a
recipe loads every knob it carries over the defaults and stays editable, and
RESET hands the panel back to the sim's own values. The in-app library picker,
the ultra-wide native module and recipe share/import land here too.

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
This commit is contained in:
2026-09-14 19:48:44 +07:00
parent 99405514c6
commit 97949e449d
49 changed files with 2050 additions and 659 deletions
+95 -63
View File
@@ -1,28 +1,55 @@
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).
// 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));
let r = 1.0, g = 1.0, b = 1.0;
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)),
]);
}
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;
}
// 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) };
}
return { r, g, 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 };
}
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
@@ -37,8 +64,8 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
const colorChrome = adj.colorChrome || 'none';
const colorChromeBlue = adj.colorChromeBlue || 'none';
// 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 = [
@@ -112,19 +139,36 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
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)
// LEITZ STREETLIFE CLASSIC — the Leica character rather than a saturation
// push. What makes a Leica 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.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,
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') {
// LEITZ STREETLIFE VIVID — the same Leica 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') {
@@ -161,43 +205,31 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
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);
}
// 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 (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;
// 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 = [
rgbTemp.r + tintR, 0, 0, 0, 0,
0, rgbTemp.g + tintG, 0, 0, 0,
0, 0, rgbTemp.b + tintB, 0, 0,
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);