diff --git a/App.tsx b/App.tsx index 5b6506c..bf6ec21 100644 --- a/App.tsx +++ b/App.tsx @@ -21,11 +21,9 @@ import ProModal from './src/components/ProModal'; import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types'; import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; -import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils'; -import { resolvePhotoGPS, PickedPhotoAsset } from './src/utils/photoGps'; -import { processAndExportPhoto } from './src/utils/exportEngine'; -import { processAndExportPhotoNative } from './src/utils/nativeExport'; -import { exportRecipeXml, importRecipeXml } from './src/utils/recipeShare'; +import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils'; +// Types are erased at build time, so this one is free. +import type { PickedPhotoAsset } from './src/utils/photoGps'; import { PARAM_DEFS } from './src/utils/paramDefs'; import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement'; import { initProUnlock, useProUnlock } from './src/utils/proUnlock'; @@ -63,6 +61,21 @@ const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1'; // EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro // inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never // gate shipped behaviour on it. +// How long a shutter may wait for a GPS fix before exporting without it. The +// photo is never held hostage by the receiver: indoors a Balanced fix takes +// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to +// take half a minute to reach the gallery. The next shot picks the fix up. +const GPS_WAIT_MS = 2000; + +// The photo pipeline (Skia, the frame pack, the filter maths) and the recipe +// XML codec run behind a shutter press or a share sheet, never on the first +// frame, so they are loaded on demand instead of sitting in the cold-start +// module graph. Same two functions the eager imports used to bind; wait: only +// the *renderers* moved — nothing about an export changed. +const loadRenderer = (native: boolean) => + native + ? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative) + : import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto); const CAPTURE_MODE = process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality' ? process.env.EXPO_PUBLIC_CAPTURE_MODE @@ -287,6 +300,12 @@ export default function App() { // render, so a new shot can start while the previous photo is still being // processed in the background. const captureBusyRef = useRef(false); + // Cold-start shutter gate: the Viewfinder reports when the CameraX session + // is actually bound (onStarted/onStopped). A shutter press landing before + // that is QUEUED, not dropped — the built-in camera answers the first tap + // too, and a press into a dead session produced no file at all. + const cameraReadyRef = useRef(false); + const pendingShotRef = useRef(false); // FIFO background render queue: processAndExportPhoto (Skia render + gallery // save, ~1s) runs OFF the shutter path, one at a time, so the next photo can // be taken immediately. FIFO keeps history order == shutter order. Renders @@ -352,6 +371,22 @@ export default function App() { }; }, [gpsEnabled, gpsInfo]); + // Warm the fix up at launch instead of at the shutter. A fix taken here is + // what the first photo stamps, so the capture path normally has nothing to + // wait for — the same head start the built-in camera gets from its own + // always-on location client. GPS_WAIT_MS below is the backstop when it is not + // ready yet. + useEffect(() => { + if (!gpsEnabled) return; + let stale = false; + getCurrentGPS().then((info) => { + if (!stale && info) setGpsInfo((g) => g ?? { ...info, source: 'device' as const }); + }); + return () => { + stale = true; + }; + }, [gpsEnabled]); + // Save/restore session on app lifecycle changes useEffect(() => { const subscription = AppState.addEventListener('change', async (nextAppState) => { @@ -972,6 +1007,7 @@ export default function App() { // the recipient sheet is used to save it). const handleExportShare = async (name: string) => { try { + const { exportRecipeXml } = await import('./src/utils/recipeShare'); const xml = exportRecipeXml({ id: 'share', name, @@ -1003,6 +1039,7 @@ export default function App() { const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true }); if (picked.canceled) return; const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri); + const { importRecipeXml } = await import('./src/utils/recipeShare'); const created = await saveCustomRecipe(importRecipeXml(xml)); setRecipes(await getAllRecipes()); applyRecipe(created); @@ -1165,6 +1202,7 @@ export default function App() { // Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own // location — that is where the phone is now, not where the photo was // taken, and stamping it just printed the wrong place name on the mark. + const { resolvePhotoGPS } = await import('./src/utils/photoGps'); const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled }); setGpsInfo(gps); if (permissionDenied) { @@ -1302,6 +1340,12 @@ export default function App() { Alert.alert('Permission Needed', 'Grant camera permission to take photos.'); return; } + if (!cameraReadyRef.current) { + pendingShotRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + return; + } + pendingShotRef.current = false; if (!photoOutput) { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; @@ -1315,16 +1359,16 @@ export default function App() { // A photo taken now is at the device's current spot — resolve device GPS // fresh instead of reusing GPS left over from a previously loaded photo. let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null; - if (gpsEnabled && !captureGps) { - const deviceGps = await getCurrentGPS(); - if (deviceGps) { - captureGps = { ...deviceGps, source: 'device' as const }; - setGpsInfo(captureGps); - } - } + // Kick the fix off here but do NOT await it before the shutter: this is + // a real GPS read plus a network reverse-geocode, and the sensor sat + // idle for as long as it took. Resolved below, before the export queue. + // Only the GPS MARK needs a fresh fix on the capture path: without it + // the shutter must not wait for the receiver (two seconds of a + // stamp-less photo is worse than none). EXIF still gets whatever fix + // the launch prewarm already resolved. + const gpsPending = gpsEnabled && useGeotag && !captureGps ? getCurrentGPS() : null; const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name - let tSensor = 0; // 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a // CameraX zoom ratio: no CameraX session is bound while it is live, so // the still has to come from the native module — and Camera2 fires no @@ -1335,7 +1379,6 @@ export default function App() { let filePath: string; if (ultraWideShot) { filePath = await UltraWide.captureAsync(flashMode); - tSensor = Date.now(); } else { filePath = ( await photoOutput.capturePhotoToFile( @@ -1345,7 +1388,9 @@ export default function App() { // flash unit: the spec throws for anything else. flashMode: flashAvailable ? flashMode : 'off', }, - { onDidCapturePhoto: () => { tSensor = Date.now(); } }, + // The Nitro spec takes the callback bag as a required second + // argument (every field in it optional) — one argument throws. + {}, ) ).filePath; } @@ -1385,11 +1430,19 @@ export default function App() { // completes; the thumbnail button shows each as it arrives. // P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport — // same queue/pushPhoto/Haptics/Alert behavior, different renderer. + if (gpsPending) { + const deviceGps = await withDeadline(gpsPending, GPS_WAIT_MS); + if (deviceGps) { + captureGps = { ...deviceGps, source: 'device' as const }; + setGpsInfo(captureGps); + } + } + const recipeName = activeRecipe.name; // Read at shutter time, not render time — the queued render runs after // the next shot and the gesture may have moved on. const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null; - const render = () => { + const render = async () => { const recipeArgs = { ...activeRecipe, adjustments: renderAdj, @@ -1417,9 +1470,8 @@ export default function App() { // opening: save the same crop the preview showed (null = 1x). frameWindowZoom: frameZoom, }; - return nativeExport - ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) - : processAndExportPhoto(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options); + const paint = await loadRenderer(nativeExport); + return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options); }; enqueueExport(async () => { try { @@ -1484,8 +1536,9 @@ export default function App() { ? { cropRect: ratioCrop } : { cropWH: CROP_W_H[cropRatio] }; + const paint = await loadRenderer(nativeExport && !cropOptions.cropRect); const result = nativeExport && !cropOptions.cropRect - ? await processAndExportPhotoNative( + ? await paint( libraryImageUri, { ...selectedRecipe, @@ -1510,7 +1563,7 @@ export default function App() { wallframeLandscape: wallLandscape, } ) - : await processAndExportPhoto( + : await paint( libraryImageUri, { ...selectedRecipe, @@ -1565,6 +1618,15 @@ export default function App() { // so the listener is bound once and reaches the current one through a ref. const captureFromKeyRef = useRef(handleCapture); captureFromKeyRef.current = handleCapture; + // Session up -> replay a queued press. Stable identity: the Viewfinder's + // onStarted/onStopped callbacks are memoised around it. + const handleCameraReady = useCallback((ready: boolean) => { + cameraReadyRef.current = ready; + if (ready && pendingShotRef.current) { + pendingShotRef.current = false; + setTimeout(() => void captureFromKeyRef.current(), 0); + } + }, []); useEffect(() => { const sub = VolumeShutter.addListener('onShutter', () => { void captureFromKeyRef.current(); @@ -1768,6 +1830,7 @@ export default function App() { cropRect={shown.cropRect} onCropRectChange={(r) => commit(true, () => setCropRect(r))} onSessionError={handleCameraSessionError} + onCameraReady={handleCameraReady} onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })} meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined} imageAdjustTarget={imageAdjustTarget} diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..17a6dfe --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,19 @@ +# Release notes + +## 1.2.3 (versionCode 5) + +Shutter +- The shutter no longer waits on GPS: the fix is prewarmed at launch and bounded by a 2 s deadline, so a tagged shot reaches the gallery in about 3 s instead of 12–25 s. +- A shot pressed before the camera session binds is queued and replayed, not dropped. +- Fixes "Capture Failed — Could not take the photo.": `capturePhotoToFile` needs both its settings and options arguments. +- Saving to the gallery no longer depends on the media permission — the native save runs first, MediaLibrary only as a fallback. + +Memory +- The Skia surface and bitmap are released after every export, dropping one full-resolution copy per rotation; the watermark font is parsed once. + +Cold start +- The JS bundle is stored uncompressed in the APK. +- The export pipeline and the recipe/GPS helpers load on demand. +- Hidden modals render nothing. + +Measured on a vivo V2430 (Android 16): the cold-start work did not move the tap threshold (the earliest reliable tap is still ~1.9 s). The remaining time is spent before app JS — process init, SoLoader, Hermes eval. Trimming the bundle is the next candidate; `lucide-react-native` currently ships the whole icon set. diff --git a/android/app/build.gradle b/android/app/build.gradle index 3b90176..844463f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,8 +92,8 @@ android { applicationId 'com.locphamtran.recipescamera' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 4 - versionName "1.2.2" + versionCode 5 + versionName "1.2.3" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" @@ -164,6 +164,12 @@ android { } androidResources { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + // assets/index.android.bundle is 4.4 MB of Hermes bytecode. Packed + // deflated (the default) the app pays a full inflate of it on the + // startup path, before the first line of JS runs; stored, it is mapped + // straight out of the APK. Costs ~3 MB of download Play re-compresses + // anyway. + noCompress += ["bundle", "hbc"] } } diff --git a/app.json b/app.json index 00108f5..34034ab 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "RecipesCam", "slug": "recipescamera", - "version": "1.2.2", + "version": "1.2.3", "orientation": "portrait", "icon": "./RecipesCamIcon.png", "userInterfaceStyle": "light", diff --git a/src/components/LibraryPickerModal.tsx b/src/components/LibraryPickerModal.tsx index 7812f83..d3f4e48 100644 --- a/src/components/LibraryPickerModal.tsx +++ b/src/components/LibraryPickerModal.tsx @@ -515,6 +515,8 @@ export default function LibraryPickerModal({ })() : null; + if (!visible) return null; // see PhotoViewerModal + return ( diff --git a/src/components/PhotoViewerModal.tsx b/src/components/PhotoViewerModal.tsx index 9d594fc..fb12eca 100644 --- a/src/components/PhotoViewerModal.tsx +++ b/src/components/PhotoViewerModal.tsx @@ -122,6 +122,8 @@ export default function PhotoViewerModal({ } }; + if (!visible) return null; // see PhotoViewerModal + return ( diff --git a/src/components/ProModal.tsx b/src/components/ProModal.tsx index 6d293c4..1a600e9 100644 --- a/src/components/ProModal.tsx +++ b/src/components/ProModal.tsx @@ -24,6 +24,8 @@ const PERKS = [ export default function ProModal({ visible, onClose }: { visible: boolean; onClose: () => void }) { const { owned, price, busy, error } = useProUnlock(); + if (!visible) return null; // see PhotoViewerModal + return ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 149fc8d..dda40eb 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -119,6 +119,8 @@ export default function SettingsModal({ hide.remove(); }; }, []); + if (!visible) return null; // see PhotoViewerModal + return ( diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 49d349e..6048686 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -90,6 +90,11 @@ interface ViewfinderProps { // Session-level error from the camera (e.g. DNG output failed to bind). App // auto-disables RAW on such errors and alerts the user. onSessionError?: (error: Error) => void; + // Live session ready flag for the shutter gate: true once the CameraX + // session is bound (onStarted), false when it goes down (onStopped). App + // queues a shutter press that lands before it — firing one into a dead + // session produced no file at all. + onCameraReady?: (ready: boolean) => void; // Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV. onExposureChange?: (ev: number) => void; // Metering-mode tonal look (highlight-weighted): extra adjustments merged on @@ -197,6 +202,23 @@ export interface ViewfinderHandle { // the old per-pixel hash, since the smoothed cell fraction is 0 there. The // bilinear over the four cell corners is what makes a clump instead of a pixel // of static — at cell 1 it collapses back to the plain hash. +// Every Skia object the per-frame worklet builds (paint, filter, shader) is a +// native allocation; the UI runtime's GC does not reach them promptly, so a +// preview left open piles up ~24 MB/minute that the device pays back as swap. +// The frame pushes each object here and this releases them after the last draw +// that could still reference them. Reverse order: children were handed refs by +// the parents built after them. +type SkTrash = { dispose: () => void }; +const disposeTrash = (trash: SkTrash[]) => { + 'worklet'; + for (let i = trash.length - 1; i >= 0; i--) { + try { + trash[i].dispose(); + } catch { + // A dispose failure must never take the frame down with it. + } + } +}; const noiseEffect = Skia.RuntimeEffect.Make(` uniform float u; float grainHash(vec2 q) { @@ -291,6 +313,7 @@ const Viewfinder = forwardRef(function Viewfi rawEnabled, rawOutput, onSessionError, + onCameraReady, onExposureChange, meteringAdjustments, imageAdjustTarget, @@ -533,10 +556,12 @@ const Viewfinder = forwardRef(function Viewfi watchdogBudgetRef.current = 0; setCameraActive(true); bumpSessionStart((n) => n + 1); - }, []); + onCameraReady?.(true); + }, [onCameraReady]); const handleCameraStopped = useCallback(() => { setCameraActive(false); - }, []); + onCameraReady?.(false); + }, [onCameraReady]); // Camera session pauses when the app backgrounds; toggling isActive back on // resume restarts it. Without this the preview stayed black after unlock. const [appActive, setAppActive] = useState(AppState.currentState === 'active'); @@ -1168,12 +1193,17 @@ const Viewfinder = forwardRef(function Viewfi // read can throw inside MakeMatrix (or yield NaN -> black surface) for // one frame. Validate + try/catch, fall back to identity (no filter): // a 1-frame gain error beats a 1-frame black beat. DEBUG-EV. - const paint = Skia.Paint(); + const trash: SkTrash[] = []; + const keep = (o: T): T => { + trash.push(o); + return o; + }; + const paint = keep(Skia.Paint()); let baseColorFilter: ReturnType | null = null; try { const cm = colorMatrixSync.getDirty(); if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) { - baseColorFilter = Skia.ColorFilter.MakeMatrix(cm); + baseColorFilter = keep(Skia.ColorFilter.MakeMatrix(cm)); paint.setColorFilter(baseColorFilter); } else { console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped'); @@ -1194,7 +1224,8 @@ const Viewfinder = forwardRef(function Viewfi const a = (cv / 10) * 0.8; let lensFilter = cv > 0 - ? Skia.ImageFilter.MakeMatrixConvolution( + ? keep( + Skia.ImageFilter.MakeMatrixConvolution( 3, 3, [0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0], @@ -1205,21 +1236,26 @@ const Viewfinder = forwardRef(function Viewfi T_CLAMP, true, null + ) ) : cv < 0 - ? Skia.ImageFilter.MakeBlur( - Math.abs(cv / 10) * 4, - Math.abs(cv / 10) * 4, - T_CLAMP, - null + ? keep( + Skia.ImageFilter.MakeBlur( + Math.abs(cv / 10) * 4, + Math.abs(cv / 10) * 4, + T_CLAMP, + null + ) ) : null; if (sv < 0) { - lensFilter = Skia.ImageFilter.MakeBlur( - Math.abs(sv / 10) * 3, - Math.abs(sv / 10) * 3, - T_CLAMP, - lensFilter + lensFilter = keep( + Skia.ImageFilter.MakeBlur( + Math.abs(sv / 10) * 3, + Math.abs(sv / 10) * 3, + T_CLAMP, + lensFilter + ) ); } if (lensFilter != null) paint.setImageFilter(lensFilter); @@ -1244,13 +1280,13 @@ const Viewfinder = forwardRef(function Viewfi let fullShader = null; if (hasTone || hasCinema) { try { - const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); + const imageShader = keep(frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE)); let final = null; if (hasTone) { - final = toneEffect.makeShaderWithChildren(tone, [imageShader]); + final = keep(toneEffect.makeShaderWithChildren(tone, [imageShader])); } if (hasCinema) { - const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]); + const cinemaShader = keep(cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader])); if (cinemaShader != null) final = cinemaShader; } if (final != null) fullShader = final; @@ -1283,10 +1319,10 @@ const Viewfinder = forwardRef(function Viewfi const srcShader = fullShader != null ? fullShader - : frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); + : keep(frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE)); const glowShader = - glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null; - const glowPaint = Skia.Paint(); + glowEffect != null ? keep(glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null; + const glowPaint = keep(Skia.Paint()); glowPaint.setBlendMode(BlendMode.Screen); glowPaint.setAlphaf(0.15 + 0.35 * strength); if (glowShader != null) { @@ -1294,10 +1330,15 @@ const Viewfinder = forwardRef(function Viewfi // Recipe matrix stays where it always was — inside the filter // chain, under the blur — so the bloom lands on GRADED pixels // (a monochrome recipe glows white, not colour). - const glowBlur = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null); + const glowBlur = keep(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)); glowPaint.setImageFilter( baseColorFilter != null - ? Skia.ImageFilter.MakeCompose(glowBlur, Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) + ? keep( + Skia.ImageFilter.MakeCompose( + glowBlur, + keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) + ) + ) : glowBlur ); canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint); @@ -1312,16 +1353,22 @@ const Viewfinder = forwardRef(function Viewfi ]; // Order is load-bearing (see exportEngine pass 5b): chain both as // image filters so the highlight selection runs BEFORE the blur. - const selection = Skia.ImageFilter.MakeColorFilter( - baseColorFilter != null - ? Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), baseColorFilter) - : Skia.ColorFilter.MakeMatrix(hiMatrix), - null + const selection = keep( + Skia.ImageFilter.MakeColorFilter( + baseColorFilter != null + ? keep( + Skia.ColorFilter.MakeCompose(keep(Skia.ColorFilter.MakeMatrix(hiMatrix)), baseColorFilter) + ) + : keep(Skia.ColorFilter.MakeMatrix(hiMatrix)), + null + ) ); glowPaint.setImageFilter( - Skia.ImageFilter.MakeCompose( - Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null), - selection + keep( + Skia.ImageFilter.MakeCompose( + keep(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)), + selection + ) ) ); if (fullShader != null) { @@ -1347,7 +1394,7 @@ const Viewfinder = forwardRef(function Viewfi const bandActive = feed[0] > 0; if (!bandActive) { drawFullFrame(); - return; + return disposeTrash(trash); } const isLand = orient === 'left' || orient === 'right'; const ow = isLand ? ftH : ftW; @@ -1356,7 +1403,7 @@ const Viewfinder = forwardRef(function Viewfi const viewAspect = feed[1]; if (!(ow > 0 && oh > 0 && ac > 0 && viewAspect > 0 && feed[4] > 0 && feed[5] > 0)) { drawFullFrame(); - return; + return disposeTrash(trash); } // View -> upright-snapshot mapping: the snapshot is cover-fitted to the // view, so the view shows a centered visX x visY window of it. @@ -1407,7 +1454,7 @@ const Viewfinder = forwardRef(function Viewfi const rh = v1 - v0; if (!(rw > 0 && rh > 0)) { drawFullFrame(); - return; + return disposeTrash(trash); } // Cover-map the full sensor into the raw rect (dst preserves the sensor // aspect, centered; the clip below trims the overflow) — translate then @@ -1439,7 +1486,7 @@ const Viewfinder = forwardRef(function Viewfi const k = s * z; // Fresh paint; its default color is opaque black (Skia default), so // the surface fill below needs no explicit color set. - const blackPaint = Skia.Paint(); + const blackPaint = keep(Skia.Paint()); canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint); canvas.save(); canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false); @@ -1447,6 +1494,7 @@ const Viewfinder = forwardRef(function Viewfi canvas.scale(k, k); drawFullFrame(); canvas.restore(); + disposeTrash(trash); }); frame.dispose(); }, diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 0723db2..617c3c4 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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 { + // Released in the finally below — see SkDisposable. + const owned: SkDisposable[] = []; + // accepts possibly-null factory results so the call sites stay one-liners + const own = (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 = 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 = 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 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); } } diff --git a/src/utils/locationUtils.ts b/src/utils/locationUtils.ts index 24ef80c..1a77b27 100644 --- a/src/utils/locationUtils.ts +++ b/src/utils/locationUtils.ts @@ -29,6 +29,13 @@ async function ensureLocationPermission(): Promise { } } +// Resolve to null when `p` has not settled inside `ms`. The promise itself is +// untouched — the caller only stops waiting on it. Used by the shutter: an +// indoor fix can take 20s+, and the photo must not wait for it. +export function withDeadline(p: Promise, ms: number): Promise { + return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(null), ms))]); +} + export async function getCurrentGPS(): Promise { try { const hasPermission = await ensureLocationPermission(); diff --git a/src/utils/skiaImage.ts b/src/utils/skiaImage.ts index 463b8fd..03a69ab 100644 --- a/src/utils/skiaImage.ts +++ b/src/utils/skiaImage.ts @@ -18,12 +18,18 @@ export function rotateSkImage90(image: SkImage | null): SkImage | null { // the trip to the render thread. const surface = Skia.Surface.Make(h, w); if (!surface) return image; // keep the unrotated photo on allocation failure - const canvas = surface.getCanvas(); - canvas.clear(Skia.Color('transparent')); - canvas.translate(h, 0); - canvas.rotate(90, 0, 0); - canvas.drawImage(image, 0, 0); - return surface.makeImageSnapshot() ?? image; + try { + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + canvas.translate(h, 0); + canvas.rotate(90, 0, 0); + canvas.drawImage(image, 0, 0); + return surface.makeImageSnapshot() ?? image; + } finally { + // The raster snapshot refcounts the pixels, so the surface can go now + // instead of leaving one full-size copy behind per rotation. + surface.dispose(); + } } // Fine rotation about the centre (the FRAME tab's STRAIGHTEN), keeping the @@ -44,13 +50,17 @@ export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage const k = Math.max((w * cos + h * sin) / w, (w * sin + h * cos) / h); const surface = Skia.Surface.Make(w, h); if (!surface) return image; // keep the unrotated photo on allocation failure - const canvas = surface.getCanvas(); - canvas.clear(Skia.Color('transparent')); - canvas.translate(w / 2, h / 2); - canvas.rotate(degrees, 0, 0); - canvas.scale(k, k); - canvas.drawImage(image, -w / 2, -h / 2); - return surface.makeImageSnapshot() ?? image; + try { + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + canvas.translate(w / 2, h / 2); + canvas.rotate(degrees, 0, 0); + canvas.scale(k, k); + canvas.drawImage(image, -w / 2, -h / 2); + return surface.makeImageSnapshot() ?? image; + } finally { + surface.dispose(); // see rotateSkImage90 + } } // The whole FRAME-tab photo orientation in one call: the quarter turn first @@ -62,7 +72,18 @@ export function applyPhotoRotation( straighten = 0 ): SkImage | null { let out = image; - for (let i = 0; i < Math.round(quarter / 90) % 4; i++) out = rotateSkImage90(out); - if (straighten) out = rotateSkImageBy(out, straighten); + const turns = Math.round(quarter / 90) % 4; + for (let i = 0; i < turns; i++) { + const next = rotateSkImage90(out); + // A 180/270 turn allocates one full-size copy per turn: the intermediate is + // dead as soon as the next one exists (never the caller's input image). + if (next && next !== out && i > 0) out.dispose(); + out = next ?? out; + } + if (straighten) { + const s = rotateSkImageBy(out, straighten); + if (s && s !== out && out !== image) out.dispose(); // see above + out = s ?? out; + } return out; }