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
+114 -3
View File
@@ -48,6 +48,8 @@ uniform float shTb;
uniform float hlTr;
uniform float hlTg;
uniform float hlTb;
uniform float cc;
uniform float ccb;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
@@ -69,6 +71,31 @@ vec4 main(vec2 xy) {
// their own tint, so the two ends of the curve can drift opposite ways
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0);
// Color Chrome / Color Chrome FX Blue: the two stock-dialed colour effects
// DEEPEN what is already chromatic and leave neutrals exactly where they are
// (Fuji: "deeper tone in highly saturated colour"; FX Blue does it for the
// blue/cyan side only). Both therefore need per-pixel chroma — a 4x5 colour
// matrix is one linear map, so any gain it applies also moves greys, and a
// blue-only gain drags the whole white point.
float mxc = max(max(rgb.r, rgb.g), rgb.b);
float mnc = min(min(rgb.r, rgb.g), rgb.b);
// Chroma ratio with a small floor: a near-black pixel with a hair of cast
// has ratio 1.0 but no colour to deepen, and must stay put.
float ccChroma = (mxc - mnc) / max(mxc, 0.10);
// Color Chrome rides the chroma itself: a muted colour barely moves, a vivid
// one gains density. The 0.25 knee keeps skin, haze and pastels untouched.
float ccMask = cc * smoothstep(0.25, 0.85, ccChroma);
// FX Blue: only where blue clearly leads red AND green (so magenta/purple
// stay out), and richest in a bright blue — a dark blue has no tonality left
// to deepen.
float ccbBlue = clamp((rgb.b - rgb.r) * 2.0, 0.0, 1.0) * clamp((rgb.b - 0.5 * (rgb.r + rgb.g) + 0.05) * 3.0, 0.0, 1.0);
float ccbMask = ccb * ccbBlue * smoothstep(0.15, 0.60, ccChroma) * smoothstep(0.20, 0.70, t);
float deep = clamp(ccMask + ccbMask, 0.0, 1.0);
// Density = lightness down with the colour difference riding along, so hue is
// preserved and the colour cannot collapse toward black (same reason the tone
// curve above keeps chroma). A touch of chroma is given up as it deepens.
float l3 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
rgb = clamp(vec3(l3 * (1.0 - 0.28 * deep)) + (rgb - vec3(l3)) * (1.0 - 0.10 * deep), 0.0, 1.0);
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
float mx = max(max(rgb.r, rgb.g), rgb.b);
@@ -79,6 +106,69 @@ vec4 main(vec2 xy) {
}
`;
// Bright Pass Filter for the LIGHT GLOW pass (HDF), SkSL over a child image
// shader — the pattern TONE_SKSL above already proved on device.
//
// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark
// *itself*: a saturated blue (B = 1.0) came out of it fully lit, so a dark blue
// shadow bloomed and a dark saturated colour smeared its hue into the darks.
// Photoshop's Bright Pass filters on the LUMINANCE instead: one knee decides how
// much light a pixel carries, and one gain scales all three channels, so below
// the knee the output is exactly 0.0 (Screen against black = no-op, the shadows
// are untouched) and above it every channel keeps its ratio — the hue cannot
// drift, only the brightness blooms.
//
// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 =
// 0), wide enough that a specular ramps in instead of clipping on/off.
export const GLOW_T0 = 0.55;
export const GLOW_T1 = 0.85;
export const GLOW_SKSL = `
uniform shader src;
uniform float t0;
uniform float t1;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
return vec4(c.rgb * smoothstep(t0, t1, luma), c.a);
}
`;
// Flat uniform buffer for `makeShaderWithChildren` — same order as GLOW_SKSL's
// declarations (t0, t1).
export function glowUniformArray(): number[] {
'worklet';
return [GLOW_T0, GLOW_T1];
}
// Same values for the declarative <Shader> path, which indexes uniforms by
// NAME (a flat array is only valid for the JS makeShaderWithChildren API).
export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 };
// CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4
// builds with MakeMatrixConvolution, re-expressed as a plain shader because RN
// Skia 2.6 exposes no convolution image filter to the declarative JSX writer.
// `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so
// the preview, the camera worklet and the file all sharpen at the same radius.
export const CLARITY_SKSL = `
uniform shader src;
uniform float a;
uniform float2 px;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec4 s = src.eval(xy + float2(0.0, -px.y))
+ src.eval(xy + float2(0.0, px.y))
+ src.eval(xy + float2(-px.x, 0.0))
+ src.eval(xy + float2( px.x, 0.0));
return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a);
}
`;
// Named uniforms for <Shader uniforms>, same names as CLARITY_SKSL declares.
export function clarityUniforms(a: number, pxX: number, pxY: number) {
return { a, px: [pxX, pxY] };
}
export interface ToneUniforms {
// All zero → no tone adjustment needed (caller can skip the shader pass).
dr: number; // 0..1
@@ -87,6 +177,8 @@ export interface ToneUniforms {
vib: number; // -1..1 (adjustments.vibrance / 10)
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
cc: number; // 0..1 Color Chrome depth (0 = 'none')
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
}
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
@@ -108,13 +200,30 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT };
// Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a
// monochrome look, so both are forced off there: the effect is colour-only
// (the preview/export matrix skips them for monochrome for the same reason).
const colour = baseFilter !== 'monochrome';
const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5;
return {
dr,
hl: hl + (film.hl ?? 0),
sh: sh + (film.sh ?? 0),
vib,
shT,
hlT,
cc: chromeDepth(adj.colorChrome),
ccb: blueDepth(adj.colorChromeBlue),
};
}
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
// order must match TONE_SKSL's declarations.
export function toneUniformArray(u: ToneUniforms): number[] {
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2]];
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
}
export function toneIsActive(u: ToneUniforms): boolean {
@@ -128,6 +237,8 @@ export function toneIsActive(u: ToneUniforms): boolean {
u.shT[2] !== 0 ||
u.hlT[0] !== 0 ||
u.hlT[1] !== 0 ||
u.hlT[2] !== 0
u.hlT[2] !== 0 ||
u.cc !== 0 ||
u.ccb !== 0
);
}