fd38269709
App.tsx passed `sharpen: true` on camera exports, and both engines turned that into a 0.5 unsharp mask whenever the SHARPENING knob sat at its default 0. The 3x3 kernel [0,-a,0; -a,1+4a,-a; 0,-a,0] with a=0.5 has a noise gain of 3.167x at low frequency and 5.0x at Nyquist, so high-ISO files came back far noisier than the viewfinder ever showed. The knob is now the only source of output sharpening: dropping the fallback cuts flat-region noise sigma ~1.6x at ISO ~330 (2.36 vs 4.52) and ~2.0x at ISO ~1900 (0.23 vs 0.46), and the JPEGs are 26-35% smaller. Measured on a Xiaomi 12S Ultra, 3000x4000 captures, same scene: 1x, ISO 335: sigma 2.35, dark-quartile 1.56, flat-tile 0.349 10x, ISO 2119: sigma 1.09, dark-quartile 0.54, flat-tile 0.225 The SHARPENING knob still sharpens as before when dialled above 0.
138 lines
6.3 KiB
TypeScript
138 lines
6.3 KiB
TypeScript
// P5 native export path: mirrors exportEngine.processAndExportPhoto's pipeline
|
|
// contract (same recipe math, same output file/gallery behavior) but renders
|
|
// inside the RecipesCamExport Kotlin module — decode/crop/tone/cinema/matrix/
|
|
// denoise/clarity/grain/frame/GPS/sharpen run off the JS thread and the JPEG is
|
|
// encoded straight to disk (DPI header patched natively). JS only computes the
|
|
// parameter set with the same helpers exportEngine uses, then saves to gallery.
|
|
// Deliberately does NOT import exportEngine or Skia: when the native path is
|
|
// active the Skia engine must not even load.
|
|
import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export';
|
|
import * as MediaLibrary from 'expo-media-library/legacy';
|
|
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments } from '../types';
|
|
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 {
|
|
uri: string;
|
|
savedToLibrary: boolean;
|
|
assetId?: string | null;
|
|
}
|
|
|
|
export interface NativeExportOptions {
|
|
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
|
|
dpi?: number;
|
|
// True for photos captured through the live camera: the AE bias already
|
|
// applied exposure compensation at capture, so no 2^EV matrix gain is added.
|
|
evFromCamera?: boolean;
|
|
// Composition aspect-ratio crop ('full'/undefined = none). Native computes the
|
|
// center-largest rect after decode because the pixel size is only known there.
|
|
aspect?: AspectRatio;
|
|
// Custom text watermark overlay — position fractions of the photo area, same
|
|
// contract as ExportOptions.watermark. SHIPPED but NOT drawn yet: the Kotlin
|
|
// module renders geo captions but has no text-watermark draw (ponytail: add
|
|
// it when native parity with the Skia engine is completed).
|
|
watermark?: { text: string; x: number; y: number } | null;
|
|
// Gallery filename + EXIF, same contract as ExportOptions.exported /
|
|
// .capturedAt: RC_<ts>_exported.jpg keeps the loaded photo's own EXIF.
|
|
exported?: boolean;
|
|
capturedAt?: number | null;
|
|
}
|
|
|
|
// The Kotlin module reads plain absolute paths, not file:// URIs.
|
|
const stripScheme = (uri: string) => uri.replace(/^file:\/\//, '');
|
|
|
|
export async function processAndExportPhotoNative(
|
|
sourceUri: string,
|
|
recipe: Recipe,
|
|
frameId: FrameId,
|
|
useGeotag: boolean,
|
|
gpsInfo: GPSInfo | null,
|
|
options?: NativeExportOptions
|
|
): Promise<NativeExportResult | null> {
|
|
try {
|
|
const adjustments: ColorAdjustments = recipe.adjustments;
|
|
// Same matrix rule as exportEngine #3: camera captures skip 2^EV (hardware
|
|
// AE bias already applied it), library stills get the matrix gain.
|
|
const matrix = options?.evFromCamera
|
|
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
|
: applyExposureGain(
|
|
getSkiaColorMatrix(recipe.baseFilter, adjustments),
|
|
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)
|
|
// 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 (-10..10) is the
|
|
// only source — the old 0.5 fallback on every camera capture multiplied
|
|
// sensor noise ~3.2x and made dim/high-ISO shots unusable.
|
|
const sharpKnob = adjustments.sharpening ?? 0;
|
|
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
|
|
const aspect: '4:3' | '3:2' | undefined =
|
|
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
|
|
? options.aspect
|
|
: undefined;
|
|
|
|
const adjust: PhotoAdjust = {
|
|
toneDr: tone.dr,
|
|
toneHl: tone.hl,
|
|
toneSh: tone.sh,
|
|
cinema: cinema ? cinema.flat : null,
|
|
denoise: adjustments.denoise,
|
|
clarity: adjustments.clarity,
|
|
grain: adjustments.grain,
|
|
quality: 95, // engine encodes at fixed JPEG 95
|
|
frameId,
|
|
// Fonts/artwork are bundled in the APK (res/raw assets_*, drawable
|
|
// wallframe) — native reads them directly; no base64 bridge needed.
|
|
geo:
|
|
useGeotag && gpsInfo
|
|
? { locality: gpsInfo.locality || 'STREET VIEW', timestamp: gpsInfo.timestamp }
|
|
: null,
|
|
sharpen: sharpenAmount > 0 ? sharpenAmount : undefined,
|
|
// Negative knob -> Kotlin soften blur, pre-grain (engine step 4).
|
|
soften: sharpKnob < 0 ? (Math.abs(sharpKnob) / 10) * 3 : undefined,
|
|
dpi: options?.dpi && options.dpi > 0 ? options.dpi : undefined,
|
|
aspect,
|
|
};
|
|
|
|
const exportFile = nextPhotoPath(options?.exported === true); // file:// URI (cacheDirectory)
|
|
const dstPath = stripScheme(exportFile);
|
|
await RecipescamExport.processPhotoAsync(stripScheme(sourceUri), dstPath, matrix, null, adjust);
|
|
// Same EXIF contract as the Skia engine (step 9c). ponytail: pixel
|
|
// dimensions are left out here — Kotlin knows the rendered size, JS does not.
|
|
await stampExifOnFile(
|
|
exportFile,
|
|
{
|
|
dateTime: options?.capturedAt ? new Date(options.capturedAt) : null,
|
|
gps: useGeotag && gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null,
|
|
dpi: options?.dpi ?? null,
|
|
},
|
|
sourceUri
|
|
);
|
|
|
|
const mediaGranted = await ensureMediaLibraryPermission();
|
|
if (mediaGranted) {
|
|
try {
|
|
const asset = await MediaLibrary.createAssetAsync(exportFile);
|
|
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
|
|
} catch (e) {
|
|
console.warn('Save to gallery failed:', e);
|
|
}
|
|
} else {
|
|
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
|
}
|
|
return { uri: exportFile, savedToLibrary: false };
|
|
} catch (error) {
|
|
console.error('Error during native photo processing and export:', error);
|
|
return null;
|
|
}
|
|
}
|