Cut 1.2.3 with a shutter that never waits on GPS

Bound the geotag fix with a 2 s deadline and prewarm it at launch, queue a
shot pressed before the camera session binds and replay it, pass the options
argument capturePhotoToFile requires, save through the native path so the
media permission is not needed, and release the Skia surface and bitmap after
every export. Store the JS bundle uncompressed and load the export pipeline on
demand. Note in RELEASE_NOTES.md that none of this moved the tap threshold.
This commit is contained in:
2026-09-17 10:02:35 +07:00
parent d07dd69f17
commit 76cf4c6d7e
12 changed files with 476 additions and 170 deletions
+230 -96
View File
@@ -1,4 +1,4 @@
import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, FontStyle, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia';
import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, FontStyle, type SkImage, type SkShader, type SkSurface, type SkTypeface } 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';
@@ -113,6 +113,42 @@ function createSurface(width: number, height: number): SkSurface | null {
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
}
// Every Skia surface and image this module allocates is a native buffer — a
// 12MP RGBA surface is ~48MB — held only by a JS local. Hermes does not collect
// them anywhere near fast enough: one session of captures had the app holding
// 174MB of native heap, 125MB of EGL textures and another 218MB swapped out,
// with the device down to ~20MB free, so every tap, capture and gallery save
// page-faulted for seconds. Each buffer is registered with `own` and released
// by hand before the export returns. A snapshot outlives its surface: Skia
// refcounts the pixels.
type SkDisposable = { dispose(): void };
function disposeAll(items: SkDisposable[]): void {
// Reverse creation order: a paint holds the shader/filter it was given, a
// shader holds the image it samples. Same native lifetime either way (Skia
// refcounts), but releasing the dependents first frees the 48MB 12MP buffers
// on this very call instead of on the next Hermes GC.
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
try {
item.dispose();
} catch (e) {
console.warn('Skia dispose failed:', e);
}
}
}
// Free one owned buffer before the finally block runs: drop it from the
// registry so disposeAll does not touch it twice, then dispose it now. Used
// where a pipeline step makes a full-res buffer dead mid-render (a 12MP
// surface is 48MB and the device has ~450MB free while shooting).
function release(owned: SkDisposable[], item: SkDisposable | null): void {
if (!item) return;
const i = owned.indexOf(item);
if (i >= 0) owned.splice(i, 1);
disposeAll([item]);
}
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
const w = image.width();
const h = image.height();
@@ -123,15 +159,46 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
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
if (!surf) { // fall back to unsharpened on allocation failure
filter.dispose();
return image;
}
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;
try {
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;
} finally {
// The snapshot holds its own ref on the pixels, so the full-res copy can go
// now instead of waiting for the caller to finish.
disposeAll([paint, filter]);
surf.dispose();
}
}
// The watermark faces are 300KB of base64 + a FreeType parse apiece, re-done on
// every export. They never change, so parse once and keep them for the life of
// the process (module scope, like Viewfinder's runtime effects).
let watermarkFonts: { typeface: SkTypeface; iconTypeface: SkTypeface } | null = null;
async function loadWatermarkFonts() {
if (watermarkFonts) return watermarkFonts;
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) return null;
watermarkFonts = { typeface, iconTypeface };
return watermarkFonts;
}
export async function processAndExportPhoto(
@@ -142,6 +209,13 @@ export async function processAndExportPhoto(
gpsInfo: GPSInfo | null,
options?: ExportOptions
): Promise<ExportResult | null> {
// Released in the finally below — see SkDisposable.
const owned: SkDisposable[] = [];
// accepts possibly-null factory results so the call sites stay one-liners
const own = <T extends SkDisposable>(obj: T | null | undefined): T => {
if (obj) owned.push(obj);
return obj as T;
};
try {
// 1. Read source image file into Skia
const skiaData = await Skia.Data.fromURI(sourceUri);
@@ -149,17 +223,30 @@ export async function processAndExportPhoto(
console.error('Failed to load image data from URI');
return null;
}
own(skiaData);
const skImage0 = Skia.Image.MakeImageFromEncoded(skiaData);
if (!skImage0) {
console.error('Failed to parse image from URI');
return null;
}
own(skImage0);
// The encoded source bytes are not read again: the decode owns its own
// pixels and EXIF is re-read from the file later.
release(owned, skiaData);
let skImage = skImage0;
// FRAME-tab rotation first: the pixel-dimension swap then propagates
// through the ratio crop, frame layout and cover maths unchanged.
skImage =
applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten) ?? skImage;
// applyPhotoRotation hands back the SAME image when there is no rotation,
// so only a fresh one is owned (owning the input twice would dispose it
// twice).
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
if (rotated && rotated !== skImage) {
own(rotated);
// Un-rotated source: superseded, never read again.
release(owned, skImage);
skImage = rotated;
}
let width = skImage.width();
let height = skImage.height();
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
@@ -195,18 +282,24 @@ export async function processAndExportPhoto(
console.error('Failed to create aspect-crop surface');
return null;
}
own(cropSurface);
const cropCanvas = cropSurface.getCanvas();
cropCanvas.drawImageRect(
skImage,
Skia.XYWHRect(srcX, srcY, cropW, cropH),
Skia.XYWHRect(0, 0, cropW, cropH),
Skia.Paint()
own(Skia.Paint())
);
const cropped = cropSurface.makeImageSnapshot();
if (!cropped) {
console.error('Failed to snapshot aspect crop');
return null;
}
own(cropped);
// Pre-crop image and the crop surface: both superseded, only the
// snapshot enters the render pass.
release(owned, skImage);
release(owned, cropSurface);
skImage = cropped;
width = cropW;
height = cropH;
@@ -218,9 +311,10 @@ export async function processAndExportPhoto(
console.error('Failed to create Skia surface');
return null;
}
own(surface);
const canvas = surface.getCanvas();
const paint = Skia.Paint();
const paint = own(Skia.Paint());
const adjustments = recipe.adjustments;
@@ -230,7 +324,7 @@ export async function processAndExportPhoto(
const evMatrix = options?.evFromCamera
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
const colorFilter = Skia.ColorFilter.MakeMatrix(evMatrix);
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
paint.setColorFilter(colorFilter);
// 3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix.
@@ -247,18 +341,15 @@ export async function processAndExportPhoto(
if (hasTone) {
let toneEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
try {
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
toneEffect = own(Skia.RuntimeEffect.Make(TONE_SKSL));
} catch (e) {
console.error('TONE_SKSL compile failed - falling back to matrix-only: ' + e);
}
if (toneEffect) {
const imageShader = skImage.makeShaderOptions(
TileMode.Clamp,
TileMode.Clamp,
FilterMode.Linear,
MipmapMode.None
const imageShader = own(
skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None)
);
toneShader = toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]);
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
if (toneShader) {
paint.setShader(toneShader);
} else {
@@ -275,15 +366,12 @@ export async function processAndExportPhoto(
let paintShader = toneShader;
if (cinema && cinemaIsActive(cinema.flat)) {
try {
const cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
const cinemaEffect = own(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]);
const child =
toneShader ??
own(skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None));
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [child]));
if (cs) {
paintShader = cs;
paint.setShader(paintShader);
@@ -300,7 +388,7 @@ export async function processAndExportPhoto(
// 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);
imageFilter = own(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null));
}
// Clarity (Convolution Sharpening if positive)
@@ -324,14 +412,14 @@ export async function processAndExportPhoto(
imageFilter
);
if (sharpenFilter) {
imageFilter = sharpenFilter;
imageFilter = own(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;
imageFilter = own(mistFilter);
}
}
@@ -349,7 +437,7 @@ export async function processAndExportPhoto(
imageFilter
);
if (softenFilter) {
imageFilter = softenFilter;
imageFilter = own(softenFilter);
}
}
@@ -368,6 +456,7 @@ export async function processAndExportPhoto(
canvas.drawImage(skImage, 0, 0, paint);
}
// 5b. Highlight Diffusion Filter (HDF): bloom on the bright areas only —
// the software twin of the lens' built-in diffusion filter, Photoshopped
// exactly as described: extract the highlights with a Bright Pass Filter on
@@ -386,16 +475,16 @@ export async function processAndExportPhoto(
// darks; one luma knee + one gain kills both.
let glowEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
try {
glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
glowEffect = own(Skia.RuntimeEffect.Make(GLOW_SKSL));
} catch (e) {
console.error('GLOW_SKSL compile failed - per-channel fallback: ' + e);
}
const srcShader =
paintShader ??
skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None);
own(skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None));
const glowShader =
glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null;
const glowPaint = Skia.Paint();
glowEffect != null ? own(glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
const glowPaint = own(Skia.Paint());
glowPaint.setBlendMode(BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
if (glowShader != null) {
@@ -405,9 +494,11 @@ export async function processAndExportPhoto(
// pixels: a monochrome recipe glows white instead of leaking the
// original colours back through the highlights.
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
Skia.ImageFilter.MakeColorFilter(colorFilter, null)
own(
Skia.ImageFilter.MakeCompose(
own(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)),
own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
)
)
);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
@@ -422,11 +513,20 @@ export async function processAndExportPhoto(
0, 0, 0, 1, 0,
];
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
Skia.ImageFilter.MakeColorFilter(
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
null
own(
Skia.ImageFilter.MakeCompose(
own(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)),
own(
Skia.ImageFilter.MakeColorFilter(
own(
Skia.ColorFilter.MakeCompose(
own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
colorFilter
)
),
null
)
)
)
)
);
@@ -453,7 +553,7 @@ export async function processAndExportPhoto(
);
if (grainAmount > 0) {
const grainOpacity = grainAmount / 20; // up to 0.5 opacity
const grainPaint = Skia.Paint();
const grainPaint = own(Skia.Paint());
grainPaint.setBlendMode(BlendMode.Overlay);
grainPaint.setAlphaf(grainOpacity);
@@ -462,7 +562,7 @@ export async function processAndExportPhoto(
// the same size RELATIVE to the picture as in the preview that tuned it;
// hashing the raw pixel grid made a 4000px file's grain ~4x finer than
// the grain the user had just tuned at 1080px.
const noiseEffect = Skia.RuntimeEffect.Make(`
const noiseEffect = own(Skia.RuntimeEffect.Make(`
uniform float u;
float grainHash(vec2 q) {
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
@@ -478,8 +578,8 @@ export async function processAndExportPhoto(
float g11 = grainHash(i + vec2(1.0, 1.0));
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
}
`);
const noiseShader = noiseEffect ? noiseEffect.makeShader([width / 1080]) : null;
`));
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
if (noiseShader) {
grainPaint.setShader(noiseShader);
@@ -496,21 +596,26 @@ export async function processAndExportPhoto(
// amount + radius/midpoint only if users ask to move the falloff.
if ((adjustments.vignette ?? 0) > 0) {
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
const vignetteShader = Skia.Shader.MakeRadialGradient(
const vignetteShader = own(Skia.Shader.MakeRadialGradient(
Skia.Point(width / 2, height / 2),
Math.hypot(width, height) / 2,
[Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
[0, 0.45, 1],
TileMode.Clamp
);
));
if (vignetteShader) {
const vignettePaint = Skia.Paint();
const vignettePaint = own(Skia.Paint());
vignettePaint.setShader(vignetteShader);
vignettePaint.setAlphaf(0.9 * v);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
}
}
// Every pass so far sampled skImage; the frame, snapshot, sharpen and
// encode steps work off the render surface only, so the decoded source
// (48MB at 12MP) can go before they allocate their own buffers.
release(owned, skImage);
// 7. Frame. RETRO INSTANT 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
@@ -525,8 +630,13 @@ export async function processAndExportPhoto(
let stampCanvas = canvas;
let stampW = width;
let stampH = height;
// Card/frame branches copy the processed photo into a surface of their
// own; from that copy on the 12MP source surface and its snapshot are
// dead, so they are released below instead of at the finally block.
let cardSource: SkImage | null = null;
if (frameId === 'polaroid') {
const photoImg = surface.makeImageSnapshot();
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const outW = width; // card width matches the photo width
// Card proportions follow the UN-rotated source, so rotating the print
// inside the frame never resizes the frame (same rule as the viewfinder).
@@ -544,8 +654,9 @@ export async function processAndExportPhoto(
console.error('Failed to create polaroid card surface');
return null;
}
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const bgPaint = Skia.Paint();
const bgPaint = own(Skia.Paint());
bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
@@ -555,7 +666,7 @@ export async function processAndExportPhoto(
// letterboxed) inside it, never stretched.
// With a framed-window zoom the source rect shrinks to the visible part
// (window keeps showing the same geometry as the viewfinder).
const plainPaint = Skia.Paint();
const plainPaint = own(Skia.Paint());
const pz = options?.frameWindowZoom ?? null;
const pwz = pz && pz.s > 1.001 ? pz : null;
const pVisW = pwz ? width / pwz.s : width;
@@ -580,7 +691,7 @@ export async function processAndExportPhoto(
plainPaint
);
// Thin seam separating image window from the card.
const seamPaint = Skia.Paint();
const seamPaint = own(Skia.Paint());
seamPaint.setColor(Skia.Color('#e5e5e5'));
seamPaint.setStyle(1);
seamPaint.setStrokeWidth(1);
@@ -592,7 +703,8 @@ export async function processAndExportPhoto(
} 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 photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const pw = photoImg.width();
const ph = photoImg.height();
const wallLand = options?.wallframeLandscape === true;
@@ -603,6 +715,7 @@ export async function processAndExportPhoto(
console.error('Failed to create wallframe surface');
return null;
}
own(cardSurface);
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).
@@ -622,16 +735,19 @@ export async function processAndExportPhoto(
console.error('Failed to load wallframe.png data');
return null;
}
const wfImage = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBase64(wfDataStr));
const wfData = Skia.Data.fromBase64(wfDataStr);
own(wfData);
const wfImage = Skia.Image.MakeImageFromEncoded(wfData);
if (!wfImage) {
console.error('Failed to decode wallframe.png');
return null;
}
own(wfImage);
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
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 plainPaint = own(Skia.Paint());
const scale = Math.max(win.w / pw, win.h / ph);
const srcW = win.w / scale;
const srcH = win.h / scale;
@@ -675,6 +791,14 @@ export async function processAndExportPhoto(
} else {
drawFrameOnCanvas(canvas, width, height, frameId);
}
// The photo now lives inside outputSurface: drop the source surface and
// its snapshot before the snapshot/sharpen/encode steps allocate their
// own full-res buffers (~96MB less at the peak of the render).
if (cardSource) {
release(owned, cardSource);
release(owned, surface);
}
// 8. Render GPS Geotag Watermark + Custom Text Watermark. One asset load +
// one amber-mono paint path serves both: GPS keeps its two icon lines; the
@@ -682,21 +806,17 @@ export async function processAndExportPhoto(
const customWm = options?.watermark ?? null;
const liteWm = options?.liteMark ?? null;
if ((useGeotag && gpsInfo) || customWm || liteWm) {
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));
// Faces are process-cached (see loadWatermarkFonts) and must NOT be
// disposed here — the next export reuses them.
const fonts = await loadWatermarkFonts();
const typeface = fonts?.typeface;
const iconTypeface = fonts?.iconTypeface;
if (typeface && iconTypeface) {
const fontSize = Math.round(stampW * 0.032); // 3.2% of output width
const textPaint = Skia.Paint();
const textPaint = own(Skia.Paint());
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
const iconPaint = Skia.Paint();
const iconPaint = own(Skia.Paint());
iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text
// GPS block: its TOP-LEFT corner sits at the dragged fraction of the
@@ -711,8 +831,8 @@ export async function processAndExportPhoto(
const gpsPos = options?.gpsWatermark ?? null;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
const gpsFont = Skia.Font(typeface, gpsSize);
const gpsIconFont = Skia.Font(iconTypeface, gpsSize);
const gpsFont = own(Skia.Font(typeface, gpsSize));
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
@@ -752,13 +872,13 @@ export async function processAndExportPhoto(
for (const mark of [customWm, liteWm]) {
if (!mark) continue;
const wmSize = Math.round(fontSize * (mark.size ?? 1));
const wmFont = mark.font
? Skia.Font(
Skia.FontMgr.System().matchFamilyStyle(mark.font, FontStyle.Normal),
wmSize
)
: Skia.Font(typeface, wmSize);
const wmPaint = Skia.Paint();
// The system FontMgr itself is a long-lived singleton — only the
// matched face is ours to release.
const wmFace = mark.font
? own(Skia.FontMgr.System().matchFamilyStyle(mark.font, FontStyle.Normal))
: null;
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
const wmPaint = own(Skia.Paint());
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
const tx = area.dx + clamp01(mark.x) * area.dw;
const ty = area.dy + clamp01(mark.y) * area.dh + Math.round(wmSize * 0.35);
@@ -771,8 +891,9 @@ export async function processAndExportPhoto(
}
}
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
let resultImage = outputSurface.makeImageSnapshot();
let resultImage = own(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.
// A negative knob means "no sharpening at all" (its blur already ran at
@@ -780,7 +901,15 @@ export async function processAndExportPhoto(
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
// The helper returns its own input when it cannot allocate a surface.
if (sharpened && sharpened !== resultImage) {
own(sharpened);
// The unsharpened copy is superseded before the encode: freeing it
// here keeps one less 12MP buffer alive across the JPEG readback.
release(owned, resultImage);
resultImage = sharpened;
}
}
// 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
@@ -815,6 +944,7 @@ export async function processAndExportPhoto(
await readPhotoBytes(sourceUri)
);
// 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 (and the
@@ -826,23 +956,23 @@ export async function processAndExportPhoto(
console.error('Failed to write export file: ' + e);
return null;
}
// 11. Request Media Library permission & Save to device gallery.
// createAssetAsync (not saveToLibraryAsync) so the caller learns the
// gallery asset id — the photo viewer's trash button needs it to remove
// the saved copy. ponytail: old exports are NOT retired here anymore — the
// in-app photo history still references their cache files for the swipe
// viewer; the OS cache purge reclaims them eventually.
// 11. Save to the device gallery. The native module is a plain MediaStore
// insert into DCIM/Camera and needs NO runtime permission, so it runs
// first; expo-media-library (which can only reach DCIM root or
// Pictures/Camera) is the fallback. Order is load-bearing: asking for
// READ_MEDIA_IMAGES raises the OS permission dialog, and a dialog on top
// of the live viewfinder takes the camera session down with it — the
// export stalled 9-16s and the next tap answered "Capture Failed".
try {
const assetId = await RecipescamExport.saveToCameraRollAsync(stripScheme(exportFile), 'image/jpeg');
if (assetId) { return { uri: exportFile, savedToLibrary: true, assetId }; }
} catch (e) {
console.warn('Native DCIM/Camera save failed, falling back to MediaLibrary:', e);
}
// Fallback only: the permission is asked for here, off the capture path.
const mediaGranted = await ensureMediaLibraryPermission();
if (mediaGranted) {
// DCIM/Camera (stock camera location) first: expo-media-library can only
// reach DCIM (root) or Pictures/Camera, so the native module does the
// MediaStore insert. Falls back to the plain expo save if that fails.
try {
const assetId = await RecipescamExport.saveToCameraRollAsync(stripScheme(exportFile), 'image/jpeg');
if (assetId) return { uri: exportFile, savedToLibrary: true, assetId };
} catch (e) {
console.warn('Native DCIM/Camera save failed, falling back to MediaLibrary:', e);
}
try {
const asset = await MediaLibrary.createAssetAsync(exportFile);
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
@@ -856,5 +986,9 @@ export async function processAndExportPhoto(
} catch (error) {
console.error('Error during photo processing and export:', error);
return null;
} finally {
// Only the file URI escapes this function, so every buffer above is dead by
// now — including on the early returns.
disposeAll(owned);
}
}