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);
+13 -5
View File
@@ -44,11 +44,19 @@ export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
// Leica digital look (M/Q/SL) is NOT a film stock: neutral-but-warm colour
// with restrained saturation, deep open shadows and gentle highlight rolloff.
// Matrix-only here (like every sim) — the tonal half lives in the leica
// matrix (mild contrast + warm neutrality); users stack DR/tone on top.
{ id: 'sim-leica', name: 'LEITZ STREETLIFE', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } },
// Leica digital look (M/Q/SL) is not a film stock, so it ships as its own
// pair instead of one look that has to compromise:
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
// renderers), never a global saturation boost, and a whisker of grain
// keeps it feeling like film rather than digital sharpness.
// VIVID — the same Leica separation opened up for the digital era: lifted
// exposure, lifted shadows, a little colour. It reads bright and flattering
// straight off the shutter, no editing pass needed.
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
// own adjustments, which every apply path now carries into the panel.
{ id: 'sim-leica', name: 'LEITZ STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
{ id: 'sim-leica-vivid', name: 'LEITZ STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
+9 -1
View File
@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { FrameId } from '../types';
import { IS_PRO_BUILD } from '../provariant';
// Lite (free, forever) vs PRO (one unlock key). The gate is on the FILE, not on
// the preview: a Lite user may pick any preset, frame or watermark, compose
@@ -18,6 +19,10 @@ export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe'];
export const isFreeRecipe = (id: string | null | undefined): boolean =>
!id || id.startsWith('sim-');
// Lite may store its own recipes, up to this many: creating and editing them is
// free, deleting one is PRO — so a Lite list only ever grows to this size.
export const LITE_RECIPE_LIMIT = 3;
// Burned into every Lite export, bottom-right of the photo/frame area. Fractions
// of the stamp canvas, same space the custom mark uses.
export const LITE_MARK = {
@@ -43,8 +48,11 @@ export function proLookInUse(look: Look): string | null {
return null;
}
// The Pro apk is the product: it is unlocked by being the paid build, and its
// SETTINGS sheet never shows a key field. The Lite apk unlocks the same features
// with a keygen key, which is what a buyer of the cheap tier gets instead.
export const loadPro = async (): Promise<boolean> =>
(await AsyncStorage.getItem(PRO_KEY)) === '1';
IS_PRO_BUILD || (await AsyncStorage.getItem(PRO_KEY)) === '1';
// ---- unlock key -------------------------------------------------------------
// Offline product key: 12 payload chars + 4 checksum chars, any punctuation
+59 -35
View File
@@ -5,12 +5,13 @@ import * as MediaLibrary from 'expo-media-library/legacy';
import { Asset } from 'expo-asset';
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray } from './toneShader';
import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray, GLOW_SKSL, glowUniformArray } from './toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
import { writeJpegExif } from './exifWrite';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
import { ensureMediaLibraryPermission } from './mediaPermission';
import RecipescamExport from '../../modules/recipescam-export';
const stripScheme = (uri: string) => uri.replace(/^file:\/\//, '');
@@ -341,49 +342,72 @@ export async function processAndExportPhoto(
// 5b. Highlight Diffusion Filter (HDF): bloom on the bright areas only —
// the software twin of the lens' built-in diffusion filter, Photoshopped
// exactly as described: extract the highlights with a hard contrast curve,
// Gaussian-blur that selection (radius 15..50px), Screen it back over the
// photo at reduced opacity. Screen against a near-black pixel is a no-op, so
// shadows stay where they were while lit areas spread their light.
// exactly as described: extract the highlights with a Bright Pass Filter on
// the LUMINANCE, Gaussian-blur that selection (radius 15..50px), Screen it
// back over the photo at reduced opacity. Below the knee the pass returns
// exact 0.0 and Screen against black is a no-op, so the shadows stay where
// they were while only the lit areas spread their light.
// One 0..10 knob drives both the blur radius and the opacity, and the radius
// is a fraction of the width so any source size blooms alike.
// ponytail: split into radius/amount knobs only if the 1D ramp reads coarse.
if ((adjustments.hdf ?? 0) > 0) {
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.004 + 0.015 * strength); // ~10..50px at 2560 wide
// Highlight extraction: out = 2.5*in - 1.5 -> black below 0.6, speculars
// saturate white. Composed on TOP of the recipe matrix so a monochrome look
// glows white instead of leaking the original colours back through.
const hiMatrix = [
2.5, 0, 0, 0, -1.5,
0, 2.5, 0, 0, -1.5,
0, 0, 2.5, 0, -1.5,
0, 0, 0, 1, 0,
];
// The order is load-bearing and NOT the paint's: a paint color filter runs
// after the paint's image filter, i.e. the blur would smear the recipe
// matrix into the selection instead of pulling the highlights out first —
// measured as a flat +6/255 haze in the shadows with no glow on the
// speculars. Chain both as image filters instead (inner runs first).
const selection = Skia.ImageFilter.MakeColorFilter(
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
null
);
// Bright pass by LUMINANCE (see GLOW_SKSL). The old per-channel matrix let
// a saturated blue through untinted and left a coloured smear in the
// darks; one luma knee + one gain kills both.
let glowEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
try {
glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
} catch (e) {
console.error('GLOW_SKSL compile failed - per-channel fallback: ' + e);
}
const srcShader =
paintShader ??
skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None);
const glowShader =
glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null;
const glowPaint = Skia.Paint();
glowPaint.setBlendMode(BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
selection
)
);
// Same source the base draw used, so the glow sits on the processed pixels.
if (paintShader) {
glowPaint.setShader(paintShader);
if (glowShader != null) {
glowPaint.setShader(glowShader);
// Recipe matrix stays where it always was — inside the filter chain,
// under the blur (inner runs first) — so the bloom lands on GRADED
// pixels: a monochrome recipe glows white instead of leaking the
// original colours back through the highlights.
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
Skia.ImageFilter.MakeColorFilter(colorFilter, null)
)
);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(skImage, 0, 0, glowPaint);
// GLOW_SKSL unavailable: keep the old per-channel curve (composed on TOP
// of the recipe matrix, the order that keeps a monochrome look white).
// A leaky bloom beats no bloom at all.
const hiMatrix = [
2.5, 0, 0, 0, -1.5,
0, 2.5, 0, 0, -1.5,
0, 0, 2.5, 0, -1.5,
0, 0, 0, 1, 0,
];
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
Skia.ImageFilter.MakeColorFilter(
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
null
)
)
);
if (paintShader) {
glowPaint.setShader(paintShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(skImage, 0, 0, glowPaint);
}
}
}
@@ -747,8 +771,8 @@ export async function processAndExportPhoto(
// the saved copy. ponytail: old exports are NOT retired here anymore — the
// in-app photo history still references their cache files for the swipe
// viewer; the OS cache purge reclaims them eventually.
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
if (mediaPermission.granted) {
const mediaGranted = await ensureMediaLibraryPermission();
if (mediaGranted) {
// DCIM/Camera (stock camera location) first: expo-media-library can only
// reach DCIM (root) or Pictures/Camera, so the native module does the
// MediaStore insert. Falls back to the plain expo save if that fails.
+22 -3
View File
@@ -11,9 +11,27 @@ export async function requestLocationPermissions(): Promise<boolean> {
}
}
// The OS permission screen takes the window focus for a moment and the camera
// session goes down with it, so a capture that asks for location comes back
// "Camera is closed" — one ask per shot turned a burst into failed frames.
// Check the status (no UI, cheap) and raise the dialog at most once per run.
let locationAsked = false;
async function ensureLocationPermission(): Promise<boolean> {
try {
if ((await Location.getForegroundPermissionsAsync()).granted) return true;
if (locationAsked) return false;
locationAsked = true;
return (await Location.requestForegroundPermissionsAsync()).status === 'granted';
} catch (error) {
console.error('Error requesting location permissions:', error);
return false;
}
}
export async function getCurrentGPS(): Promise<GPSInfo | null> {
try {
const hasPermission = await requestLocationPermissions();
const hasPermission = await ensureLocationPermission();
if (!hasPermission) return null;
const location = await Location.getCurrentPositionAsync({
@@ -67,8 +85,9 @@ export async function reverseGeocode(latitude: number, longitude: number): Promi
// Foreground permission is not optional here: expo-location's Android
// geocoder throws LocationUnauthorizedException without it. A photo picked
// before the camera ever asked for location therefore resolved to no name at
// all and the stamp fell back to "STREET VIEW" — so ask before every call.
await requestLocationPermissions();
// all and the stamp fell back to "STREET VIEW" — so make sure it is held
// before every call (same once-per-run ask as getCurrentGPS).
await ensureLocationPermission();
try {
const geo = await Location.reverseGeocodeAsync({ latitude, longitude });
if (geo && geo.length > 0) return localityName(geo[0]);
+20
View File
@@ -0,0 +1,20 @@
import * as MediaLibrary from 'expo-media-library/legacy';
// Raising the media permission opens the OS permission screen, which takes the
// window focus for a moment — and the camera session goes down with it. Asking
// once per export therefore turned a burst into frames that came back
// "ImageCaptureException: Camera is closed". Check the status (no UI, cheap)
// and raise the dialog at most once per run.
let asked = false;
export async function ensureMediaLibraryPermission(): Promise<boolean> {
try {
if ((await MediaLibrary.getPermissionsAsync()).granted) return true;
if (asked) return false;
asked = true;
return (await MediaLibrary.requestPermissionsAsync()).granted;
} catch (e) {
console.warn('Media library permission check failed:', e);
return false;
}
}
+8 -4
View File
@@ -12,6 +12,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments } from '../type
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { getToneUniforms } from './toneShader';
import { getCinemaUniforms } from './cinemaShader';
import { ensureMediaLibraryPermission } from './mediaPermission';
import { nextPhotoPath, stampExifOnFile } from './photoMeta';
export interface NativeExportResult {
@@ -65,8 +66,11 @@ export async function processAndExportPhotoNative(
adjustments.exposureCompensation ?? 0
);
// Base filter is passed so a stock's own tone pass is picked up, but the
// Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg) is
// preview/engine only. Nothing in the app calls nativeExport (dev probe).
// Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg)
// and the Color Chrome/Chrome-Blue depths are preview/engine only, so a
// native-export build silently drops those three. ponytail: port them into
// applyTone() when the native path stops being a dev probe
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinema = getCinemaUniforms(recipe.cinema);
// Final screen sharpen (engine #9): the sharpening knob (0..10) overrides
@@ -114,8 +118,8 @@ export async function processAndExportPhotoNative(
sourceUri
);
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
if (mediaPermission.granted) {
const mediaGranted = await ensureMediaLibraryPermission();
if (mediaGranted) {
try {
const asset = await MediaLibrary.createAssetAsync(exportFile);
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
+99
View File
@@ -0,0 +1,99 @@
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
// recipe JSON, so a shared recipe imports back into the app on another phone.
//
// ponytail: obfuscation, not cryptography — the key ships inside the app and
// the keystream is a plain xorshift. It stops hand-editing and casual peeking,
// which is all a shared look needs; swap in expo-crypto/AES at the same two
// functions if the format ever has to resist a determined attacker.
const KEY = 'RecipesCam::recipe-share::v1';
const ALGORITHM = 'xor16-v1';
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'];
const hash = (s: string): number => {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
};
// xorshift32: one 16-bit word of keystream per call.
const stream = (seed: number) => {
let s = seed >>> 0 || 0x9e3779b9;
return () => {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return s & 0xffff;
};
};
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
// with Vietnamese diacritics included) survives the round trip.
const scramble = (text: string, salt: number): string => {
const k = stream(hash(`${KEY}|${salt}`));
let out = '';
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
return out;
};
const unscramble = (hex: string, salt: number): string => {
if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.');
const k = stream(hash(`${KEY}|${salt}`));
let out = '';
for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k());
return out;
};
// The shareable half of a recipe: never the local id, and only known fields, so
// a file from a newer version cannot smuggle anything into storage.
export function exportRecipeXml(recipe: Recipe): string {
const shareable = {
name: recipe.name,
baseFilter: recipe.baseFilter,
adjustments: recipe.adjustments,
frameId: recipe.frameId,
useGeotag: recipe.useGeotag,
};
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
` <payload>${scramble(JSON.stringify(shareable), salt)}</payload>`,
'</recipescam-recipe>',
'',
].join('\n');
}
// Throws with a user-readable reason. Fields are validated and clamped to what
// this build understands: a recipe from a newer app imports as best it can
// instead of poisoning the store.
export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
const payload = /<payload>([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, '');
if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.');
let raw: any;
try {
raw = JSON.parse(unscramble(payload, parseInt(salt, 16)));
} catch {
raw = null;
}
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') {
throw new Error('Recipe file is damaged or from another app.');
}
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
}
+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
);
}