fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish

This commit is contained in:
2026-09-08 10:25:34 +07:00
parent 515f408c67
commit 8eca31ce58
25 changed files with 1450 additions and 822 deletions
+47 -11
View File
@@ -1,3 +1,7 @@
import { ColorAdjustments } from '../types';
export type BaseFilter = 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
// 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).
@@ -23,16 +27,21 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number }
return { r, g, b };
}
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and adjustments
export function getSkiaColorMatrix(
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none',
exposure: number, // -10 to +10
contrast: number, // -10 to +10
saturation: number, // -10 to +10
temperature: number, // 2500 to 10000
tint: number, // -10 to +10
colorChrome: 'none' | 'weak' | 'strong'
): number[] {
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the
// exposure-compensation EV gain are intentionally NOT part of the matrix —
// dynamicRange/highlight/shadow run as a tone shader (see toneShader.ts),
// and EV is applied either as hardware AE bias (camera) or by the caller via
// applyExposureGain (library), never doubled.
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
const exposure = adj.exposure;
const contrast = adj.contrast;
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
const colorChrome = adj.colorChrome || 'none';
const colorChromeBlue = adj.colorChromeBlue || 'none';
// 1. Start with Identity Matrix
let matrix = [
1, 0, 0, 0, 0,
@@ -110,6 +119,18 @@ export function getSkiaColorMatrix(
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);
}
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (baseFilter !== 'monochrome') {
const rgbTemp = kelvinToRGB(temperature);
@@ -146,6 +167,21 @@ export function getSkiaColorMatrix(
return matrix;
}
// Apply photographic EV compensation (stops) as a linear gain: 2^EV on every
// RGB channel (offsets scale too, matching true exposure). Only used for
// stills that were NOT exposed with a hardware AE bias (library images).
export function applyExposureGain(matrix: number[], evStops: number): number[] {
if (!evStops) return matrix;
const s = Math.pow(2, evStops);
const gainMat = [
s, 0, 0, 0, 0,
0, s, 0, 0, 0,
0, 0, s, 0, 0,
0, 0, 0, 1, 0,
];
return multiplyMatrices(gainMat, matrix);
}
// Compute a tint color + opacity from the matrix for camera overlay preview
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
@@ -177,7 +213,7 @@ function multiplyMatrices(a: number[], b: number[]): number[] {
a[r * 5 + 2] * b[2 * 5 + c] +
a[r * 5 + 3] * b[3 * 5 + c];
}
// Handle the 5th column translation translation
// Handle the 5th column translation
result[r * 5 + 4] =
a[r * 5 + 0] * b[0 * 5 + 4] +
a[r * 5 + 1] * b[1 * 5 + 4] +
+42 -1
View File
@@ -1,4 +1,25 @@
import { Recipe } from '../types';
import { Recipe, ColorAdjustments } from '../types';
// Fallback values for every adjustable knob. Old recipes/sessions persisted in
// AsyncStorage lack the newer fields — merge this under stored JSON to keep
// every adjustment well-defined (see storageUtils.getAllRecipes / App session).
export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 5500,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 0,
grain: 0,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
};
export const DEFAULT_RECIPES: Recipe[] = [
{
@@ -17,6 +38,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
clarity: 2,
grain: 4,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
@@ -37,6 +62,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
clarity: 4,
grain: 1,
colorChrome: 'strong',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
@@ -57,6 +86,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
clarity: 1,
grain: 2,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
@@ -77,6 +110,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
clarity: 3,
grain: 6,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
@@ -97,6 +134,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
clarity: -2,
grain: 5,
colorChrome: 'strong',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'classic-white',
useGeotag: true,
+102 -29
View File
@@ -1,12 +1,14 @@
import { Skia, ImageFormat, BlendMode, TileMode, type SkImage } from '@shopify/react-native-skia';
import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia';
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import * as MediaLibrary from 'expo-media-library/legacy';
import { Asset } from 'expo-asset';
import { Recipe, GPSInfo, FrameId } from '../types';
import { getSkiaColorMatrix } from './colorUtils';
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
import { drawFrameOnCanvas } from './frameUtils';
import { formatCoordinate } from './locationUtils';
import { base64ToBytes, bytesToBase64, patchJpegDpi } from './jpegDpi';
import { patchJpegDpi } from './jpegDpi';
export interface ExportResult {
uri: string;
@@ -19,12 +21,24 @@ export interface ExportOptions {
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
// final pixels before encoding. Pixels already export at full resolution.
sharpen?: boolean;
// True for photos captured through the live camera: exposure compensation was
// already applied as a hardware AE bias at capture, so no 2^EV matrix gain
// should be added on top. Library stills omit this and get the matrix gain.
evFromCamera?: boolean;
}
// Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3
// convolution whose kernel sums to 1 (brightness preserved), amount ~0.5.
// ponytail: fixed subtle preset tuned for full-res desktop/screen output; turn
// into an amount/radius UI control if users ask for adjustable strength.
// GPU-backed surface when the Skia GPU context is available (convolutions/sharpen
// run on the Adreno shader cores instead of the CPU — big win on 50MP captures),
// falling back to the CPU raster surface. MakeOffscreen returns null when no GPU
// context exists yet, so the fallback keeps this safe in library-only cold starts.
function createSurface(width: number, height: number): SkSurface | null {
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
}
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
const w = image.width();
const h = image.height();
@@ -34,7 +48,7 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
[0, -amount, 0, -amount, center, -amount, 0, -amount, 0],
1, 0, 1, 1, TileMode.Clamp, false, null, null
);
const surf = Skia.Surface.Make(w, h);
const surf = createSurface(w, h);
if (!surf) return image; // fall back to unsharpened on allocation failure
const paint = Skia.Paint();
paint.setImageFilter(filter);
@@ -58,6 +72,10 @@ export async function processAndExportPhoto(
gpsInfo: GPSInfo | null,
options?: ExportOptions
): Promise<ExportResult | null> {
const t0 = Date.now();
// Direct console.error templates (user log captures are E-level only). Steps
// use full static prefixes — release minification dropped step literals when
// they were passed through a wrapper, so no indirection here.
try {
// 1. Read source image file into Skia
const skiaData = await Skia.Data.fromURI(sourceUri);
@@ -71,12 +89,14 @@ export async function processAndExportPhoto(
console.error('Failed to parse image from URI');
return null;
}
console.error(`[export] decode ${Date.now() - t0}ms`);
const width = skImage.width();
const height = skImage.height();
console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`);
// 2. Create offscreen canvas
const surface = Skia.Surface.Make(width, height);
// 2. Create offscreen canvas (GPU-backed when available — see createSurface)
const surface = createSurface(width, height);
if (!surface) {
console.error('Failed to create Skia surface');
return null;
@@ -87,19 +107,48 @@ export async function processAndExportPhoto(
const adjustments = recipe.adjustments;
// 3. Build Color Matrix Filter
const matrix = getSkiaColorMatrix(
recipe.baseFilter,
adjustments.exposure,
adjustments.contrast,
adjustments.saturation,
adjustments.temperature,
adjustments.tint,
adjustments.colorChrome
);
const colorFilter = Skia.ColorFilter.MakeMatrix(matrix);
// 3. Build Color Matrix Filter (+2^EV gain for library stills; camera
// captures already got the bias at exposure time → evFromCamera skips it).
const evStops = adjustments.exposureCompensation ?? 0;
const evMatrix = options?.evFromCamera
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
const colorFilter = Skia.ColorFilter.MakeMatrix(evMatrix);
paint.setColorFilter(colorFilter);
// 3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix.
// Keep the built shader in its own variable: only drawRect (which samples
// through the shader) when a real shader exists. If the RuntimeEffect fails
// to compile / makeShaderWithChildren returns null, paint has no shader and a
// drawRect would fill solid opaque color → black/blank output.
const tone = getToneUniforms(adjustments);
const hasTone = toneIsActive(tone);
let toneShader: SkShader | null = null;
if (hasTone) {
let toneEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
try {
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e);
}
if (toneEffect) {
const imageShader = skImage.makeShaderOptions(
TileMode.Clamp,
TileMode.Clamp,
FilterMode.Linear,
MipmapMode.None
);
toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh], [imageShader]);
if (toneShader) {
paint.setShader(toneShader);
} else {
console.error('[export] tone shader build failed - falling back to matrix-only');
}
} else {
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only');
}
}
// 4. Denoise / Clarity Image Filters (export-only enhancements)
let imageFilter = null;
@@ -145,8 +194,16 @@ export async function processAndExportPhoto(
paint.setImageFilter(imageFilter);
}
// 5. Draw primary image with color filters & enhancements
canvas.drawImage(skImage, 0, 0, paint);
// 5. Draw primary image with color filters & enhancements. Tone path samples
// the image through the paint shader (child imageShader) with a rect so the
// whole chain (tone → matrix → denoise/clarity) applies — but only when a
// real shader was built; otherwise plain drawImage (never drawRect on a
// shader-less paint, which paints solid color).
if (toneShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
} else {
canvas.drawImage(skImage, 0, 0, paint);
}
// 6. Add Monochrome Grain Overlay
if (adjustments.grain > 0) {
@@ -215,27 +272,42 @@ export async function processAndExportPhoto(
}
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
console.error(`[export] composite ${Date.now() - t0}ms`);
let resultImage = surface.makeImageSnapshot();
if (options?.sharpen) {
resultImage = screenSharpenImage(resultImage);
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
}
let base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);
console.error(`[export] sharpen ${Date.now() - t0}ms`);
// Encode straight to raw JPEG bytes — no base64. The old path round-tripped
// the whole 12MP frame through JS base64 decode/encode for the DPI patch and
// then writeAsStringAsync, which alone took ~6s of the render.
let bytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
if (!base64) {
if (!bytes || bytes.length === 0) {
console.error('Failed to encode image to JPEG');
return null;
}
console.error(`[export] encode ${Date.now() - t0}ms`);
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested —
// in-place Uint8Array work, no base64 round-trip.
if (options?.dpi && options.dpi > 0) {
const patched = patchJpegDpi(base64ToBytes(base64), options.dpi);
base64 = bytesToBase64(patched);
bytes = patchJpegDpi(bytes, options.dpi);
}
// 10. Write binary bytes to temporary local file (overwrites previous export)
await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, {
encoding: FileSystem.EncodingType.Base64,
});
try {
new File(EXPORT_FILE).write(bytes);
} catch (e) {
console.error('Failed to write export file: ' + e);
return null;
}
console.error(`[export] write ${Date.now() - t0}ms`);
console.error(`[export] total-done ${Date.now() - t0}ms`);
// 11. Request Media Library permission & Save to device gallery
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
@@ -249,6 +321,7 @@ export async function processAndExportPhoto(
} else {
console.warn('Media Library permission denied. Image saved to temporary cache only.');
}
console.error(`[export] gallery-save ${Date.now() - t0}ms`);
return { uri: EXPORT_FILE, savedToLibrary: false };
} catch (error) {
+4 -2
View File
@@ -1,6 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Recipe } from '../types';
import { DEFAULT_RECIPES } from './defaultRecipes';
import { DEFAULT_RECIPES, DEFAULT_ADJUSTMENTS } from './defaultRecipes';
const CUSTOM_RECIPES_KEY = '@camrecipe_pro:custom_recipes';
@@ -17,7 +17,9 @@ export async function getCustomRecipes(): Promise<Recipe[]> {
export async function getAllRecipes(): Promise<Recipe[]> {
const custom = await getCustomRecipes();
return [...DEFAULT_RECIPES, ...custom];
// Backfill newer adjustment fields onto recipes persisted before they existed.
const fill = (r: Recipe): Recipe => ({ ...r, adjustments: { ...DEFAULT_ADJUSTMENTS, ...r.adjustments } });
return [...DEFAULT_RECIPES.map(fill), ...custom.map(fill)];
}
export async function saveCustomRecipe(recipe: Omit<Recipe, 'id' | 'isCustom'>): Promise<Recipe> {
+74
View File
@@ -0,0 +1,74 @@
import { ColorAdjustments } from '../types';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader, driven by luma so hue is
// preserved (single multiplicative factor per pixel).
//
// Soft-knee masks keep the knobs independent, like camera tone curves:
// hl affects ONLY the bright end (knee 0.80..1.00),
// sh affects ONLY the dark end (knee 0.00..0.30),
// leaving the whole 0.30..0.80 mid/upper band untouched. Earlier knees
// (0.40..0.95 / 0.05..0.55, then 0.55..1.0 / 0.0..0.45, then 0.70..1.0) still
// reached mid/upper grays, so HL read as a global gray/brightness change
// instead of isolating the bright end — on-device testing confirmed it.
//
// HL is multiplicative on BOTH signs so +/− act on the same region:
// + scales 0.85+ up toward white (natural clip at 1.0), − scales them down.
// A + additive lift using (1-t) dies at t→1.0 and never touched the specular
// end — that asymmetry made +HL look like a mid-tone push, not a highlight
// change. (SH keeps its original form; additive + worked there.)
//
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down.
// sh - shadow -1..1: + lifts the dark side, - deepens it.
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
uniform float hl;
uniform float sh;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
float hlMask = smoothstep(0.80, 1.0, t);
float shMask = 1.0 - smoothstep(0.0, 0.30, t);
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
float o = t;
// Highlight: bright end only, multiplicative on both signs (symmetric region).
if (hl >= 0.0) {
o *= 1.0 + hl * 0.30 * hlMask;
} else {
o *= 1.0 + hl * 0.45 * hlMask;
}
// Shadow: dark end only.
if (sh >= 0.0) {
o += sh * 0.80 * shMask * (1.0 - t);
} else {
o *= 1.0 + sh * 0.80 * shMask;
}
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
o += dr * 0.12 * shMask * (1.0 - t);
o -= dr * 0.18 * hlMask * t;
float f = clamp(o / max(t, 0.0004), 0.0, 4.0);
return vec4(clamp(c.rgb * f, 0.0, 1.0), c.a);
}
`;
export interface ToneUniforms {
// All zero → no tone adjustment needed (caller can skip the shader pass).
dr: number; // 0..1
hl: number; // -1..1 (adjustments.highlight / 10)
sh: number; // -1..1 (adjustments.shadow / 10)
}
export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
const drRaw = adj.dynamicRange ?? 'auto';
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
return { dr, hl, sh };
}
export function toneIsActive(u: ToneUniforms): boolean {
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0;
}