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
+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) {