534 lines
22 KiB
TypeScript
534 lines
22 KiB
TypeScript
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, AspectRatio, ASPECT_RATIO_W_H } from '../types';
|
|
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
|
|
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
|
|
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
|
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils';
|
|
import { patchJpegDpi } from './jpegDpi';
|
|
|
|
export interface ExportResult {
|
|
uri: string;
|
|
savedToLibrary: boolean;
|
|
}
|
|
|
|
export interface ExportOptions {
|
|
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
|
|
dpi?: number;
|
|
// 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;
|
|
// Composition aspect-ratio crop (upright 4:3 or 3:2). Center-largest crop
|
|
// applied right after decode — every later step (frame/GPS/sharpen/size)
|
|
// then runs on the cropped pixels, so the export matches the ratio the
|
|
// viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed
|
|
// opening and export size). 'full'/undefined = no crop.
|
|
aspect?: AspectRatio;
|
|
}
|
|
|
|
// 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();
|
|
const center = 1 + 4 * amount;
|
|
const filter = Skia.ImageFilter.MakeMatrixConvolution(
|
|
3, 3,
|
|
[0, -amount, 0, -amount, center, -amount, 0, -amount, 0],
|
|
1, 0, 1, 1, TileMode.Clamp, false, null, null
|
|
);
|
|
const surf = createSurface(w, h);
|
|
if (!surf) return image; // fall back to unsharpened on allocation failure
|
|
const paint = Skia.Paint();
|
|
paint.setImageFilter(filter);
|
|
const c = surf.getCanvas();
|
|
c.clear(Skia.Color('transparent'));
|
|
c.drawImage(image, 0, 0, paint);
|
|
const sharpened = surf.makeImageSnapshot();
|
|
if (!sharpened) return image;
|
|
return sharpened;
|
|
}
|
|
|
|
// Unique filename per export. A fixed path made every export share one URI, and
|
|
// RN's <Image> cache (keyed by URI) then kept showing the previous photo in the
|
|
// thumbnail after a new capture overwrote the file. A fresh URI per export busts
|
|
// that cache. Previous export file is deleted after the new one is written and
|
|
// gallery-saved, so at most one stale temp file can ever exist.
|
|
let lastExportPath: string | null = null;
|
|
const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
|
|
|
|
export async function processAndExportPhoto(
|
|
sourceUri: string,
|
|
recipe: Recipe,
|
|
frameId: FrameId,
|
|
useGeotag: boolean,
|
|
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);
|
|
if (!skiaData) {
|
|
console.error('Failed to load image data from URI');
|
|
return null;
|
|
}
|
|
const skImage0 = Skia.Image.MakeImageFromEncoded(skiaData);
|
|
|
|
if (!skImage0) {
|
|
console.error('Failed to parse image from URI');
|
|
return null;
|
|
}
|
|
console.error(`[export] decode ${Date.now() - t0}ms`);
|
|
|
|
let skImage = skImage0;
|
|
let width = skImage.width();
|
|
let height = skImage.height();
|
|
console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`);
|
|
|
|
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
|
|
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
|
|
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
|
|
// its export is the fixed frame artwork whose opening cover-crops anyway.
|
|
const ratioWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
|
|
if (ratioWH && frameId !== 'wallframe') {
|
|
const curAspect = width / height;
|
|
let cropW = width;
|
|
let cropH = height;
|
|
let srcX = 0;
|
|
let srcY = 0;
|
|
if (curAspect > ratioWH) {
|
|
cropW = Math.round(height * ratioWH);
|
|
srcX = Math.round((width - cropW) / 2);
|
|
} else {
|
|
cropH = Math.round(width / ratioWH);
|
|
srcY = Math.round((height - cropH) / 2);
|
|
}
|
|
const cropSurface = createSurface(cropW, cropH);
|
|
if (!cropSurface) {
|
|
console.error('Failed to create aspect-crop surface');
|
|
return null;
|
|
}
|
|
const cropCanvas = cropSurface.getCanvas();
|
|
cropCanvas.drawImageRect(
|
|
skImage,
|
|
Skia.XYWHRect(srcX, srcY, cropW, cropH),
|
|
Skia.XYWHRect(0, 0, cropW, cropH),
|
|
Skia.Paint()
|
|
);
|
|
const cropped = cropSurface.makeImageSnapshot();
|
|
if (!cropped) {
|
|
console.error('Failed to snapshot aspect crop');
|
|
return null;
|
|
}
|
|
skImage = cropped;
|
|
width = cropW;
|
|
height = cropH;
|
|
console.error(`[export] aspect-crop ${width}x${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;
|
|
}
|
|
|
|
const canvas = surface.getCanvas();
|
|
const paint = Skia.Paint();
|
|
|
|
const adjustments = recipe.adjustments;
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// 3c. Cinema seasonal atmosphere grade (chain: cinema -> tone -> image).
|
|
// All-zero uniforms (no recipe.cinema) → skipped entirely.
|
|
const cinema = getCinemaUniforms(recipe.cinema);
|
|
let paintShader = toneShader;
|
|
if (cinema && cinemaIsActive(cinema.flat)) {
|
|
try {
|
|
const cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
|
if (cinemaEffect) {
|
|
const child = toneShader ?? skImage.makeShaderOptions(
|
|
TileMode.Clamp,
|
|
TileMode.Clamp,
|
|
FilterMode.Linear,
|
|
MipmapMode.None
|
|
);
|
|
const cs = cinemaEffect.makeShaderWithChildren(cinema.flat, [child]);
|
|
if (cs) {
|
|
paintShader = cs;
|
|
paint.setShader(paintShader);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[export] CINEMA_SKSL failed - skipped: ' + e);
|
|
}
|
|
}
|
|
|
|
// 4. Denoise / Clarity Image Filters (export-only enhancements)
|
|
let imageFilter = null;
|
|
|
|
// Denoise (Subtle blur to smooth noise)
|
|
if (adjustments.denoise > 0) {
|
|
const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px
|
|
imageFilter = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
|
|
}
|
|
|
|
// Clarity (Convolution Sharpening if positive)
|
|
if (adjustments.clarity > 0) {
|
|
const sharpAmount = (adjustments.clarity / 10) * 0.8;
|
|
const kernel = [
|
|
0, -sharpAmount, 0,
|
|
-sharpAmount, 1 + 4 * sharpAmount, -sharpAmount,
|
|
0, -sharpAmount, 0,
|
|
];
|
|
const sharpenFilter = Skia.ImageFilter.MakeMatrixConvolution(
|
|
3,
|
|
3,
|
|
kernel,
|
|
1,
|
|
0,
|
|
0,
|
|
0,
|
|
TileMode.Clamp,
|
|
true,
|
|
imageFilter
|
|
);
|
|
if (sharpenFilter) {
|
|
imageFilter = sharpenFilter;
|
|
}
|
|
} else if (adjustments.clarity < 0) {
|
|
// Bloom/mist effect if negative
|
|
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
|
const mistFilter = Skia.ImageFilter.MakeBlur(mistSigma, mistSigma, TileMode.Clamp, imageFilter);
|
|
if (mistFilter) {
|
|
imageFilter = mistFilter;
|
|
}
|
|
}
|
|
|
|
if (imageFilter) {
|
|
paint.setImageFilter(imageFilter);
|
|
}
|
|
|
|
// 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 (paintShader) {
|
|
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) {
|
|
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
|
|
const grainPaint = Skia.Paint();
|
|
grainPaint.setBlendMode(BlendMode.Overlay);
|
|
grainPaint.setAlphaf(grainOpacity);
|
|
|
|
// Procedural noise shader
|
|
const noiseEffect = Skia.RuntimeEffect.Make(`
|
|
vec4 main(vec2 pos) {
|
|
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
|
return vec4(vec3(r), 1.0);
|
|
}
|
|
`);
|
|
const noiseShader = noiseEffect ? noiseEffect.makeShader([]) : null;
|
|
|
|
if (noiseShader) {
|
|
grainPaint.setShader(noiseShader);
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
|
}
|
|
}
|
|
|
|
// 7. Frame. RETRO POLAROID is not a border drawn on the photo: the whole
|
|
// processed picture is composited, uncropped, into the window of a bigger
|
|
// off-white card (real print proportions, see frameUtils) — the export IS
|
|
// the card, with the photo scaled to the window so it keeps ~WIN_W of the
|
|
// card width. WALL FRAME: the export IS the ROTATED frame PNG at its native
|
|
// portrait size (WALLFRAME_W x WALLFRAME_H), with the photo cover-cropped
|
|
// (center) into the fixed 2:3 window (the mat opening of a real frame).
|
|
// Other frames (classic white border, legacy cinematic bars) draw over the
|
|
// photo-sized canvas as before.
|
|
let outputSurface: SkSurface = surface;
|
|
let stampCanvas = canvas;
|
|
let stampW = width;
|
|
let stampH = height;
|
|
let framedWindow: { x: number; y: number; w: number; h: number } | null = null;
|
|
if (frameId === 'polaroid') {
|
|
const photoImg = surface.makeImageSnapshot();
|
|
const outW = width; // card width matches the photo width
|
|
const outH = Math.round(
|
|
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + height * POLAROID_WIN_W
|
|
);
|
|
const cardSurface = createSurface(outW, outH);
|
|
if (!cardSurface) {
|
|
console.error('Failed to create polaroid card surface');
|
|
return null;
|
|
}
|
|
const cardCanvas = cardSurface.getCanvas();
|
|
const bgPaint = Skia.Paint();
|
|
bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card
|
|
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
|
const pl = polaroidLayout(outW, outH, width / height, 'contain');
|
|
const win = pl.window;
|
|
// Window aspect == photo aspect → fills exactly, no letterbox inside.
|
|
const plainPaint = Skia.Paint();
|
|
cardCanvas.drawImageRect(
|
|
photoImg,
|
|
Skia.XYWHRect(0, 0, width, height),
|
|
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
|
plainPaint
|
|
);
|
|
// Thin seam separating image window from the card.
|
|
const seamPaint = Skia.Paint();
|
|
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
|
seamPaint.setStyle(1);
|
|
seamPaint.setStrokeWidth(1);
|
|
cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
|
|
outputSurface = cardSurface;
|
|
stampCanvas = cardCanvas;
|
|
stampW = outW;
|
|
stampH = outH;
|
|
framedWindow = win;
|
|
} else if (frameId === 'wallframe') {
|
|
// Export at the ROTATED frame's native size (the frame is the product, so
|
|
// the file is exactly frame-sized — never the photo's own dimensions).
|
|
const photoImg = surface.makeImageSnapshot();
|
|
const pw = photoImg.width();
|
|
const ph = photoImg.height();
|
|
const outW = WALLFRAME_W; // 3117
|
|
const outH = WALLFRAME_H; // 4000
|
|
const cardSurface = createSurface(outW, outH);
|
|
if (!cardSurface) {
|
|
console.error('Failed to create wallframe surface');
|
|
return null;
|
|
}
|
|
const cardCanvas = cardSurface.getCanvas();
|
|
// Decode the frame PNG artwork (bundled asset → base64, same path as the
|
|
// GPS fonts below — embedded assets may not expose a readable file URI).
|
|
const wfAsset = Asset.fromModule(require('../../wallframe.png'));
|
|
await wfAsset.downloadAsync();
|
|
const wfDataStr = wfAsset.localUri
|
|
? await FileSystem.readAsStringAsync(wfAsset.localUri, { encoding: FileSystem.EncodingType.Base64 })
|
|
: null;
|
|
if (!wfDataStr) {
|
|
console.error('Failed to load wallframe.png data');
|
|
return null;
|
|
}
|
|
const wfImage = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBase64(wfDataStr));
|
|
if (!wfImage) {
|
|
console.error('Failed to decode wallframe.png');
|
|
return null;
|
|
}
|
|
const wl = wallframeLayout(outW, outH, 'fill');
|
|
const win = wl.window;
|
|
// Cover-crop (center): scale the processed photo so it fills the fixed
|
|
// 3:2 window and cut the overflowing edges equally on both sides.
|
|
const plainPaint = Skia.Paint();
|
|
const scale = Math.max(win.w / pw, win.h / ph);
|
|
const srcW = win.w / scale;
|
|
const srcH = win.h / scale;
|
|
cardCanvas.drawImageRect(
|
|
photoImg,
|
|
Skia.XYWHRect((pw - srcW) / 2, (ph - srcH) / 2, srcW, srcH),
|
|
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
|
plainPaint
|
|
);
|
|
// Artwork over the crop: its transparent window reveals the photo, the
|
|
// mat covers the rest. The source PNG is landscape — rotate it 90° CW
|
|
// onto the portrait canvas (same affine as wallframeLayout.drawMatrix).
|
|
cardCanvas.save();
|
|
cardCanvas.translate(outW, 0);
|
|
cardCanvas.rotate(90, 0, 0);
|
|
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
|
cardCanvas.restore();
|
|
outputSurface = cardSurface;
|
|
stampCanvas = cardCanvas;
|
|
stampW = outW;
|
|
stampH = outH;
|
|
framedWindow = win;
|
|
} else {
|
|
drawFrameOnCanvas(canvas, width, height, frameId);
|
|
}
|
|
|
|
// 8. Render GPS Geotag Watermark
|
|
if (useGeotag && gpsInfo) {
|
|
const fontAsset = Asset.fromModule(require('../../assets/Cousine-Regular.ttf'));
|
|
const iconFontAsset = Asset.fromModule(require('../../assets/NotoEmoji-GPS.ttf'));
|
|
await Promise.all([fontAsset.downloadAsync(), iconFontAsset.downloadAsync()]);
|
|
const [fontData, iconFontData] = await Promise.all([
|
|
FileSystem.readAsStringAsync(fontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64 }),
|
|
FileSystem.readAsStringAsync(iconFontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64 }),
|
|
]);
|
|
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(fontData));
|
|
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(iconFontData));
|
|
|
|
if (typeface && iconTypeface) {
|
|
const fontSize = Math.round(stampW * 0.032); // 3.2% of output width
|
|
const font = Skia.Font(typeface, fontSize);
|
|
const iconFont = Skia.Font(iconTypeface, fontSize);
|
|
const textPaint = Skia.Paint();
|
|
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
|
|
const iconPaint = Skia.Paint();
|
|
iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text
|
|
|
|
const locationName = gpsInfo.locality || 'STREET VIEW';
|
|
const d = new Date(gpsInfo.timestamp);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
|
|
|
// Framed (polaroid/wallframe): caption at the bottom-left inside the
|
|
// photo window. Other frames: bottom-left of the image, clear of edge.
|
|
let baseX: number;
|
|
let baseY: number;
|
|
if (framedWindow) {
|
|
baseX = framedWindow.x + framedWindow.w * 0.05;
|
|
baseY = framedWindow.y + framedWindow.h - Math.round(fontSize * 1.4);
|
|
} else {
|
|
baseX = fontSize * 1.2;
|
|
baseY = stampH - fontSize * 1.5;
|
|
}
|
|
|
|
stampCanvas.drawText('📍', baseX, baseY - fontSize * 1.4, iconPaint, iconFont);
|
|
stampCanvas.drawText(locationName, baseX + gap, baseY - fontSize * 1.4, textPaint, font);
|
|
stampCanvas.drawText('📷', baseX, baseY, iconPaint, iconFont);
|
|
stampCanvas.drawText(timestampStr, baseX + gap, baseY, textPaint, font);
|
|
}
|
|
}
|
|
|
|
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
|
console.error(`[export] composite ${Date.now() - t0}ms`);
|
|
let resultImage = outputSurface.makeImageSnapshot();
|
|
// 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);
|
|
}
|
|
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 (!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 —
|
|
// in-place Uint8Array work, no base64 round-trip.
|
|
if (options?.dpi && options.dpi > 0) {
|
|
bytes = patchJpegDpi(bytes, options.dpi);
|
|
}
|
|
|
|
// 10. Write binary bytes to a unique temporary local file. The unique name
|
|
// is load-bearing: a fixed URI would be served from RN's <Image> cache and
|
|
// the thumbnail/preview would keep showing the previous export.
|
|
const exportFile = nextExportFile();
|
|
try {
|
|
new File(exportFile).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();
|
|
if (mediaPermission.granted) {
|
|
try {
|
|
await MediaLibrary.saveToLibraryAsync(exportFile);
|
|
// New file is safely in the gallery — retire the previous export temp.
|
|
if (lastExportPath && lastExportPath !== exportFile) {
|
|
try {
|
|
await FileSystem.deleteAsync(lastExportPath, { idempotent: true });
|
|
} catch (e) {
|
|
console.warn('Cleanup of previous export temp failed:', e);
|
|
}
|
|
}
|
|
lastExportPath = exportFile;
|
|
return { uri: exportFile, savedToLibrary: true };
|
|
} catch (e) {
|
|
console.warn('Save to gallery failed:', e);
|
|
}
|
|
} else {
|
|
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
|
}
|
|
console.error(`[export] gallery-save ${Date.now() - t0}ms`);
|
|
|
|
return { uri: exportFile, savedToLibrary: false };
|
|
} catch (error) {
|
|
console.error('Error during photo processing and export:', error);
|
|
return null;
|
|
}
|
|
}
|