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:
+95
-63
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user