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
+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.