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:
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "RecipesCam",
|
||||
"slug": "recipescamera",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"orientation": "portrait",
|
||||
"icon": "./RecipesCamIcon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
|
||||
@@ -515,6 +515,8 @@ export default function LibraryPickerModal({
|
||||
})()
|
||||
: null;
|
||||
|
||||
if (!visible) return null; // see PhotoViewerModal
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onCancel}>
|
||||
<View className="flex-1 bg-black">
|
||||
|
||||
@@ -122,6 +122,8 @@ export default function PhotoViewerModal({
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null; // see PhotoViewerModal
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="fade" transparent={false} onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black">
|
||||
|
||||
@@ -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 (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-4">
|
||||
|
||||
@@ -119,6 +119,8 @@ export default function SettingsModal({
|
||||
hide.remove();
|
||||
};
|
||||
}, []);
|
||||
if (!visible) return null; // see PhotoViewerModal
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-6" style={{ paddingBottom: kb }}>
|
||||
|
||||
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
rawEnabled,
|
||||
rawOutput,
|
||||
onSessionError,
|
||||
onCameraReady,
|
||||
onExposureChange,
|
||||
meteringAdjustments,
|
||||
imageAdjustTarget,
|
||||
@@ -533,10 +556,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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 = <T extends SkTrash>(o: T): T => {
|
||||
trash.push(o);
|
||||
return o;
|
||||
};
|
||||
const paint = keep(Skia.Paint());
|
||||
let baseColorFilter: ReturnType<typeof Skia.ColorFilter.MakeMatrix> | 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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
canvas.scale(k, k);
|
||||
drawFullFrame();
|
||||
canvas.restore();
|
||||
disposeTrash(trash);
|
||||
});
|
||||
frame.dispose();
|
||||
},
|
||||
|
||||
+230
-96
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ async function ensureLocationPermission(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
return Promise.race([p, new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))]);
|
||||
}
|
||||
|
||||
export async function getCurrentGPS(): Promise<GPSInfo | null> {
|
||||
try {
|
||||
const hasPermission = await ensureLocationPermission();
|
||||
|
||||
+36
-15
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user