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:
+102
-29
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user