diff --git a/.gitignore b/.gitignore index db241e6..a50f058 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules .expo/ modules/*/android/build/ +android/keystore.properties +android/app/release.keystore diff --git a/App.tsx b/App.tsx index 8c7b71a..11c5530 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native'; import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; -import * as ImagePicker from 'expo-image-picker'; +import * as DocumentPicker from 'expo-document-picker'; +import * as Sharing from 'expo-sharing'; import * as FileSystem from 'expo-file-system/legacy'; -import * as MediaLibrary from 'expo-media-library'; import * as MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -15,16 +15,19 @@ import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; +import LibraryPickerModal from './src/components/LibraryPickerModal'; import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, 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 } from './src/utils/photoGps'; +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 { PARAM_DEFS } from './src/utils/paramDefs'; -import { LITE_MARK, activatePro, loadPro, proLookInUse } from './src/utils/entitlement'; +import { LITE_MARK, LITE_RECIPE_LIMIT, activatePro, loadPro, proLookInUse } from './src/utils/entitlement'; +import UltraWide from './modules/recipescam-ultrawide'; // @ts-ignore import './global.css'; @@ -49,14 +52,27 @@ const CAMERA_SETTINGS_KEY = 'camera_settings'; // decides which engine ships. const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1'; +// B1: the shutter's CameraX capture mode. 'balanced' ships as the default — +// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms +// against ~540 ms for 'quality', with no resolution loss, because this device +// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it. +// 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. +const CAPTURE_MODE = + process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality' + ? process.env.EXPO_PUBLIC_CAPTURE_MODE + : 'balanced'; + export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); // Back/front lens the live viewfinder uses; flipping is transient UI state // (not persisted in sessions — startup always opens on the back camera). const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back'); - // Imperative handle into the Viewfinder camera controller: flushes the EV to - // the hardware AE bias right before each capture (live slider drags never - // touch the hardware — that flashed the Xiaomi preview). + // Imperative handle into the Viewfinder camera controller: reads the live + // viewfinder state at capture/export time (framed-window zoom, crop rect, + // horizon). There is no EV flush to do: the hardware AE bias is never + // touched — setExposureBias re-locks AE and flashes the Xiaomi preview. const viewfinderRef = useRef(null); // Edge-to-edge (app.json/gradle edgeToEdgeEnabled) makes Android ignore the // manifest's adjustResize, so the OS never shrinks the window for the IME and @@ -87,6 +103,11 @@ export default function App() { useEffect(() => { loadPro().then(setPro); }, []); + // Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save + // paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows. + const customRecipeCount = recipes.filter((r) => r.isCustom).length; + const liteSaveBlocked = !pro && customRecipeCount >= LITE_RECIPE_LIMIT; + const promptRecipeLimit = () => promptProFeature(`Saving more than ${LITE_RECIPE_LIMIT} recipes`); // FAVORITED: ids of the starred recipes. Metadata only — never saved into the // session look, so RESET and applying a recipe leave it untouched. const [favoriteIds, setFavoriteIds] = useState([]); @@ -223,11 +244,17 @@ export default function App() { const [photoHistory, setPhotoHistory] = useState([]); // Index into photoHistory of the photo shown in the swipe viewer; null = closed. const [viewerIndex, setViewerIndex] = useState(null); + // In-app library picker (grid -> full-screen preview -> USE/CANCEL). + const [libraryPickerVisible, setLibraryPickerVisible] = useState(false); // Gear sheet (sound / ratio / metering mode). Camera captures never auto-open // the viewer anymore — the thumbnail button does. const [settingsVisible, setSettingsVisible] = useState(false); // Shutter click: only OFF/ON (no volume API on Android). Applied at capture. const [shutterSound, setShutterSound] = useState('on'); + // Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks + // AE/AF/AWB on the tapped point and, once focus has settled, fires a capture. + // OFF = a tap only focuses (the historical behaviour). + const [touchToShutter, setTouchToShutter] = useState(false); // Startup behavior: 'default' = PROVIPES neutral at 4:3 camera; 'last' = restore // previous session from AsyncStorage. Default is 'default'. const [startupMode, setStartupMode] = useState('default'); @@ -255,7 +282,7 @@ export default function App() { // Full-resolution photo output — attached to the live in Viewfinder. // Photo capture is resolution-independent from the Skia preview output. const photoOutput = usePhotoOutput({ - qualityPrioritization: 'quality', + qualityPrioritization: CAPTURE_MODE, }); // Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as // a live output only while rawEnabled — CameraX cannot bind two ImageCapture @@ -277,25 +304,6 @@ export default function App() { // Do NOT auto-load device GPS here — it would override photo GPS on library mode }, []); - // P0 spike: native decode/encode benchmark (EXPO_PUBLIC_BENCH=1 builds only). - useEffect(() => { - if (process.env.EXPO_PUBLIC_BENCH === '1') { - import('./src/dev/nativeBenchProbe') - .then((m) => m.nativeBenchProbe()) - .catch(() => {}); - } - }, []); - - // P6 parity probe: same source through legacy Skia and native engines - // (EXPO_PUBLIC_PARITY=1 builds only). - useEffect(() => { - if (process.env.EXPO_PUBLIC_PARITY === '1') { - import('./src/dev/parityProbe') - .then((m) => m.parityProbe()) - .catch((e) => console.error('[PARITY] effect failed', e)); - } - }, []); - // A photo picked while the GPS chip was OFF kept its coordinates but no place // name (the geocode only ran when the chip was already on), and toggleGeotag // skips GPS loading entirely when coordinates exist — so turning the chip on @@ -363,6 +371,7 @@ export default function App() { if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode); if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled); if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled); + if (typeof s.touchToShutter === 'boolean') setTouchToShutter(s.touchToShutter); } } catch (e) { console.warn('Failed to read camera settings:', e); @@ -417,11 +426,15 @@ export default function App() { imageUri = null; } } - if (imageUri) { + // A saved 'camera' mode is a deliberate choice and outranks a lingering + // library image: switching modes never clears libraryImageUri, so without + // this a session closed on the live camera came back up in the viewer. + if (imageUri && session.mode !== 'camera') { setLibraryImageUri(imageUri); setMode('library'); } else { - setMode(session.mode || 'camera'); + // No usable photo (or the user had chosen the camera): open the camera. + setMode('camera'); } if (session.adjustments) setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); @@ -495,24 +508,25 @@ export default function App() { const applyRecipe = (recipe: Recipe) => { setSelectedRecipe(recipe); - setAdjustments(recipe.adjustments); + // EVERY knob the recipe carries lands in the panel, backfilled over the + // defaults so a recipe saved before a knob existed cannot leave that knob on + // whatever the previous look happened to have. + setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments }); setSelectedFrame(recipe.frameId || 'none'); // GPS watermark is the user's own setting (FRAME tab), not part of a // preset look: recipes carry a useGeotag field but selecting one must // never flip the watermark on/off. }; - // Picking a film sim swaps the look only: everything the user dialled in on - // the LIGHT/WB/FX tabs and the frame they picked stay exactly as they are. - // A saved recipe still loads its own stored values (that is what saving it - // was for). Same trick as the session restore below: set the recipe without - // applyRecipe, which would snap the sliders back to the sim's canonical set. + // Picking a look — sim or saved recipe — loads the whole thing: identity, + // every parameter and the frame it was saved with. The panel then shows the + // values that are actually on screen (and stays editable from there). This + // used to keep the sliders for sims, which left the panel naming numbers the + // look no longer had: the sim carried its own adjustments and they were + // silently dropped. The session restore below is a different case — there the + // stored adjustments ARE the user's, so it still sets the recipe alone. const handleSelectRecipe = (recipe: Recipe) => { - if (!FILM_SIMS.some((s) => s.id === recipe.id)) { - applyRecipe(recipe); - return; - } - setSelectedRecipe({ ...recipe, adjustments, frameId: selectedFrame }); + applyRecipe(recipe); }; // The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe @@ -722,9 +736,9 @@ export default function App() { if (!settingsHydratedRef.current) return; AsyncStorage.setItem( CAMERA_SETTINGS_KEY, - JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled }) + JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter }) ).catch(() => {}); - }, [shutterSound, meterMode, rawEnabled, gpsEnabled]); + }, [shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter]); // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. @@ -771,8 +785,8 @@ export default function App() { }; const handleSaveRecipe = async (name: string) => { - if (!pro) { - promptProFeature('Saving a recipe'); + if (liteSaveBlocked) { + promptRecipeLimit(); return; } if (!selectedRecipe) return; @@ -789,6 +803,52 @@ export default function App() { Alert.alert('Recipe Saved', `"${name}" has been created.`); }; + // TopBar SAVE & SHARE -> EXPORT & SHARE: the look on screen as an encrypted + // recipe file, handed to the share sheet (and to the phone's storage when + // the recipient sheet is used to save it). + const handleExportShare = async (name: string) => { + try { + const xml = exportRecipeXml({ + id: 'share', + name, + baseFilter: activeRecipe.baseFilter, + adjustments, + frameId: selectedFrame, + useGeotag, + }); + const file = `${FileSystem.cacheDirectory}${name.replace(/[^\w.-]+/g, '_') || 'recipe'}.rcx.xml`; + await FileSystem.writeAsStringAsync(file, xml); + if (!(await Sharing.isAvailableAsync())) { + Alert.alert('Recipe Exported', `Saved as ${file}`); + return; + } + await Sharing.shareAsync(file, { mimeType: 'application/xml', dialogTitle: `Share recipe "${name}"` }); + } catch (e) { + Alert.alert('Export failed', e instanceof Error ? e.message : String(e)); + } + }; + + // PRESETS -> IMPORT: a recipe file shared from another phone, picked from + // storage, decoded and stored as a normal custom recipe. + const handleImportRecipe = async () => { + if (liteSaveBlocked) { + promptRecipeLimit(); + return; + } + try { + const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true }); + if (picked.canceled) return; + const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri); + const created = await saveCustomRecipe(importRecipeXml(xml)); + setRecipes(await getAllRecipes()); + applyRecipe(created); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert('Recipe Imported', `"${created.name}" has been added.`); + } catch (e) { + Alert.alert('Import failed', e instanceof Error ? e.message : String(e)); + } + }; + // TopBar "SAVE" on a user-created recipe: overwrite it in place, same id. const handleOverwriteRecipe = async (name: string) => { if (!selectedRecipe) return; @@ -816,6 +876,10 @@ export default function App() { if (!target) return; const name = recipe.name.trim(); const unchanged = name === target.name.trim(); + if (!unchanged && liteSaveBlocked) { + promptRecipeLimit(); + return; + } const commit = async () => { if (unchanged) { await updateCustomRecipe(target.id, { ...recipe, name }); @@ -850,11 +914,11 @@ export default function App() { // typed fields, persist it, and apply it (the user sees the look immediately). const handleCreateRecipe = async (recipe: Omit) => { // Lite composes the recipe and applies it as a throwaway draft so the look - // can be tried; only STORING it is PRO. 'draft' is no free id, so the - // shutter gate already treats the look as the PRO preset it is. - if (!pro) { + // can be tried; storing it is what the LITE_RECIPE_LIMIT caps. 'draft' is no + // free id, so the shutter gate already treats the look as the PRO preset. + if (liteSaveBlocked) { applyRecipe({ ...recipe, id: 'draft', isCustom: false }); - promptProFeature('Saving a custom recipe'); + promptRecipeLimit(); return; } const newRecipe = await saveCustomRecipe(recipe); @@ -864,7 +928,12 @@ export default function App() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; + // Deleting is PRO: Lite may create and edit its recipes, it may not drop them. const handleDeleteRecipe = async (id: string) => { + if (!pro) { + promptProFeature('Deleting recipes'); + return; + } await deleteCustomRecipe(id); const list = await getAllRecipes(); setRecipes(list); @@ -898,63 +967,44 @@ export default function App() { setPhotoStraighten(-tilt); }, []); - const handlePickImage = async () => { + // OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a + // grid, then the photo full screen to pinch/double-tap zoom and swipe through, + // then USE or CANCEL. The system picker it replaced returned the still on the + // first tap (no preview) and, on Android 13+, as a redacted cache copy with + // assetId=null — the grid uri keeps the MediaStore asset id, so with + // ACCESS_MEDIA_LOCATION granted the file is not redacted and tier-1 EXIF / + // tier-2 MediaStore both still work. + const handlePickImage = () => { if (isProcessing) return; + setLibraryPickerVisible(true); + }; - // On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us - // a redacted copy (EXIF GPS stripped), which silently kills the photo's own - // coordinates. Request media read + photo-location BEFORE the pick so the - // copy keeps its true EXIF GPS. - if (gpsEnabled && Platform.OS === 'android') { - try { - await MediaLibrary.requestPermissionsAsync(false, ['photo']); - } catch (e) { - console.warn('Pre-pick media+photo-location permission error:', e); - } + const handleLibraryPicked = async (asset: PickedPhotoAsset) => { + setLibraryPickerVisible(false); + if (!asset.uri) return; + setLibraryImageUri(asset.uri); + // A new photo starts level: the previous photo's turn and fine + // straighten must not carry over. + setPhotoRotation(0); + setPhotoStraighten(0); + setMode('library'); + // Picking from the camera is the other half of that switch (the camera + // button is the way back), so the camera's edits must not land on the + // photo: the picked still starts at the recipe's own values too. + if (mode !== 'library') resetEditsToRecipe(); + // Clear old GPS — will be replaced if the photo (or device) has location data + setGpsInfo(null); + + // 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 { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled }); + setGpsInfo(gps); + if (permissionDenied) { + console.warn('Media library read permission denied — photo GPS unavailable.'); } - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: false, - quality: 1, - exif: true, - // Android 13+ photo picker always returns a redacted cache copy with - // assetId=null — EXIF GPS stripped, MediaStore id lost. The legacy picker - // (ACTION_GET_CONTENT → DocumentsUI) hands back a - // com.android.providers.media.documents uri, so the MediaStore asset id - // survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not - // redacted → tier-1 EXIF / tier-2 MediaStore both work. Always on: the - // photo keeps its own EXIF GPS even when the chip is off at pick time, so - // switching the chip on later still stamps the photo's real place. - legacy: Platform.OS === 'android', - }); - - if (!result.canceled && result.assets && result.assets.length > 0) { - const asset = result.assets[0]; - setLibraryImageUri(asset.uri); - // A new photo starts level: the previous photo's turn and fine - // straighten must not carry over. - setPhotoRotation(0); - setPhotoStraighten(0); - setMode('library'); - // Picking from the camera is the other half of that switch (the camera - // button is the way back), so the camera's edits must not land on the - // photo: the picked still starts at the recipe's own values too. - if (mode !== 'library') resetEditsToRecipe(); - // Clear old GPS — will be replaced if the photo (or device) has location data - setGpsInfo(null); - - // 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 { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled }); - setGpsInfo(gps); - if (permissionDenied) { - console.warn('Media library read permission denied — photo GPS unavailable.'); - } - - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; const activeRecipe: Recipe = @@ -973,11 +1023,15 @@ export default function App() { // The PRO feature the current look is using, or null while it is Lite-clean. // Only the EXPORT consults it: the preview shows every look for free, so the // answer is computed where the file is about to be written, not on the chips. - const proLook = proLookInUse({ - recipeId: selectedRecipe?.id, - frame: selectedFrame, - customWm: customWmApplied.enabled, - }); + // PRO unlocks every look, so a paying user has nothing left in use: only a + // Lite session can trip the two gates below. + const proLook = pro + ? null + : proLookInUse({ + recipeId: selectedRecipe?.id, + frame: selectedFrame, + customWm: customWmApplied.enabled, + }); // The native Kotlin exporter has no text-watermark draw, so a Lite export // (which always carries the mark) must take the Skia engine. const nativeExport = NATIVE_EXPORT && pro; @@ -1045,7 +1099,10 @@ export default function App() { } : null; - const handleCapture = async () => { + // `burst` marks one frame of a held-shutter burst: the loop already gave the + // single haptic and the click would machine-gun, so both are muted, and RAW + // is skipped (a DNG per frame doubles the exposures the burst has to fit). + const handleCapture = async (opts?: { burst?: boolean }) => { if (isProcessing) return; // A PRO look can be composed but not rendered: stop before the shutter, so @@ -1065,8 +1122,11 @@ export default function App() { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } + // One capture in flight at a time — the shutter button, a viewfinder tap + // and a burst frame all land here. + if (captureBusyRef.current) return; captureBusyRef.current = true; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + if (!opts?.burst) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); try { // 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. @@ -1080,13 +1140,30 @@ export default function App() { } const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name - const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); + 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 + // shutter sound of its own. + const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true; + let filePath: string; + if (ultraWideShot) { + filePath = await UltraWide.captureAsync(); + tSensor = Date.now(); + } else { + filePath = ( + await photoOutput.capturePhotoToFile( + { enableShutterSound: shutterSound === 'on' && !opts?.burst }, + { onDidCapturePhoto: () => { tSensor = Date.now(); } }, + ) + ).filePath; + } const sourceUri = toUri(filePath); // RAW DNG sidecar: fire the second exposure immediately after the JPEG // so the two shots stay a few tens of ms apart, then file it into the // "RAW" album without blocking the JPEG export below. - if (rawEnabled && rawPhotoOutput) { + if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) { try { const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); const rawUri = toUri(rawFile.filePath); @@ -1281,6 +1358,35 @@ export default function App() { } }; + // Held-shutter burst: press and hold the shutter, frames keep coming until + // the finger lifts. Every frame is a normal capture (GPS resolve, RAW rules, + // background render enqueue), so a burst photo is indistinguishable from a + // tapped one apart from the muted haptic/sound. The `await` IS the pacing: a + // frame cannot start before the previous one is on disk, which is why no + // timer is needed (and why 'speed' buys nothing extra here). + // ponytail: BURST_MAX is a runaway guard for a lost press-out (responder + // terminated mid-hold); raise it if bursts longer than 30 frames are wanted. + const BURST_MAX = 30; + const burstRef = useRef(false); + const handleBurstStart = () => { + if (mode !== 'camera' || burstRef.current) return; + if (proLook) { + promptPro(proLook); + return; + } + burstRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + void (async () => { + for (let i = 0; i < BURST_MAX && burstRef.current; i++) { + await handleCapture({ burst: true }); + } + burstRef.current = false; + })(); + }; + const handleBurstStop = () => { + burstRef.current = false; + }; + // Switching back to camera: drop GPS that belongs to a library photo, so the // live view (and any capture) never stamps a stale location. const handleSetMode = (next: 'camera' | 'library') => { @@ -1457,6 +1563,8 @@ export default function App() { meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined} imageAdjustTarget={imageAdjustTarget} onComparePeek={peekArmed ? handleComparePeek : undefined} + // Touch-to-shutter: tap the live image -> focus locks -> capture. + onTouchShutter={touchToShutter ? handleCapture : undefined} customWm={customWm} wmRotation={wmRotation} gpsWm={gpsWm} @@ -1487,6 +1595,7 @@ export default function App() { onEditRecipe={ selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined } + onExportShare={handleExportShare} /> {/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */} @@ -1544,6 +1653,7 @@ export default function App() { onUpdateFrame={handleUpdateFrame} onToggleGeotag={handleToggleGeotag} onSaveRecipe={handleSaveRecipe} + onImportRecipe={handleImportRecipe} onDeleteRecipe={handleDeleteRecipe} customWm={customWm} onUpdateCustomWm={handleUpdateCustomWm} @@ -1561,9 +1671,18 @@ export default function App() { + {/* Library picker (grid -> zoomable preview -> USE / CANCEL) */} + setLibraryPickerVisible(false)} + onSelect={handleLibraryPicked} + /> + setSettingsVisible(true)} @@ -1586,6 +1705,8 @@ export default function App() { onMeterModeChange={setMeterMode} shutterSound={shutterSound} onShutterSound={setShutterSound} + touchToShutter={touchToShutter} + onTouchToShutter={setTouchToShutter} gpsEnabled={gpsEnabled} onGpsEnabled={handleToggleGps} startupMode={startupMode} diff --git a/RecipesCam.png b/RecipesCam.png new file mode 100644 index 0000000..0a23ba6 Binary files /dev/null and b/RecipesCam.png differ diff --git a/RecipesCamIcon.png b/RecipesCamIcon.png new file mode 100644 index 0000000..2c56883 Binary files /dev/null and b/RecipesCamIcon.png differ diff --git a/android/app/build.gradle b/android/app/build.gradle index bf8ee88..9313b79 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -102,6 +102,19 @@ android { } } signingConfigs { + release { + // Credentials live in android/keystore.properties (gitignored), next to + // app/release.keystore. Lose either and no future build can install over + // this one - back both up somewhere safe. + def props = new Properties() + def propsFile = rootProject.file('keystore.properties') + if (!propsFile.exists()) throw new GradleException('android/keystore.properties missing') + propsFile.withInputStream { props.load(it) } + storeFile file('release.keystore') + storePassword props.getProperty('storePassword') + keyAlias props.getProperty('keyAlias') + keyPassword props.getProperty('keyPassword') + } debug { storeFile file('debug.keystore') storePassword 'android' @@ -109,6 +122,21 @@ android { keyPassword 'android' } } + // Two products, two sales channels: lite = free apk handed out for feedback, + // pro = paid apk. Same JS; the tier reaches JS via src/provariant.ts, which + // tools/set-variant.mjs rewrites before each build. + flavorDimensions "tier" + productFlavors { + lite { + dimension "tier" + } + pro { + dimension "tier" + applicationIdSuffix ".pro" + versionNameSuffix "-pro" + resValue "string", "app_name", "RecipesCam Pro" + } + } buildTypes { debug { signingConfig signingConfigs.debug @@ -116,7 +144,7 @@ android { release { // Caution! In production, you need to generate your own keystore file. // see https://reactnative.dev/docs/signed-apk-android. - signingConfig signingConfigs.debug + signingConfig signingConfigs.release def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' shrinkResources enableShrinkResources.toBoolean() minifyEnabled enableMinifyInReleaseBuilds diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 1670f5b..f395f56 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 9291e9c..1ef9e8b 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 1670f5b..f395f56 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 7195061..a382363 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index e1d12b9..3d1f615 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 7195061..a382363 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index ffb078e..1b044db 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index ae0066f..715be71 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index ffb078e..1b044db 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index abdf523..3cc0c01 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 229054a..99d1885 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index abdf523..3cc0c01 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 673fe34..e325055 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index d739c73..0466903 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 673fe34..e325055 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app.json b/app.json index 647560e..09e2fd7 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "recipescamera", "version": "1.2", "orientation": "portrait", - "icon": "./recipes_icon.png", + "icon": "./RecipesCamIcon.png", "userInterfaceStyle": "light", "ios": { "supportsTablet": true, @@ -15,7 +15,7 @@ "android": { "adaptiveIcon": { "backgroundColor": "#E6F4FE", - "foregroundImage": "./recipes_icon.png", + "foregroundImage": "./RecipesCamIcon.png", "backgroundImage": "./assets/android-icon-background.png", "monochromeImage": "./assets/android-icon-monochrome.png" }, diff --git a/modules/recipescam-ultrawide/android/build.gradle b/modules/recipescam-ultrawide/android/build.gradle new file mode 100644 index 0000000..8c0227a --- /dev/null +++ b/modules/recipescam-ultrawide/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'com.locphamtran.recipescamera' +version = '0.1.0' + +android { + namespace "com.locphamtran.recipescamera.ultrawide" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } +} diff --git a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt new file mode 100644 index 0000000..0276e75 --- /dev/null +++ b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt @@ -0,0 +1,712 @@ +package com.locphamtran.recipescamera.ultrawide + +import android.content.Context +import android.graphics.ImageFormat +import android.graphics.Rect +import android.graphics.SurfaceTexture +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.params.MeteringRectangle +import android.media.ImageReader +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.util.Log +import android.util.Size +import android.view.Surface +import android.view.TextureView +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.Promise +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.views.ExpoView +import java.io.File +import java.io.FileOutputStream +import kotlin.math.abs +import kotlin.math.min + +private const val TAG = "RecipesCamUltraWide" + +// Camera2 open is exclusive: while the main (CameraX) session still holds the +// HAL the ultra-wide open fails with ERROR_CAMERA_IN_USE, and the release takes +// a few hundred ms after the preview is switched off — so retry instead of +// surfacing an error to JS. +private const val OPEN_RETRY_MS = 80L +private const val OPEN_MAX_ATTEMPTS = 25 +private const val CAPTURE_TIMEOUT_MS = 6000L +// Ceiling for closeAsync(): the platform is supposed to report the device gone, +// but a release that is never signalled must not strand the caller. +private const val RELEASE_MAX_MS = 300L + +// Tap-to-focus metering window, as a fraction of a side of the sensor's active +// array. 16% of the frame is roughly what a finger covers when it taps. +private const val METER_FRACTION = 0.08f + +private fun cameraManager(ctx: Context) = + ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager + +/** + * The Xiaomi 12S Ultra hides its ultra-wide (and tele) logical cameras from + * `cameraIdList` — CameraX therefore reports a single back device with + * minZoomRatio 1.0 and no 0.5x. Camera2 can still open the hidden id directly, + * so detect it as "the widest back lens that is NOT in cameraIdList": back + * facing, smallest LENS_INFO_AVAILABLE_FOCAL_LENGTHS. + * + * Returns "" when the device has no such camera (single-lens / front-only). + */ +internal fun findUltraWideId(ctx: Context): String { + val cm = cameraManager(ctx) + val exposed = cm.cameraIdList.toHashSet() + var best = "" + var bestFocal = Float.MAX_VALUE + // Ids are sparse on these devices (0/1 exposed, 2/3/6/8/9 hidden) but the + // space is not bounded by the API — 16 is well above any shipped layout. + for (id in 0 until 16) { + val key = id.toString() + if (exposed.contains(key)) continue + val cs = try { + cm.getCameraCharacteristics(key) + } catch (e: Throwable) { + continue // no such camera id + } + if (cs.get(CameraCharacteristics.LENS_FACING) != CameraCharacteristics.LENS_FACING_BACK) continue + val focals = cs.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) ?: continue + val f = focals.minOrNull() ?: continue + if (f > 0f && f < bestFocal) { + bestFocal = f + best = key + } + } + return best +} + +/** Largest 4:3 size no bigger than [maxArea]; any size if the camera has no 4:3. */ +private fun pick4x3(sizes: Array?, maxArea: Int): Size? { + if (sizes == null || sizes.isEmpty()) return null + var best: Size? = null + var bestArea = -1 + for (s in sizes) { + if (s.width <= 0 || s.height <= 0) continue + if (abs(s.width.toDouble() / s.height - 4.0 / 3.0) > 0.02) continue + val area = s.width * s.height + if (area > maxArea || area <= bestArea) continue + bestArea = area + best = s + } + return best ?: sizes.maxByOrNull { it.width * it.height } +} + +/** + * Live ultra-wide feed. A TextureView in a Camera2 session: the frames arrive + * as a landscape 4:3 buffer and are cover-scaled + rotated onto the portrait + * container by the view transform (no GL, no JS frame hop). + */ +class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + companion object { + @Volatile private var live: UltraWidePreviewView? = null + fun current(): UltraWidePreviewView? = live + } + + private val texture = TextureView(context) + // Freezes the outgoing frame instead of letting it vanish with the session — + // see holdFrame(). + private val holdView = ImageView(context) + + // One thread owns every camera callback and the JPEG write — the main thread + // only ever sets props. + private var camThread: HandlerThread? = null + private val camHandler: Handler + get() { + val t = camThread ?: HandlerThread("uw-camera").also { it.start(); camThread = it } + return Handler(t.looper) + } + + private var active = false + // Handoff cover — see setCover(). + private var cover = false + // Bumped on every activate/deactivate: a retry or capture callback carrying a + // stale generation must not touch the current session. + private var generation = 0 + private var cameraId = "" + private var device: CameraDevice? = null + private var session: CameraCaptureSession? = null + private var reader: ImageReader? = null + private var previewSize: Size? = null + private var photoSize: Size? = null + // Preview stream of the live session, kept so a tap can re-issue the request + // with a metering region. + private var previewSurface: Surface? = null + // Active pixel array of the sensor: the coordinate space Camera2 wants for + // CONTROL_AF_REGIONS / CONTROL_AE_REGIONS. + private var activeArray: Rect? = null + private var sensorOrientation = 90 + private var busy = false + private var pending: Promise? = null + // Held by closeAsync() until the HAL has really let go of the lens; see + // release(). + private var closedPromise: Promise? = null + private val mainHandler = Handler(Looper.getMainLooper()) + // Frames drawn by the current session: leaving 0.5x while the lens is still + // opening must not hold a never-painted (black) frame over the viewfinder. + private var frameCount = 0 + + init { + // The frame is laid out at the buffer's own size and only then turned onto + // the viewfinder box, so the overflow (if the frame is wider than the box) + // has to be clipped away. + clipChildren = true + val fill = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + addView(texture, fill) + holdView.visibility = View.GONE + holdView.scaleType = ImageView.ScaleType.FIT_XY + addView(holdView, fill) + live = this + texture.surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) { + val p = previewSize + if (p != null) st.setDefaultBufferSize(p.width, p.height) + if (active) openCamera() + } + + override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) {} + override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean = true + override fun onSurfaceTextureUpdated(st: SurfaceTexture) { + frameCount++ + // First frame of a fresh session: only now may the held frame go, see + // syncViews(). + if (frameCount == 1) syncViews() + } + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + applyPreviewTransform() + } + + // ReactViewGroup lays nobody out, and a TextureView stretches its buffer onto + // whatever box it is given. So the texture is laid out 1:1 with the frame and + // framed with the plain view properties, which act in the container's space: + // unlike the setTransform matrix (which works inside the already-stretched + // texture space) they cannot come out sideways or sheared. + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val p = previewSize + if (p == null) { + texture.layout(0, 0, r - l, b - t) + holdView.layout(0, 0, r - l, b - t) + } else { + texture.layout(0, 0, p.width, p.height) + holdView.layout(0, 0, p.width, p.height) + } + applyPreviewTransform() + } + + /** + * Frame the ultra-wide exactly like the main preview: the full-width 3:4 box. + * The preview stream of this (hidden) ultra-wide id already arrives the right + * way up — same orientation as its JPEG, merely squeezed into the 4:3 buffer — + * so there is nothing to rotate: stretching the buffer onto the 3:4 box both + * un-squeezes it and fills the box, which is what the main preview shows too. + * Rotation and scale act about the texture's own centre, so aligning the two + * centres is enough. + */ + private fun applyPreviewTransform() { + val p = previewSize ?: return + val cw = width.toFloat() + val ch = height.toFloat() + if (cw <= 0f || ch <= 0f) return + val pw = p.width.toFloat() + val ph = p.height.toFloat() + // The sizing must happen here, not only in onLayout: openCamera() resolves + // the preview size *after* the first layout pass, so that pass laid the + // texture out at the container size and nothing put it back. + val boxH = min(ch, cw * 4f / 3f) + for (v in listOf(texture, holdView)) { + v.layout(0, 0, p.width, p.height) + v.rotation = 0f + v.scaleX = cw / pw + v.scaleY = boxH / ph + v.translationX = cw / 2f - pw / 2f + v.translationY = ch / 2f - ph / 2f + } + } + + /** + * Whether the ultra-wide stays on screen. The two back lenses are mutually + * exclusive, so leaving 0.5x closes this session and only then can the main + * one reopen — on screen that is a frozen stale frame, then the reopening + * session's out-of-focus, shifted first frames. JS keeps this true across + * that window so the held frame (holdFrame) covers it, and drops it once the + * main preview is live. + * + * Deliberately plain visibility toggles: a GONE TextureView stops being + * updated, so gating the first frame of a fresh session behind one would + * leave the view hidden forever (measured: the session opens on camera id 2 + * and nothing is ever drawn). + */ + fun setCover(next: Boolean) { + if (next == cover) return + cover = next + syncViews() + } + + private fun syncViews() { + texture.visibility = if (active) View.VISIBLE else View.GONE + // Held frame stays up until the fresh session has painted. Dissolving before + // that reveals the surface the session is still rebuilding — measured black + // for the rest of the open on this device, right where the 0.5x -> 1x swap + // is supposed to look seamless. During a rebind frameCount is 0, so the + // gate is "this session has drawn something", not "is active". + // A session the caller handed back through release() is gone: its last + // frameCount must not read as fresh, or the held frame would drop off a + // dead (black) texture. + val fresh = active && device != null && frameCount > 0 + val holding = !fresh && (cover || frameCount == 0) && holdView.drawable != null + if (holding) { + holdView.visibility = View.VISIBLE + } else if (holdView.visibility == View.VISIBLE) { + // The incoming preview is already settled by now — dissolve rather than + // cut, so the swap between two different lenses reads as one motion. + holdView.animate().alpha(0f).setDuration(160).withEndAction { + holdView.visibility = View.GONE + holdView.alpha = 1f + holdView.setImageDrawable(null) + } + } + } + + /** + * Copy the frame that is on screen into the hold layer. Closing the device + * abandons the texture's buffer queue: the last frame does NOT stay on screen + * (measured — the ultra-wide content disappears within one frame of the + * close), so it has to be copied out first. + */ + private fun holdFrame() { + // No frame drawn by this session yet (left again while the lens was still + // opening): the texture has nothing worth holding, and copying it out would + // paste a black frame over the viewfinder. Same once the device is gone — + // release() already froze that frame, and the dead texture now reads black. + if (device == null || frameCount == 0) return + val bmp = try { + texture.bitmap + } catch (e: Throwable) { + null + } + if (bmp == null) return + holdView.animate().cancel() + holdView.alpha = 1f + holdView.setImageBitmap(bmp) + } + + fun setActive(next: Boolean) { + if (next == active) return + active = next + generation++ + if (next) { + frameCount = 0 + // openCamera() resolves the sizes itself, so only the surface needs to be + // ready here — the texture is usually already available, in which case its + // listener has long since fired (while inactive) and will not fire again. + val st = texture.surfaceTexture + if (st != null) { + val p = previewSize + if (p != null) st.setDefaultBufferSize(p.width, p.height) + openCamera() + } + // Otherwise the surface listener opens it once the texture is ready. + } else { + // Always copy the outgoing frame before the device closes — `setCover` + // decides whether it is drawn, and the two props may land in either order. + holdFrame() + closeAll() + } + syncViews() + } + + /** + * Hand the lens back: freeze the outgoing frame, close the session and resolve + * once the platform says the device is gone. The caller re-activates the 1x + * preview only on that signal, because CameraX opens the main lens the moment + * it is switched back on and the HAL refuses that open while this one still + * holds the back camera slot — it is retried on a fixed ~520ms timeout, which + * is exactly the cost this handshake removes. + */ + fun release(promise: Promise) { + if (device == null && session == null) { + promise.resolve(null) + return + } + holdFrame() + closedPromise = promise + closeAll() + // frameCount stays put: it is what tells syncViews this lens did paint, + // so the held frame above keeps covering the main preview until the caller + // drops it (holding on a never-painted session would freeze that frame). + syncViews() + camHandler.postDelayed({ onReleased() }, RELEASE_MAX_MS) + } + + /** Back to 0.5x while a release() was still in flight: take the lens again. */ + fun reopen() { + if (!active || device != null) return + frameCount = 0 + openCamera() + } + + private fun onReleased() { + val p = closedPromise ?: return + closedPromise = null + mainHandler.post { p.resolve(null) } + } + + private fun openCamera(attempt: Int = 0) { + if (!active) return + val ctx = context.applicationContext + val id = cameraId.ifEmpty { findUltraWideId(ctx) } + if (id.isEmpty()) { + Log.w(TAG, "no ultra-wide camera on this device") + return + } + cameraId = id + val gen = generation + try { + val cs = cameraManager(ctx).getCameraCharacteristics(id) + val map = cs.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + sensorOrientation = cs.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90 + val ps = pick4x3(map?.getOutputSizes(SurfaceTexture::class.java), 1920 * 1440) + val fs = pick4x3(map?.getOutputSizes(ImageFormat.JPEG), Int.MAX_VALUE) + activeArray = cs.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) + if (ps == null || fs == null) { + Log.w(TAG, "ultra-wide $id has no preview/jpeg size") + return + } + if (ps != previewSize) { + previewSize = ps + texture.surfaceTexture?.setDefaultBufferSize(ps.width, ps.height) + applyPreviewTransform() + } + photoSize = fs + } catch (e: Throwable) { + Log.w(TAG, "characteristics($id) failed: ${e.message}") + return + } + + cameraManager(ctx).openCamera(id, object : CameraDevice.StateCallback() { + override fun onOpened(dev: CameraDevice) { + if (gen != generation) { + dev.close() + return + } + device = dev + createSession(dev, gen) + } + + override fun onDisconnected(dev: CameraDevice) { + dev.close() + if (device === dev) device = null + if (gen == generation && active) camHandler.postDelayed({ openCamera() }, OPEN_RETRY_MS) + } + + override fun onError(dev: CameraDevice, error: Int) { + dev.close() + if (device === dev) device = null + Log.w(TAG, "openCamera($id) error $error (attempt $attempt)") + if (gen == generation && active && attempt < OPEN_MAX_ATTEMPTS) { + camHandler.postDelayed({ openCamera(attempt + 1) }, OPEN_RETRY_MS) + } + } + + override fun onClosed(dev: CameraDevice) { + onReleased() + } + }, camHandler) + } + + private fun createSession(dev: CameraDevice, gen: Int) { + val p = previewSize ?: return + val f = photoSize ?: return + val st = texture.surfaceTexture ?: return + st.setDefaultBufferSize(p.width, p.height) + val surface = Surface(st) + previewSurface?.release() + previewSurface = surface + val ir = ImageReader.newInstance(f.width, f.height, ImageFormat.JPEG, 2) + ir.setOnImageAvailableListener({ r -> onJpegAvailable(r) }, camHandler) + reader = ir + + val builder = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) + builder.addTarget(surface) + builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + try { + dev.createCaptureSession( + listOf(surface, ir.surface), + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(s: CameraCaptureSession) { + if (gen != generation) { + s.close() + return + } + session = s + try { + s.setRepeatingRequest(builder.build(), null, camHandler) + } catch (e: Throwable) { + Log.w(TAG, "repeating request failed: ${e.message}") + } + } + + override fun onConfigureFailed(s: CameraCaptureSession) { + Log.w(TAG, "session config failed") + if (gen == generation && active) camHandler.postDelayed({ openCamera() }, OPEN_RETRY_MS) + } + }, + camHandler + ) + } catch (e: Throwable) { + Log.w(TAG, "createCaptureSession failed: ${e.message}") + } + } + + fun capture(promise: Promise) { + val dev = device + val s = session + val ir = reader + if (dev == null || s == null || ir == null) { + promise.reject("ERR_NOT_READY", "Ultra-wide camera is not ready", null) + return + } + if (busy) { + promise.reject("ERR_BUSY", "A capture is already in flight", null) + return + } + busy = true + pending = promise + val gen = generation + try { + val req = dev.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE) + req.addTarget(ir.surface) + req.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation) + s.capture(req.build(), null, camHandler) + } catch (e: Throwable) { + fail("ERR_CAPTURE", "Capture request failed: ${e.message}") + return + } + camHandler.postDelayed({ + if (busy && gen == generation) fail("ERR_TIMEOUT", "Ultra-wide capture timed out") + }, CAPTURE_TIMEOUT_MS) + } + + private fun onJpegAvailable(r: ImageReader) { + val image = try { + r.acquireNextImage() + } catch (e: Throwable) { + null + } + if (image == null) { + fail("ERR_IMAGE", "No image from the ultra-wide capture") + return + } + val out = File(context.cacheDir, "uw_${System.currentTimeMillis()}.jpg") + try { + val buf = image.planes[0].buffer + val bytes = ByteArray(buf.remaining()) + buf.get(bytes) + FileOutputStream(out).use { it.write(bytes) } + } catch (e: Throwable) { + fail("ERR_WRITE", "Could not write the ultra-wide photo: ${e.message}") + return + } finally { + image.close() + } + val p = pending + pending = null + busy = false + Log.i(TAG, "captured ${out.absolutePath} (${out.length()} bytes)") + p?.resolve(out.absolutePath) + } + + private fun fail(code: String, message: String) { + val p = pending + pending = null + busy = false + p?.reject(code, message, null) + } + + /** + * Tap-to-focus + spot metering on the native lens. Camera2 wants metering + * regions in active-array coordinates, so the tap travels back through the + * preview transform. This lens is a landscape 4000x3000 sensor + * (SENSOR_ORIENTATION 90) whose stream reaches the portrait box already + * rotated by the HAL, so the tap's vertical fraction is the sensor's x and + * its horizontal fraction, flipped, is the sensor's y. Measured on device by + * metering 25 taps and ranking the settled frame brightness against the + * scene: this axis order scores +0.72, every other one +0.20 or worse. + */ + private fun sensorRegion(x: Float, y: Float): Rect? { + val arr = activeArray ?: return null + val cw = width.toFloat() + val ch = height.toFloat() + if (cw <= 0f || ch <= 0f) return null + val boxH = min(ch, cw * 4f / 3f) + val u = (x / cw).coerceIn(0f, 1f) + val v = ((y - (ch - boxH) / 2f) / boxH).coerceIn(0f, 1f) + val px = arr.left + v * arr.width() + val py = arr.top + (1f - u) * arr.height() + val hw = arr.width() * METER_FRACTION + val hh = arr.height() * METER_FRACTION + return Rect( + (px - hw).toInt().coerceIn(arr.left, arr.right - 1), + (py - hh).toInt().coerceIn(arr.top, arr.bottom - 1), + (px + hw).toInt().coerceIn(arr.left + 1, arr.right), + (py + hh).toInt().coerceIn(arr.top + 1, arr.bottom) + ) + } + + /** Preview request that meters [region] only; null = whole-frame continuous. */ + private fun meter(region: Rect?, trigger: Boolean = false): CaptureRequest? { + val dev = device ?: return null + val ps = previewSurface ?: return null + val b = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) + b.addTarget(ps) + if (region == null) { + b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + return b.build() + } + val regions = arrayOf(MeteringRectangle(region, MeteringRectangle.METERING_WEIGHT_MAX)) + // AF_MODE_AUTO holds the lens where the scan leaves it, so the tapped plane + // stays in focus until the next tap or the unlock. Triggering inside the + // repeating request itself would restart the scan every frame and never + // lock, which is why the trigger goes out as one extra frame. + b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO) + b.set(CaptureRequest.CONTROL_AF_REGIONS, regions) + b.set(CaptureRequest.CONTROL_AE_REGIONS, regions) + if (trigger) { + b.set(CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START) + b.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START) + } + return b.build() + } + + fun focusAt(x: Float, y: Float) { + val s = session ?: return + val region = sensorRegion(x, y) ?: return + val hold = meter(region) ?: return + val trigger = meter(region, trigger = true) ?: return + try { + s.setRepeatingRequest(hold, null, camHandler) + s.capture(trigger, null, camHandler) + } catch (e: Throwable) { + Log.w(TAG, "focusAt failed: ${e.message}") + } + } + + /** Back to whole-frame continuous AF/AE (the tap again to unlock gesture). */ + fun resetFocus() { + val s = session ?: return + val back = meter(null) ?: return + try { + s.setRepeatingRequest(back, null, camHandler) + } catch (e: Throwable) { + Log.w(TAG, "resetFocus failed: ${e.message}") + } + } + + private fun closeAll() { + fail("ERR_CLOSED", "Ultra-wide camera closed") + try { + session?.close() + } catch (e: Throwable) { + Log.w(TAG, "session close: ${e.message}") + } + session = null + try { + reader?.close() + } catch (e: Throwable) { + Log.w(TAG, "reader close: ${e.message}") + } + reader = null + try { + device?.close() + } catch (e: Throwable) { + Log.w(TAG, "device close: ${e.message}") + } + device = null + try { + previewSurface?.release() + } catch (e: Throwable) { + Log.w(TAG, "surface release: ${e.message}") + } + previewSurface = null + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + closeAll() + if (live === this) live = null + } +} + +class UltraWideModule : Module() { + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun definition() = ModuleDefinition { + Name("RecipesCamUltraWide") + + AsyncFunction("availableAsync") { -> + val ctx = appContext.reactContext?.applicationContext ?: return@AsyncFunction "" + val id = findUltraWideId(ctx) + id + } + + View(UltraWidePreviewView::class) { + Prop("active") { view: UltraWidePreviewView, active: Boolean -> view.setActive(active) } + Prop("cover") { view: UltraWidePreviewView, cover: Boolean -> view.setCover(cover) } + } + + AsyncFunction("captureAsync") { promise: Promise -> + val view = UltraWidePreviewView.current() + if (view == null) { + promise.reject("ERR_NO_VIEW", "Ultra-wide preview is not mounted", null) + } else { + view.capture(promise) + } + } + + // Tap coordinates arrive in dp, like every other React Native touch point; + // the view transform works in pixels. + AsyncFunction("focusAsync") { x: Double, y: Double -> + val density = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f + UltraWidePreviewView.current()?.focusAt((x * density).toFloat(), (y * density).toFloat()) + } + + AsyncFunction("resetFocusAsync") { + UltraWidePreviewView.current()?.resetFocus() + } + + // Resolves once the ultra-wide has actually released the back camera slot, + // so the caller can re-activate the 1x preview without the open being + // refused (see UltraWidePreviewView.release). + // Expo dispatches async functions on its own queue, not the main thread, + // and ViewRootImpl kills the process outright when another thread touches a + // view — so the calls that reach into the view hierarchy are posted here. + AsyncFunction("closeAsync") { promise: Promise -> + val view = UltraWidePreviewView.current() + if (view == null) promise.resolve(null) else mainHandler.post { view.release(promise) } + } + + AsyncFunction("reopenAsync") { + val view = UltraWidePreviewView.current() + if (view != null) mainHandler.post { view.reopen() } + } + } +} diff --git a/modules/recipescam-ultrawide/expo-module.config.json b/modules/recipescam-ultrawide/expo-module.config.json new file mode 100644 index 0000000..3b518d7 --- /dev/null +++ b/modules/recipescam-ultrawide/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "platforms": ["android"], + "android": { + "modules": [ + "com.locphamtran.recipescamera.ultrawide.UltraWideModule" + ] + } +} diff --git a/modules/recipescam-ultrawide/index.ts b/modules/recipescam-ultrawide/index.ts new file mode 100644 index 0000000..09ab565 --- /dev/null +++ b/modules/recipescam-ultrawide/index.ts @@ -0,0 +1,47 @@ +import { requireNativeModule, requireNativeViewManager } from 'expo-modules-core'; +import type { ComponentType } from 'react'; +import type { ViewProps } from 'react-native'; + +export interface UltraWideModule { + /** Hidden back ultra-wide camera id ('' when this device has none). */ + availableAsync(): Promise; + /** Still capture from the ultra-wide lens; resolves to an absolute file path. */ + captureAsync(): Promise; + /** + * Tap-to-focus + spot metering on the ultra-wide lens, at a point in the + * preview view (React Native dp, same space as a touch's locationX/Y). The + * main CameraX session is stopped while 0.5x is live, so this is the only + * focus path that exists in that mode. + */ + focusAsync(x: number, y: number): Promise; + /** Back to whole-frame continuous AF/AE. */ + resetFocusAsync(): Promise; + /** + * Closes the native session and resolves once the HAL has really released the + * back camera slot. Leaving 0.5x must wait for this before switching the 1x + * preview back on: the two back lenses cannot open at once, so an early 1x + * open is refused and only retried ~520ms later. + */ + closeAsync(): Promise; + /** Takes the ultra-wide again after a closeAsync whose handoff was cancelled. */ + reopenAsync(): Promise; +} + +export interface UltraWidePreviewProps extends ViewProps { + /** Opens the camera while true, closes it on false/unmount. */ + active: boolean; + /** + * Draws this view while true, session or not. While the session is open the + * first frame gates it (the previous session's last frame must not show), and + * after it closes the texture holds its last frame — which is what lets the + * caller keep the ultra-wide on screen until the main preview is live again. + */ + cover: boolean; +} + +export const UltraWidePreview = requireNativeViewManager( + 'RecipesCamUltraWide', + 'UltraWidePreviewView' +) as ComponentType; + +export default requireNativeModule('RecipesCamUltraWide') as UltraWideModule; diff --git a/modules/recipescam-ultrawide/package.json b/modules/recipescam-ultrawide/package.json new file mode 100644 index 0000000..a249a08 --- /dev/null +++ b/modules/recipescam-ultrawide/package.json @@ -0,0 +1,8 @@ +{ + "name": "recipescam-ultrawide", + "version": "0.1.0", + "description": "Native Camera2 ultra-wide (0.5x) preview + still capture for Recipescam.", + "main": "index.ts", + "license": "MIT", + "private": true +} diff --git a/package-lock.json b/package-lock.json index d6342dd..df15f56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "expo": "~57.0.7", "expo-asset": "~57.0.5", "expo-dev-client": "~57.0.6", + "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", @@ -3446,6 +3447,15 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.2.tgz", + "integrity": "sha512-vVBbBNg0piMJUIxjB+DR6JaZZpcSl90ZUoywLA/EgNHTfRkZW+9axP97UxM2a0Zm0W6WfH3cLzzGAEfP9OKGwg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.1.tgz", diff --git a/package.json b/package.json index ce4cdfd..319d18b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "expo": "~57.0.7", "expo-asset": "~57.0.5", "expo-dev-client": "~57.0.6", + "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", @@ -43,7 +44,9 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web" + "web": "expo start --web", + "apk:lite": "node tools/set-variant.mjs lite && cd android && gradlew.bat assembleLiteRelease --console=plain", + "apk:pro": "node tools/set-variant.mjs pro && cd android && gradlew.bat assembleProRelease --console=plain" }, "private": true, "allowScripts": { diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 586d248..653c5fb 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -108,6 +108,9 @@ interface AdjustmentPanelProps { onToggleWallLandscape: () => void; onToggleGeotag: (enabled: boolean) => void; onSaveRecipe: (name: string) => void; + // IMPORT: pick a recipe file shared from another phone and store it as a + // normal custom recipe. + onImportRecipe: () => void; onDeleteRecipe: (id: string) => void; // WATERMARK tab state — lifted to App so the live Viewfinder overlay, this // input and the export options all read the same object. @@ -171,7 +174,7 @@ const WB_PAIRS: Record = { daylight: { t: 5500, ti: 0 }, 'daylight-3r': { t: 5500, ti: -3 }, cloudy: { t: 6500, ti: 1 }, - shade: { t: 8000, ti: 2 }, + shade: { t: 7500, ti: 2 }, tungsten: { t: 3200, ti: 0 }, fluorescent: { t: 4000, ti: 3 }, }; @@ -239,6 +242,7 @@ export default function AdjustmentPanel({ wallLandscape, onToggleWallLandscape, onToggleGeotag, + onImportRecipe, onDeleteRecipe, customWm, onUpdateCustomWm, @@ -791,6 +795,12 @@ export default function AdjustmentPanel({ active: false, onPress: () => setCreateVisible(true), }, + { + key: 'import', + label: 'IMPORT', + active: false, + onPress: onImportRecipe, + }, ], 8 ); @@ -1024,6 +1034,7 @@ export default function AdjustmentPanel({ { setCreateVisible(false); diff --git a/src/components/CameraControls.tsx b/src/components/CameraControls.tsx index 76b95fa..7c42aae 100644 --- a/src/components/CameraControls.tsx +++ b/src/components/CameraControls.tsx @@ -6,6 +6,10 @@ import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw } from ' interface CameraControlsProps { lastPhotoUri: string | null; onCapture: () => void; + // Held shutter = burst: fire on long-press, stop when the finger lifts. + // Absent handlers (library mode) leave the button a plain tap. + onBurstStart?: () => void; + onBurstStop?: () => void; onPickImage: () => void; onOpenPreview: () => void; onOpenSettings: () => void; @@ -17,6 +21,8 @@ interface CameraControlsProps { export default function CameraControls({ lastPhotoUri, onCapture, + onBurstStart, + onBurstStop, onPickImage, onOpenPreview, onOpenSettings, @@ -55,6 +61,11 @@ export default function CameraControls({ {/* Dynamic Center Button: Shutter or Save */} diff --git a/src/components/RecipeCreateModal.tsx b/src/components/RecipeCreateModal.tsx index bfda6bf..6470f9f 100644 --- a/src/components/RecipeCreateModal.tsx +++ b/src/components/RecipeCreateModal.tsx @@ -26,6 +26,10 @@ interface RecipeCreateModalProps { // Id of the currently applied film sim (or bundled recipe whose look should // prefill the SIMULATION row); undefined → default to PROVIPES. currentBase?: BaseFilter; + // The look live on screen. CREATE opens on THESE values (not on the sim's + // standard ones) so a recipe is a snapshot of what the user is looking at; + // every knob the form has no row for rides along on SAVE. + current?: ColorAdjustments | null; // When set the form opens as an EDITOR: every row (name included) is // prefilled from this recipe instead of the sim's standard values, and // SAVE hands the edited copy back for the caller to store. @@ -144,16 +148,24 @@ const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps export default function RecipeCreateModal({ visible, currentBase, + current, initial, onClose, onSave, }: RecipeCreateModalProps) { const insets = useSafeAreaInsets(); - // Seed from the current film sim (or PROVIPES) so "other params" start at the - // sim's standard values and the user only types what they want to change. + // Seed from what the user is LOOKING AT: the recipe being edited, else the live + // adjustments, else the current sim's standard values. Seeding from the sim's + // standard values alone (the old behaviour) meant the form showed — and SAVE + // wrote — neutral numbers for knobs the user had already dialled in. const seedBase: BaseFilter = - currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia'; - const seedAdj = FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS; + initial?.baseFilter ?? + (currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia'); + const seedAdj: ColorAdjustments = + initial?.adjustments ?? + current ?? + FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? + DEFAULT_ADJUSTMENTS; // Every sim's standard values as form state — used to seed the modal and to // reload the form when the SIMULATION chip changes. @@ -161,13 +173,19 @@ export default function RecipeCreateModal({ const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS; return { dr: String(s.dynamicRange ?? 'auto'), - grain: String(s.grain === 0 ? 0 : s.grain >= 6 ? 6 : 3), + // Raw, not rounded to the three chips: a look sitting on grain 2 must not + // become 3 just because the form was opened (and the chip row below shows + // that value as its own chip when it is none of OFF/WEAK/STRONG). + grain: String(s.grain ?? 0), cc: s.colorChrome ?? 'none', ccb: s.colorChromeBlue ?? 'none', + // 'custom' = a WB the six chips do not name (a hand-set 6300K, say). It + // keeps its own chip and its own value; the old AUTO fallback rewrote the + // recipe's WB to 5500K on every save. wbKey: WB_KEYS.find((k) => { const p = WB_PRESETS.find((w) => w.key === k)!; return p.temperature === s.temperature && p.tint === s.tint; - }) ?? 'auto', + }) ?? 'custom', highlight: String(s.highlight), shadow: String(s.shadow), color: String(s.saturation), @@ -181,14 +199,14 @@ export default function RecipeCreateModal({ const [sim, setSim] = useState(seedBase); const [name, setName] = useState(''); const [dr, setDr] = useState(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange)); - const [grain, setGrain] = useState(String(seedAdj.grain === 0 ? 0 : seedAdj.grain >= 6 ? 6 : 3)); + const [grain, setGrain] = useState(String(seedAdj.grain ?? 0)); const [cc, setCc] = useState(seedAdj.colorChrome ?? 'none'); const [ccb, setCcb] = useState(seedAdj.colorChromeBlue ?? 'none'); const [wbKey, setWbKey] = useState( WB_KEYS.find((k) => { const p = WB_PRESETS.find((w) => w.key === k)!; return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint; - }) ?? 'auto' + }) ?? 'custom' ); const [wbRed, setWbRed] = useState('0'); const [wbBlue, setWbBlue] = useState('0'); @@ -208,15 +226,15 @@ export default function RecipeCreateModal({ // Editing a stored recipe wins over the live sim seed: the form must show // that recipe's own values, not the sim's standard ones. const base = initial?.baseFilter ?? seedBase; - const v = valuesOf(base, initial?.adjustments); + const v = valuesOf(base, seedAdj); setSim(base); setDr(v.dr); setGrain(v.grain); setCc(v.cc); setCcb(v.ccb); setWbKey(v.wbKey); - setWbRed(String(initial?.adjustments.wbRed ?? 0)); - setWbBlue(String(initial?.adjustments.wbBlue ?? 0)); + setWbRed(String(seedAdj.wbRed ?? 0)); + setWbBlue(String(seedAdj.wbBlue ?? 0)); setFHigh(v.highlight); setFShadow(v.shadow); setFColor(v.color); @@ -250,13 +268,19 @@ export default function RecipeCreateModal({ }; const handleSave = () => { - const wb = WB_PRESETS.find((w) => w.key === wbKey) ?? WB_PRESETS[0]; + const wb = WB_PRESETS.find((w) => w.key === wbKey); const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS; + // The base is what the user was looking at (CREATE) or the recipe being + // edited. Spreading the sim's neutral values here instead — the old + // behaviour — silently zeroed every knob the form has no row for: exposure, + // contrast, vibrance, vignetting, glow, the WB red/blue shift. That is what + // made a saved recipe apply a different look from the one on screen. const adj: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, - ...simAdj, - temperature: wb.temperature, - tint: wb.tint, + ...seedAdj, + // A WB with no preset of its own keeps the value it was seeded with. + temperature: wb ? wb.temperature : seedAdj.temperature, + tint: wb ? wb.tint : seedAdj.tint, wbRed: int(wbRed, 0, -9, 9), wbBlue: int(wbBlue, 0, -9, 9), highlight: int(fHigh, simAdj.highlight, -10, 10), @@ -265,7 +289,7 @@ export default function RecipeCreateModal({ denoise: int(fNr, simAdj.denoise, 0, 10), sharpening: int(fSharp, simAdj.sharpening, 0, 10), clarity: int(fClarity, simAdj.clarity, -10, 10), - grain: parseInt(grain, 10), + grain: int(grain, seedAdj.grain, 0, 10), colorChrome: cc as ColorAdjustments['colorChrome'], colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'], dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'], @@ -354,6 +378,9 @@ export default function RecipeCreateModal({ {GRAIN_OPTS.map((o) => ( setGrain(o.v)} /> ))} + {!GRAIN_OPTS.some((o) => o.v === grain) && ( + {}} /> + )} COLOR CHROME EFFECT @@ -375,6 +402,9 @@ export default function RecipeCreateModal({ {WB_PRESETS.map((w) => ( setWbKey(w.key)} /> ))} + {wbKey === 'custom' && ( + setWbKey('custom')} /> + )} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index f0d3e6c..b693aaf 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -31,6 +31,8 @@ interface SettingsModalProps { onMeterModeChange: (mode: MeterMode) => void; shutterSound: ShutterSound; onShutterSound: (sound: ShutterSound) => void; + touchToShutter: boolean; + onTouchToShutter: (enabled: boolean) => void; gpsEnabled: boolean; onGpsEnabled: (enabled: boolean) => void; startupMode: StartupMode; @@ -95,6 +97,8 @@ export default function SettingsModal({ onMeterModeChange, shutterSound, onShutterSound, + touchToShutter, + onTouchToShutter, gpsEnabled, onGpsEnabled, startupMode, @@ -200,6 +204,26 @@ export default function SettingsModal({ + {/* Touch to shutter */} + + TOUCH TO SHUTTER + onTouchToShutter(true) } + : { key: 'tts-on', label: 'ON', active: touchToShutter, disabled: true, onPress: () => {} }, + cameraMode + ? { key: 'tts-off', label: 'OFF', active: !touchToShutter, onPress: () => onTouchToShutter(false) } + : { key: 'tts-off', label: 'OFF', active: !touchToShutter, disabled: true, onPress: () => {} }, + ]} + /> + + Tap the live image to lock focus on that point, then take the photo the moment focus settles. + OFF: taps only focus. + + + + {/* Composition ratio + RAW */} PHOTO RATIO diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 6c0f7da..dc494f7 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native'; +import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform, Alert } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Plus, Pencil } from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; @@ -13,9 +13,12 @@ interface TopBarProps { // Also custom-only: opens the recipe editor — the CREATE form prefilled // with this recipe's parameters and name. onEditRecipe?: () => void; + // SAVE & SHARE -> EXPORT: write this look to an encrypted recipe file and + // hand it to the system share sheet so it can reach another phone. + onExportShare: (name: string) => void; } -export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe }: TopBarProps) { +export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe, onExportShare }: TopBarProps) { const insets = useSafeAreaInsets(); const [modalVisible, setModalVisible] = useState(false); const [newRecipeName, setNewRecipeName] = useState(''); @@ -27,6 +30,20 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci setModalVisible(true); }; + // SAVE & SHARE: two ways out of the same look — keep it as a recipe on this + // phone, or export it as a file another phone can import. + const openSaveMenu = () => { + Alert.alert( + 'SAVE & SHARE', + 'Save the look on this screen as a recipe, or export it as a file to share.', + [ + { text: 'CANCEL', style: 'cancel' }, + { text: 'EXPORT & SHARE', onPress: () => onExportShare(recipeName) }, + { text: 'SAVE RECIPE', onPress: openModal }, + ], + ); + }; + const run = (fn: (name: string) => void) => { const name = newRecipeName.trim(); if (!name) return; @@ -68,13 +85,13 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci )} - - SAVE + + SAVE & SHARE diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 1f77a24..ca01ab4 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake'; import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera'; import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia'; +import UltraWide, { UltraWidePreview } from '../../modules/recipescam-ultrawide'; import { createSynchronizable } from 'react-native-worklets'; import { Canvas, @@ -18,6 +19,7 @@ import { Rect, RadialGradient, ColorMatrix, + Blur, Image as SkiaImage, ImageShader, BlendMode, @@ -29,7 +31,7 @@ import { import { Lock } from 'lucide-react-native'; import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; -import { TONE_SKSL, getToneUniforms, toneUniformArray } from '../utils/toneShader'; +import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; import { applyPhotoRotation } from '../utils/skiaImage'; @@ -111,6 +113,9 @@ interface ViewfinderProps { // undone; the finger coming off asks for the edited look back, so the user // can flick between the before and the after. Absent prop = feature off. onComparePeek?: (on: boolean) => void; + // Touch-to-shutter (gear sheet): a plain tap focuses as always, then fires a + // capture once the focus promise settles. Absent prop = tap only focuses. + onTouchShutter?: () => void; // Custom text watermark (WATERMARK tab -> CUSTOM chip). Drawn live on the // image like the GPS mark; dragging/tapping the image moves it. Fractions are // 0..1 of the photo/window area. Absent prop = feature off. @@ -157,6 +162,12 @@ export interface ViewfinderHandle { * exactly the crop the user framed after pinch/drag. */ getFrameWindowZoom(): { s: number; u: number; v: number } | null; + /** + * True while the 0.5x chip holds the native ultra-wide lens. The CameraX + * session is off then, so App must capture through the native module instead + * of `photoOutput`. + */ + isUltraWide(): boolean; /** * FRAME-tab CROP with a fixed ratio: the part of the photo the on-screen * keep-band shows, as 0..1 fractions of the (post-rotation) source image, so @@ -205,6 +216,29 @@ const cinemaEffect = (() => { } })(); +// Bright Pass Filter effect for the HDF glow (GLOW_SKSL) — hoisted with the +// two above so the preview worklet can capture it instead of compiling per +// frame. +const glowEffect = (() => { + try { + return Skia.RuntimeEffect.Make(GLOW_SKSL); + } catch (e) { + console.error('GLOW_SKSL compile failed: ' + e); + return null; + } +})(); + +// Unsharp twin of the camera worklet's MakeMatrixConvolution (see CLARITY_SKSL) +// for the library preview, which draws through the declarative tree instead. +const clarityEffect = (() => { + try { + return Skia.RuntimeEffect.Make(CLARITY_SKSL); + } catch (e) { + console.error('CLARITY_SKSL compile failed: ' + e); + return null; + } +})(); + // Plain numeric primitives (captured by value into the preview worklet — the // worklet runtime can't read RNSkia's enum namespace objects directly). const T_CLAMP = TileMode.Clamp; @@ -237,6 +271,7 @@ const Viewfinder = forwardRef(function Viewfi meteringAdjustments, imageAdjustTarget, onComparePeek, + onTouchShutter, customWm = { enabled: false, text: '', x: 0.5, y: 0.5 }, onWmPositionChange, gpsWm, @@ -426,12 +461,20 @@ const Viewfinder = forwardRef(function Viewfi // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + // Clarity (-10..10): local unsharp mask / mist, the same numbers exportEngine + // uses, so the live preview and the exported file agree. 0 = no filter. + const claritySync = useMemo( + () => createSynchronizable(adjustments.clarity ?? 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); useEffect(() => { colorMatrixSync.setBlocking(colorMatrix); toneSync.setBlocking(toneParams); cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat()); hdfSync.setBlocking(adjustments.hdf ?? 0); - }, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, colorMatrixSync, toneSync, cinemaSync, hdfSync]); + claritySync.setBlocking(adjustments.clarity ?? 0); + }, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]); // Camera controller lives behind SkiaCamera. The camera AE bias is never // written: EV (user + metering offset) is purely software matrix gain, for @@ -500,13 +543,123 @@ const Viewfinder = forwardRef(function Viewfi useEffect(() => { zoomRef.current = zoomRatio; }, [zoomRatio]); - const applyZoom = useCallback((ratio: number) => { - setZoomRatio(ratio); - const ctl = skiaCameraRef.current?.controller; - if (ctl) { - ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120))); - } + // The back ultra-wide lens is a hidden Camera2 id (see the native module), so + // 0.5x is not a CameraX zoom ratio: the chip swaps the live preview over to a + // native Camera2 session on that lens. Only the back side has one. + const [ultraWideId, setUltraWideId] = useState(''); + const [ultraWideOn, setUltraWideOn] = useState(false); + const ultraWideOnRef = useRef(false); + ultraWideOnRef.current = ultraWideOn; + useEffect(() => { + let alive = true; + UltraWide.availableAsync() + .then((id) => { + if (alive) setUltraWideId(id || ''); + }) + .catch((e) => console.warn('Ultra-wide probe failed', String(e).slice(0, 120))); + return () => { + alive = false; + }; }, []); + const ultraWideReady = ultraWideId !== '' && facing === 'back'; + const ultraWideReadyRef = useRef(false); + ultraWideReadyRef.current = ultraWideReady; + // Handoff cover. The two back lenses cannot be open at once, so leaving 0.5x + // closes the native session first and the 1x preview only then reopens: for + // ~1s the viewfinder shows a frozen stale frame and then the reopening + // session's out-of-focus, shifted first frames. The ultra-wide keeps drawing + // its own last frame over that instead (the native `cover` prop), so the swap + // reads as one clean cut from 0.5x to 1x. + const [uwCover, setUwCover] = useState(false); + const uwCoverTimer = useRef | null>(null); + const uwFramePoll = useRef | null>(null); + const stopCoverTimer = useCallback(() => { + if (uwCoverTimer.current) clearTimeout(uwCoverTimer.current); + uwCoverTimer.current = null; + if (uwFramePoll.current) clearInterval(uwFramePoll.current); + uwFramePoll.current = null; + }, []); + const holdCover = useCallback( + (ms?: number) => { + stopCoverTimer(); + if (ms == null) { + setUwCover(true); + return; + } + uwCoverTimer.current = setTimeout(() => { + uwCoverTimer.current = null; + setUwCover(false); + }, ms); + }, + [stopCoverTimer] + ); + useEffect(() => stopCoverTimer, [stopCoverTimer]); + // Bumped on every lens handoff: a slow release belonging to an older one must + // not touch the state a newer zoom choice already owns. + const uwHandoff = useRef(0); + const applyZoom = useCallback( + (ratio: number) => { + const native = ratio === 0.5 && ultraWideReadyRef.current; + // The reticle points at a region of a lens that is about to go away. + if (native !== ultraWideOnRef.current) setFocusPoint(null); + setZoomRatio(ratio); + if (!native && ultraWideOnRef.current) { + // Hold the outgoing 0.5x frame until the main preview really paints + // again, instead of a fixed 700ms: measured on this device the fresh + // 1x session lands anywhere from 0.2s to 0.9s after the tap, so the + // old ceiling left a black hole when it ran slow and a stale frame + // when it ran fast. The frame beat advances on every delivered frame + // of the main camera, so it is the honest "1x is live" signal. + holdCover(1500); // ceiling: never keep the stale frame up for good + const beatAtSwap = frameBeatSync.getDirty(); + uwFramePoll.current = setInterval(() => { + if (frameBeatSync.getDirty() - beatAtSwap < 3) return; + stopCoverTimer(); + setUwCover(false); + }, 50); + // The main session is only switched back on once the native lens has + // actually let go of the back camera slot. Activating it earlier makes + // CameraX open the 1x while the HAL still holds that lens, and the + // refused open is retried on a fixed ~520ms timeout - the bulk of the + // handoff time. + const handoff = ++uwHandoff.current; + UltraWide.closeAsync() + .catch(() => {}) + .then(() => { + if (uwHandoff.current !== handoff) return; + setUltraWideOn(false); + }); + } else { + uwHandoff.current++; // a release still in flight belongs to the past now + setUltraWideOn(native); + if (native) { + holdCover(); + // Back to 0.5x while the previous handoff was still releasing the + // lens: it is closed by now, so ask for it back. + if (ultraWideOnRef.current) UltraWide.reopenAsync().catch(() => {}); + } + } + // The native lens is a different camera: the main session is off while + // it is live, so never carry the 0.5 over as a zoom ratio onto it. + zoomRef.current = native ? 1 : ratio; + const ctl = skiaCameraRef.current?.controller; + if (ctl && !native) { + ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120))); + } + }, + [holdCover, stopCoverTimer] + ); + // 0.5x is a chip wherever an ultra-wide can actually be shown: the hidden + // Camera2 lens above, or a bound CameraX device whose minZoomRatio reaches + // below 1 (a logical multi-camera with the ultra-wide wired in). A plain + // single-lens device reports 1 (and 0.0 before the session binds, which is + // why the gate is `> 0`), and setZoom rejects anything below min. + const zoomPresets = useMemo(() => { + const base = [1, 2, 3, 5, 10]; + const min = device?.minZoom ?? 1; + if (ultraWideReady) return [0.5, ...base]; + return min > 0 && min <= 0.5 ? [0.5, ...base] : base; + }, [device, ultraWideReady]); // Re-apply after the camera session restarts (background resume, camera↔ // library switch): a fresh session starts back at 1x. useEffect(() => { @@ -520,7 +673,10 @@ const Viewfinder = forwardRef(function Viewfi useEffect(() => { zoomRef.current = 1; setZoomRatio(1); - }, [facing]); + setUltraWideOn(false); + stopCoverTimer(); + setUwCover(false); + }, [facing, stopCoverTimer]); // The quick-EV bubble/drag is the recipe's OWN EV: `adjustments` above also // carries the metering-mode dip (highlight-weighted), so reading it here // showed EV -0.5 while the LIGHT tab slider sat at 0 — and a drag then wrote @@ -534,6 +690,9 @@ const Viewfinder = forwardRef(function Viewfi getFrameWindowZoom() { return frameZoomRef.current; }, + isUltraWide() { + return ultraWideOnRef.current; + }, getCropRatioRect() { return cropRatioRectRef.current; }, @@ -553,6 +712,17 @@ const Viewfinder = forwardRef(function Viewfi // preview (lift highlights -> AE underexposes -> looks unchanged), which is // why HL/SH edits read as "no effect" on the live preview. const handleTapFocus = async (x: number, y: number) => { + // 0.5x is a native lens whose CameraX session is stopped, so focusTo() + // has no camera behind it and the tap has to go to the module. + if (ultraWideOnRef.current) { + setFocusPoint({ x, y }); + try { + await UltraWide.focusAsync(x, y); + } catch (e) { + console.warn('Ultra-wide focus failed: ' + String(e).slice(0, 120)); + } + return; + } if (!cameraActive) return; const cam = skiaCameraRef.current; const d = cam?.controller?.device; @@ -572,6 +742,15 @@ const Viewfinder = forwardRef(function Viewfi } }; const handleUnlockFocus = async () => { + if (ultraWideOnRef.current) { + setFocusPoint(null); + try { + await UltraWide.resetFocusAsync(); + } catch (e) { + console.warn('Ultra-wide focus reset failed: ' + String(e).slice(0, 120)); + } + return; + } const cam = skiaCameraRef.current; if (!cam) return; try { @@ -865,6 +1044,11 @@ const Viewfinder = forwardRef(function Viewfi g.lastSent = rounded; g.param.onChange(rounded); }; + // Latest prop through a ref: the responder props read it on every event, but + // a ref keeps a stale closure from ever firing a capture after the toggle + // was switched off mid-gesture. + const onTouchShutterRef = useRef(onTouchShutter); + onTouchShutterRef.current = onTouchShutter; const onImageDragEnd = (e: any) => { const g = imgDragRef.current; imgDragRef.current = null; @@ -890,7 +1074,12 @@ const Viewfinder = forwardRef(function Viewfi if (!g.moved && !peeked) { // Plain tap: lock focus at the tapped point (drag never re-locks). A // hold-to-compare is not a tap either. - handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY); + // Touch-to-shutter chains the capture AFTER the focus promise: focusTo() + // only resolves once focus has fully settled (rejects on timeout/cancel), + // so waiting on it is exactly "focus first, then shoot" — no timer. + void handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY).then(() => + onTouchShutterRef.current?.() + ); } }; @@ -944,13 +1133,48 @@ const Viewfinder = forwardRef(function Viewfi } catch (e) { console.warn('Color matrix build failed: ' + e); } + // CLARITY: the export pass 4 twin — unsharp convolution above 0, mist + // blur below. Applied to the base paint so it sits under the glow and + // the panel actually shows what the file will get. + try { + const clarityV = claritySync.getDirty(); + if (typeof clarityV === 'number' && Number.isFinite(clarityV) && clarityV !== 0) { + const clarityFilter = + clarityV > 0 + ? (() => { + const a = (clarityV / 10) * 0.8; + return Skia.ImageFilter.MakeMatrixConvolution( + 3, + 3, + [0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0], + 1, + 0, + 0, + 0, + T_CLAMP, + true, + null + ); + })() + : Skia.ImageFilter.MakeBlur( + Math.abs(clarityV / 10) * 4, + Math.abs(clarityV / 10) * 4, + T_CLAMP, + null + ); + if (clarityFilter != null) paint.setImageFilter(clarityFilter); + } + } catch (e) { + console.warn('Clarity filter build failed: ' + e); + } const tone = toneSync.getDirty(); const cinema = cinemaSync.getDirty(); const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 || tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 || - tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0); + tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 || + tone[10] !== 0 || tone[11] !== 0); const hasCinema = cinemaEffect != null && cinema[0] > 0; // Draws the full frame with the tone/cinema shader when active (drawRect, // not drawImage: paint shaders only apply to geometry fills — drawImage @@ -985,45 +1209,67 @@ const Viewfinder = forwardRef(function Viewfi } else { canvas.drawImage(frameTexture, 0, 0, paint); } - // HDF: mirror of export pass 5b — extract highlights with a hard - // contrast curve, blur that selection, Screen it back on top. Screen - // against near-black is a no-op, so shadows stay put. Same source - // (shader or image) the base draw used, so the glow sits on processed - // pixels. Radius is a fraction of width so preview/export bloom alike. + // HDF: mirror of export pass 5b — Bright Pass Filter on the LUMINANCE, + // blurred, Screened back on top. Below the knee the pass returns exact + // 0.0 and Screen against black is a no-op, so the shadows stay put + // while only the lit areas spread their light. Same source (shader or + // image) the base draw used, so the glow sits on processed pixels. + // Radius is a fraction of width so preview/export bloom alike. const hdfV = hdfSync.getDirty(); if (hdfV > 0) { const strength = hdfV / 10; const width = frameTexture.width(); const sigma = width * (0.004 + 0.015 * strength); - const hiMatrix = [ - 2.5, 0, 0, 0, -1.5, - 0, 2.5, 0, 0, -1.5, - 0, 0, 2.5, 0, -1.5, - 0, 0, 0, 1, 0, - ]; - const hiFilter = Skia.ColorFilter.MakeMatrix(hiMatrix); - // 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(hiFilter, baseColorFilter) - : hiFilter, - null - ); + const srcShader = + fullShader != null + ? fullShader + : frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); + const glowShader = + glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null; const glowPaint = Skia.Paint(); glowPaint.setBlendMode(BlendMode.Screen); glowPaint.setAlphaf(0.15 + 0.35 * strength); - glowPaint.setImageFilter( - Skia.ImageFilter.MakeCompose( - Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null), - selection - ) - ); - if (fullShader != null) { - glowPaint.setShader(fullShader); + if (glowShader != null) { + glowPaint.setShader(glowShader); + // 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); + glowPaint.setImageFilter( + baseColorFilter != null + ? Skia.ImageFilter.MakeCompose(glowBlur, Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) + : glowBlur + ); canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint); } else { - canvas.drawImage(frameTexture, 0, 0, glowPaint); + // GLOW_SKSL unavailable: old per-channel curve (composed on TOP of + // the recipe matrix, the order that keeps a monochrome look white). + const hiMatrix = [ + 2.5, 0, 0, 0, -1.5, + 0, 2.5, 0, 0, -1.5, + 0, 0, 2.5, 0, -1.5, + 0, 0, 0, 1, 0, + ]; + // 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 + ); + glowPaint.setImageFilter( + Skia.ImageFilter.MakeCompose( + Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null), + selection + ) + ); + if (fullShader != null) { + glowPaint.setShader(fullShader); + canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint); + } else { + canvas.drawImage(frameTexture, 0, 0, glowPaint); + } } } }; @@ -1144,7 +1390,7 @@ const Viewfinder = forwardRef(function Viewfi }); frame.dispose(); }, - [colorMatrixSync, toneSync, cinemaSync, hdfSync, toneFailLogged, feedRectSync, frameBeatSync] + [colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync] ); const grainOpacity = adjustments.grain / 20; @@ -1456,7 +1702,7 @@ const Viewfinder = forwardRef(function Viewfi stallTicks = 0; } // Backgrounding pauses the session on purpose — never watchdog that. - if (!startedOnceRef.current || !appActiveRef.current) return; + if (!startedOnceRef.current || !appActiveRef.current || ultraWideOnRef.current) return; // Two stale ticks give an in-flight (flip/resume/remount) restart time // to land. if (stallTicks < 2) return; @@ -2409,7 +2655,7 @@ const Viewfinder = forwardRef(function Viewfi ref={skiaCameraRef} style={StyleSheet.absoluteFill} device={device} - isActive={appActive} + isActive={appActive && !ultraWideOn} outputs={cameraOutputs} pixelFormat="yuv" targetResolution={frameTarget} @@ -2418,6 +2664,15 @@ const Viewfinder = forwardRef(function Viewfi onFrame={handleFrame} onError={onSessionError} /> + {/* 0.5x: a native Camera2 session on the hidden ultra-wide lens, drawn + over the (stopped) SkiaCamera feed. The main session has to stay + mounted but inactive — the two lenses are mutually exclusive, so + turning one on means turning the other off, never both at once. */} + {/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts the open panel parameter (or EV while AE is locked). Sits below the quick-EV strip, which keeps its own narrower direct-drag handle. */} @@ -2484,18 +2739,20 @@ const Viewfinder = forwardRef(function Viewfi )} {renderCameraOverlays()} {/* Native zoom presets — snap to a controller zoom ratio. 1x is the - only optical stop on the main back lens; 2x-10x are digital crop - (CameraX exposes no tele/ultra-wide logical device here). Row sits + main lens; 0.5x appears only on a device whose CameraX zoom range + reaches below 1 (ultra-wide), and 2x-10x are digital crop. Row sits under the TopBar, clear of the AE-lock chip (top-right) and the EV - strip (right edge). */} - {controller && ( + strip (right edge). uwCover keeps the row mounted across the + 0.5x handoff: the 1x session has not restarted yet, so + `controller` is still undefined and the row used to blink. */} + {(controller || ultraWideOn || uwCover) && ( - {[1, 2, 3, 5, 10].map((z) => ( + {zoomPresets.map((z) => ( applyZoom(z)} @@ -2534,10 +2791,98 @@ const Viewfinder = forwardRef(function Viewfi hlTr: toneParams[7], hlTg: toneParams[8], hlTb: toneParams[9], + cc: toneParams[10], + ccb: toneParams[11], }; const cinemaOn = cinemaEffect != null && !!cinemaParams; const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); + // ---- CLARITY + LIGHT GLOW on the LIBRARY preview ----------------------- + // The camera worklet and exportEngine both have these two passes; the three + // library branches below draw the photo declaratively and had neither, so a + // photo opened from the library ignored both knobs. Same maths as the camera: + // `sx`/`sy` turn one ORIGINAL image pixel into canvas units (the library + // preview is laid out in dp, the camera surface is in sensor px), keeping the + // unsharp radius and the bloom sigma identical to the file. + const libClarity = adjustments.clarity ?? 0; + const libGlow = (adjustments.hdf ?? 0) / 10; + const libShaderPath = useShaderPass || (libClarity > 0 && clarityEffect != null); + const libPhoto = ( + r: { x: number; y: number; w: number; h: number }, + fit: 'cover' | 'fill' | 'contain' + ) => { + const iw = skiaImage ? skiaImage.width() : r.w; + const ih = skiaImage ? skiaImage.height() : r.h; + // cover/contain scale uniformly (max/min), fill stretches each axis. + const sw = r.w / iw; + const sh = r.h / ih; + const uniform = fit === 'cover' ? Math.max(sw, sh) : fit === 'contain' ? Math.min(sw, sh) : null; + const sx = uniform ?? sw; + const sy = uniform ?? sh; + const rectNode = rect(r.x, r.y, r.w, r.h); + let graded: React.ReactNode = ; + if (toneEffect != null && toneOn) { + graded = {graded}; + } + if (cinemaOn) { + graded = {graded}; + } + // The Rect must cover exactly the photo area, never the full canvas: a + // fullscreen ImageShader with fit="contain" clamps the photo edges across + // the black letterbox (reads as "the background is a zoomed copy"). + let photo: React.ReactNode = libShaderPath ? ( + + {libClarity > 0 && clarityEffect != null ? ( + + {graded} + + ) : ( + graded + )} + + ) : ( + + ); + // Mist (negative clarity) = export pass 4's blur; sigma in original px. + if (libClarity < 0) { + photo = ( + + + {photo} + + ); + } + // Export clips the bloom and the mist to the canvas, which IS the photo. + // The preview must clip too: the HDF glow's blur (and negative clarity's) + // otherwise bleeds past the photo onto the black letterbox and over the + // frame's mat. Clip to the drawn image, not to the rect: on "contain" the + // rect is wider than the picture. + const cw = Math.min(r.w, iw * sx); + const ch = Math.min(r.h, ih * sy); + const photoClip = rect(r.x + (r.w - cw) / 2, r.y + (r.h - ch) / 2, cw, ch); + return ( + + {photo} + {/* HDF bloom, the export pass 5b twin: bright pass on the luminance, + blur, Screen back at 0.15..0.5 x strength. Radius is a fraction of + the drawn width, so any image size blooms like the camera does. */} + {libGlow > 0 && glowEffect != null ? ( + + + {graded} + + ) : null} + + ); + }; + // RETRO INSTANT (library): the whole picture is contain-fitted into the // card's photo window — nothing is masked or cropped, and the card is // centered on the black background like a physical print. The window keeps @@ -2556,33 +2901,7 @@ const Viewfinder = forwardRef(function Viewfi - {useShaderPass ? ( - - {cinemaOn ? ( - - {toneEffect && toneOn ? ( - - - - ) : ( - - )} - - ) : toneEffect && toneOn ? ( - - - - ) : null} - - ) : ( - - )} + {libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, polaroidFit)} {grainOpacity > 0 && noiseEffect && ( @@ -2640,33 +2959,7 @@ const Viewfinder = forwardRef(function Viewfi - {useShaderPass ? ( - - {cinemaOn ? ( - - {toneEffect && toneOn ? ( - - - - ) : ( - - )} - - ) : toneEffect && toneOn ? ( - - - - ) : null} - - ) : ( - - )} + {libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, 'cover')} {grainOpacity > 0 && noiseEffect && ( @@ -2709,66 +3002,10 @@ const Viewfinder = forwardRef(function Viewfi - {useShaderPass ? ( - // DR/Highlight/Shadow tone + Cinema seasonal grade, both live - // over the photo. The Rect must cover exactly the contain-fitted - // photo area (not the full canvas): a fullscreen ImageShader - // with fit="contain" clamps/smears the photo edges across the - // black letterbox (user-visible as "the background is a zoomed - // copy of the photo"). Cinema samples the tone output when both - // are on (chain: cinema -> tone -> image), matrix applies after. - - {cinemaOn ? ( - - {toneEffect && toneOn ? ( - - - - ) : ( - - )} - - ) : toneEffect && toneOn ? ( - - - - ) : null} - - ) : dispRect ? ( - + {dispRect ? ( + // Tone/Cinema chain, CLARITY and HDF bloom all live in + // libPhoto — see its comment for the rect caveat. + libPhoto({ x: dispRect.dx, y: dispRect.dy, w: dispRect.dw, h: dispRect.dh }, dispFit) ) : ( )} diff --git a/src/components/ZoomableImage.tsx b/src/components/ZoomableImage.tsx index 0b613c8..7811238 100644 --- a/src/components/ZoomableImage.tsx +++ b/src/components/ZoomableImage.tsx @@ -6,8 +6,8 @@ import { Animated, PanResponder } from 'react-native'; // While zoomed in, responder termination is refused so the parent ScrollView // cannot steal the gesture — the photo pans instead of paging; swiping to the // next photo resumes after zooming back out (double tap or pinch below 1.15). -// Two-finger pinch only zooms once the photo is already zoomed in: at 100% the -// pinch stays inert, so the paging strip still behaves like a plain gallery. +// Pinch zooms from any scale: a two-finger gesture never pages the strip, so +// pinching out of 100% cannot be mistaken for a swipe. // // ponytail: pinch zooms around the image center, not the finger midpoint; // focal zoom needs per-touch location math against the image's screen rect. @@ -116,13 +116,10 @@ export default function ZoomableImage({ uri, width }: ZoomableImageProps) { const b = touches[1]; const dist = Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY); if (!st.pinchStart) st.pinchStart = { scale: st.scale, dist: dist || 1 }; - const next = - st.pinchStart.scale > 1.001 - ? Math.max( - 1, - Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist)) - ) - : 1; + const next = Math.max( + 1, + Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist)) + ); st.scale = next; if (next <= 1.001) { st.tx = 0; diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts deleted file mode 100644 index 570b1b5..0000000 --- a/src/dev/nativeBenchProbe.ts +++ /dev/null @@ -1,129 +0,0 @@ -import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export'; -import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils'; -import { getCinemaUniforms } from '../utils/cinemaShader'; -import { BaseFilter, ColorAdjustments } from '../types'; - -const RUNS = 2; -const IDENTITY = (() => { - const m = new Array(20).fill(0); - m[0] = m[6] = m[12] = m[18] = 1; - return m; -})(); - -const NEUTRAL_ADJ: ColorAdjustments = { - exposure: 0, - contrast: 0, - saturation: 0, - temperature: 5500, - tint: 0, - highlight: 0, - shadow: 0, - denoise: 0, - clarity: 0, - grain: 0, - colorChrome: 'none', - colorChromeBlue: 'none', - dynamicRange: 100, - sharpening: 0, - exposureCompensation: 0, -}; - -/** - * P0-P3 probe harness (temporary — removed at P7). Reads the native - * RecipesCamExport "centerPixel"/"finalPixel" log lines (probe gates compare - * those against the node reference sim). - */ -export async function nativeBenchProbe(): Promise { - const m = RecipescamExport as any; - try { - const pong = await m.ping(); - const run = async (tag: string, src: string, o: PhotoAdjust & { matrix?: number[] } = {}) => { - const { matrix, ...adj } = o; - const dst = src.replace(/\.png$/, `-${tag}-${Date.now()}.jpg`); - const t = await m.processPhotoAsync(src, dst, matrix ?? IDENTITY, null, adj); - console.error(`[BENCH] ${tag} totalMs=${t.totalMs} decodeMs=${t.decodeMs} toneMs=${t.toneMs ?? 0} cinemaMs=${t.cinemaMs ?? 0} colorMs=${t.colorMs} clarityMs=${t.clarityMs ?? 0} grainMs=${t.grainMs ?? 0} frameMs=${t.frameMs ?? 0} encodeWriteMs=${t.encodeWriteMs}`); - }; - const src: string = await m.materializeBenchAsset('wallframe'); - - // P1 parity: classic-neg matrix on black -> expect center ~(13,5,0). - const matrix = applyExposureGain(getSkiaColorMatrix('classic-neg' as BaseFilter, NEUTRAL_ADJ), 0); - const black = await m.materializeSolidAsset(0, 0, 0); - const blackOut = black.replace(/\.png$/, `-neg-${Date.now()}.jpg`); - await m.processColorAsync(black, blackOut, matrix, null, 95); - console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(',')); - - // P2 tone parity on solid ramps (expected values computed from TONE_SKSL): - // white(255) hl=-1 -> 199; gray64 sh=+1 -> 113; white dr=1 -> 209; - // gray26 sh=+1 -> 105; gray26 hl=+1 sh=-1 -> 0. - const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [ - { rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' }, - { rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' }, - { rgb: [255, 255, 255], tone: { dr: 1, hl: 0, sh: 0 }, tag: 'white-dr1' }, - { rgb: [26, 26, 26], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray26-sh+1' }, - { rgb: [26, 26, 26], tone: { dr: 0, hl: 1, sh: -1 }, tag: 'gray26-hl+1-sh-1' }, - ]; - for (const g of toneGates) { - const solid = await m.materializeSolidAsset(g.rgb[0], g.rgb[1], g.rgb[2]); - await run(`tone-${g.tag}`, solid, { toneDr: g.tone.dr, toneHl: g.tone.hl, toneSh: g.tone.sh }); - } - - // P3 cinema parity (CINEMA_SKSL): lit+warm+glow path, shade+cool+fog path, - // and a saturated pixel to exercise the saturation mix. - const summer = getCinemaUniforms('summer')!.flat; - const winter = getCinemaUniforms('winter')!.flat; - const s192 = await m.materializeSolidAsset(192, 192, 192); - await run('cinema-summer-192', s192, { cinema: summer }); - const s32 = await m.materializeSolidAsset(32, 32, 32); - await run('cinema-winter-32', s32, { cinema: winter }); - const sat = await m.materializeSolidAsset(200, 120, 60); - await run('cinema-summer-sat', sat, { cinema: summer }); - - // P3 enhance gates on the gray ramp (gradient makes blur/conv non-trivial). - const ramp = await m.materializeRampAsset(); - await run('denoise10-ramp', ramp, { denoise: 10 }); - await run('clarity10-ramp', ramp, { clarity: 10 }); - await run('mist-ramp', ramp, { clarity: -10 }); - // Step gates: rows 0..31=0, 32..63=255. A ramp is invariant under box - // blur, so the step makes heavy blur non-trivial (expected center row32: - // denoise r=1 -> 170, mist r=4 -> 142 per the padded-boxBlur reference). - const step = await m.materializeStepAsset(); - await run('denoise10-step', step, { denoise: 10 }); - await run('mist-step', step, { clarity: -10 }); - // Grain is structurally-gated only (float32 hash noise vs node doubles - // differ) — the run must complete and the center must stay plausible. - const gray100 = await m.materializeSolidAsset(100, 100, 100); - await run('grain10-100', gray100, { grain: 10 }); - - // P4 frame parity on solid gray100 64x64 — border/bar/card colors and the - // composed output geometry are fully predictable, so probeSamples assert - // exact pixels. Expected (offline verifier re-checks): - // classic-white: (1,1)=white border, (32,32)=100 photo, size 64x64. - // cinematic: (1,1)=black bar, (32,32)=100, size 64x64. - // polaroid: outH=round(64*(SIDE+DECK)+64*WIN_W)=78; (1,1)=card #FAF9F6, - // (32,32)=100 photo, (32,70)=card deck. - await run('frame-classic', gray100, { frameId: 'classic-white', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] }); - await run('frame-cinematic', gray100, { frameId: 'cinematic', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] }); - await run('frame-polaroid', gray100, { frameId: 'polaroid', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }, { x: 32, y: 70 }] }); - // wallframe: export becomes the fixed 3117x4000 rotated artwork; center is - // the cover-cropped photo (100), mat samples compare against wallframe.png - // pixels offline (display->source: (dx,dy)->(dy, 3117-dx)). Art + fonts are - // resolved natively from bundled resources — embedded metro assets expose - // no readable file URI to JS (readAsStringAsync rejects 'wallframe'). - await run('frame-wallframe', gray100, { frameId: 'wallframe', png: true, probeSamples: [{ x: 100, y: 1500 }, { x: 2900, y: 200 }, { x: 1559, y: 1998 }] }); - - // P4 GPS watermark (no frame): 400x400 gray base, stamp bottom-left. - // probeStampDiff logs stampPixelDiff/amber/white after drawing — counts > 0 - // prove the emoji + amber text actually rendered (vs a silent no-op). - const gray400 = await m.materializeSolidSized(400, 400, 100, 100, 100); - await run('geo-stamp', gray400, { geo: { locality: 'HANOI TEST', timestamp: Date.now() }, png: true, probeStampDiff: true }); - - // Full-artwork run: provia + tone + cinema + all enhance passes, 12MP. - const provia = applyExposureGain(getSkiaColorMatrix('provia' as BaseFilter, NEUTRAL_ADJ), 0); - for (let i = 0; i < RUNS; i++) { - await run(`fullRun-${i}`, src, { matrix: provia, toneDr: 0.33, toneHl: 0.3, toneSh: 0.3, cinema: summer, denoise: 10, clarity: 10, grain: 10 }); - } - console.error('[BENCH] done pong=' + pong); - } catch (e) { - console.error('[BENCH] failed', e); - } -} diff --git a/src/dev/parityProbe.ts b/src/dev/parityProbe.ts deleted file mode 100644 index ebf7bae..0000000 --- a/src/dev/parityProbe.ts +++ /dev/null @@ -1,107 +0,0 @@ -// P6 parity probe (temporary — removed at P7). Runs ONE source image through -// the legacy Skia engine (processAndExportPhoto) and the native Kotlin engine -// (processAndExportPhotoNative) with identical recipe/options, measures wall -// time per engine plus a JS-tick counter (legacy renders sync JSI → JS thread -// stalls; native runs off-thread → ticks keep firing), then asks the module to -// downscale-diff the two outputs and write a 3-panel composite -// [legacy|native|diff*8] JPEG into cache + gallery for on-device QA. -import RecipescamExport from '../../modules/recipescam-export'; -import * as FileSystem from 'expo-file-system/legacy'; -import * as MediaLibrary from 'expo-media-library/legacy'; -import { processAndExportPhoto } from '../utils/exportEngine'; -import { processAndExportPhotoNative } from '../utils/nativeExport'; -import { DEFAULT_RECIPES } from '../utils/defaultRecipes'; - -const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); -const toUri = (p: string) => (/^(file|content|ph):\/\//.test(p) ? p : `file://${p}`); - -interface ParityCase { - tag: string; - recipeId: string; -} -const CASES: ParityCase[] = [ - // matrix + tone + CC + enhance (denoise/clarity/grain) — full core pipeline. - { tag: 'classic-neg', recipeId: 'classic-neg-default' }, - // matrix + mist (clarity<0) + classic-white frame + grain. - { tag: 'retro-amber-frame', recipeId: 'retro-amber-default' }, - // cinema seasonal grade + tone + enhance. - { tag: 'cinema-summer', recipeId: 'cinema-summer-default' }, -]; -// Library-style export (evFromCamera falsy → 2^EV matrix gain applies, as it -// does for gallery stills); aspect full so the whole frame is compared. -const OPTIONS = { dpi: 300, sharpen: true }; - -async function newestGalleryPhoto(): Promise { - try { - const perm = await MediaLibrary.requestPermissionsAsync(); - if (!perm.granted) return null; - const page = await MediaLibrary.getAssetsAsync({ - first: 1, - // @ts-ignore legacy SortByValue tuple shape - sortBy: [['creationTime', false]], - }); - const a = page.assets?.[0]; - return a && a.uri ? a.uri : null; - } catch (e) { - console.error('[PARITY] gallery read failed', e); - return null; - } -} - -export async function parityProbe(): Promise { - const m = RecipescamExport as any; - try { - const src = (await newestGalleryPhoto()) ?? toUri(await m.materializeBenchAsset('wallframe')); - console.error(`[PARITY] source=${src}`); - for (const c of CASES) { - const recipe = DEFAULT_RECIPES.find((r) => r.id === c.recipeId); - if (!recipe) { - console.error(`[PARITY] ${c.tag}: recipe ${c.recipeId} missing`); - continue; - } - const frameId = recipe.frameId ?? 'none'; - let ticks = 0; - const iv = setInterval(() => { - ticks += 1; - }, 40); - const t0 = Date.now(); - const legacy = await processAndExportPhoto(src, recipe, frameId, false, null, OPTIONS); - const legacyMs = Date.now() - t0; - const legacyTicks = ticks; - ticks = 0; - const t1 = Date.now(); - const native = await processAndExportPhotoNative(src, recipe, frameId, false, null, OPTIONS); - const nativeMs = Date.now() - t1; - const nativeTicks = ticks; - clearInterval(iv); - console.error( - `[PARITY] ${c.tag}: legacyMs=${legacyMs} ticks=${legacyTicks} uri=${legacy?.uri} | nativeMs=${nativeMs} ticks=${nativeTicks} uri=${native?.uri}` - ); - if (!legacy || !native) { - console.error(`[PARITY] ${c.tag}: missing engine output`); - continue; - } - const out = `${FileSystem.cacheDirectory}parity_${c.tag}_${Date.now()}.jpg`; - const res = await m.parityCompareAsync( - stripScheme(legacy.uri), - stripScheme(native.uri), - stripScheme(out), - 1280 - ); - let saved = 'not-saved'; - try { - const perm = await MediaLibrary.requestPermissionsAsync(); - if (perm.granted) { - const asset = await MediaLibrary.createAssetAsync(out); - saved = `asset=${asset.id}`; - } - } catch (e) { - console.warn('[PARITY] save composite failed', e); - } - console.error(`[PARITY] ${c.tag}: composite=${saved} stats=${JSON.stringify(res)}`); - } - console.error('[PARITY] done'); - } catch (e) { - console.error('[PARITY] failed', e); - } -} diff --git a/src/provariant.ts b/src/provariant.ts new file mode 100644 index 0000000..9966ce4 --- /dev/null +++ b/src/provariant.ts @@ -0,0 +1,3 @@ +// Generated by tools/set-variant.mjs - do not edit by hand. +// false = Lite apk (free, unlockable with a key), true = Pro apk (sold as-is). +export const IS_PRO_BUILD = false; diff --git a/src/types/index.ts b/src/types/index.ts index 74c6c8a..55bda1d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -59,7 +59,7 @@ export const MIN_CROP_FRAC = 0.08; // cool tint in shade, plus a seasonal atmospheric haze/fog veil. export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter'; -export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'none'; +export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none'; export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 987dee6..3195fab 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -1,28 +1,55 @@ import { ColorAdjustments, BaseFilter } from '../types'; // Kelvin to RGB compensation for White Balance. -// In photography, when WB is set HIGH (e.g. 10000K), the camera understands -// the scene light is COLD and compensates with WARM tones (red/orange). -// When WB is set LOW (e.g. 2500K), the camera compensates with COOL tones (blue). +// The WB setting names the light the camera is told to expect, so its gain is +// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a +// daylight scene (5500K) and the camera thinks the light is bluer than it is +// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at +// 5500K, the app's neutral reference. +// +// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin: +// - moving the three channels independently (the old one lifted green 20% at +// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow- +// green rather than orange. Here every gain is divided by its own Rec.709 +// luma, so a neutral pixel keeps its level and only the cast moves. +// - the excursion was arbitrary. These are the physical ratios of the sRGB +// blackbody colours, tamed by a square root: the raw linear-light ratio +// (2.1x on blue at 2500K) over-corrects once it lands on already tone-mapped +// pixels, which is exactly the domain a 4x5 colour matrix works in. export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } { const k = Math.min(10000, Math.max(2500, kelvin)); - let r = 1.0, g = 1.0, b = 1.0; + const light = planckianSRGB(k); + const ref = planckianSRGB(5500); + return normalizeGainLuma([ + Math.sqrt(ref.r / Math.max(light.r, 1e-4)), + Math.sqrt(ref.g / Math.max(light.g, 1e-4)), + Math.sqrt(ref.b / Math.max(light.b, 1e-4)), + ]); +} - if (k < 5500) { - // 2500K → 5500K: Warm light → need blue/cyan compensation - const factor = (5500 - k) / 3000; - r = 1.0 - factor * 0.45; - g = 1.0 - factor * 0.15; - b = 1.0 + factor * 0.50; - } else { - // 5500K → 10000K: Cool light → need warm/amber compensation - const factor = (k - 5500) / 4500; - r = 1.0 + factor * 0.55; - g = 1.0 + factor * 0.20; - b = 1.0 - factor * 0.40; - } +// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit +// of the Planckian locus; exact enough from 1000K up, and exactly neutral where +// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the +// caller. +function planckianSRGB(kelvin: number): { r: number; g: number; b: number } { + const t = kelvin / 100; + const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592); + const g = + t <= 66 + ? 99.4708025861 * Math.log(t) - 161.1195681661 + : 288.1221695283 * Math.pow(t - 60, -0.0755148492); + const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307; + const norm = (v: number) => Math.min(1, Math.max(0, v / 255)); + return { r: norm(r), g: norm(g), b: norm(b) }; +} - return { r, g, b }; +// Divide a set of channel gains by their own luma: the CAST moves, the exposure +// does not. Channels still clip where they must — this only keeps a neutral +// pixel sitting at its neutral level. +function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } { + const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2]; + const s = luma > 0 ? 1 / luma : 1; + return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s }; } // Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and @@ -37,8 +64,8 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments const saturation = adj.saturation; const temperature = adj.temperature; const tint = adj.tint; - const colorChrome = adj.colorChrome || 'none'; - const colorChromeBlue = adj.colorChromeBlue || 'none'; + // colorChrome/colorChromeBlue are deliberately unused here — the chrome + // effects run in the tone shader (per-pixel chroma), not the matrix. // 1. Start with Identity Matrix let matrix = [ @@ -112,19 +139,36 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments 0, 0, 0, 1, 0, ]; } else if (baseFilter === 'leica') { - // Leica digital look (M/Q/SL): neutral-but-warm, deep yet open shadows, - // restrained saturation with deep reds and true greens — never the punchy - // chroma of Velvia or the amber of Classic Neg. A linear matrix cannot do - // the smooth highlight rolloff (that's the tone shader's job), so this only - // carries the mild contrast/warmth/colour balance half: - // - gentle contrast around mid-grey (deep blacks, no lifted blacks) - // - slight warm cast in highlights (R up, B down), near-neutral white - // - reds a touch deeper, blues slightly muted towards natural skies - // - green mostly untouched (Leica greens stay true, not olive) + // LEITZ STREETLIFE CLASSIC — the Leica character rather than a saturation + // push. What makes a Leica file read as one is the separation BETWEEN the + // hues, so the deepening lives in the off-diagonal terms, not in a global + // chroma gain: + // - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and + // every saturated colour gains ~5% through channel separation; Velvia + // pushes 39%, this stays quiet ("dậm đà, không gắt"). + // - a small uniform offset deepens the darks: solid shadows that keep their + // texture, no lifted black and no crushed toe. + // - red up / blue down with green held at 1.00 is the warm European cast: + // it lands on skin as warmth, never as the yellow-green a warm gain on + // all three channels would give. matrix = [ - 1.08, -0.02, -0.01, 0, -0.025, - -0.015, 1.06, -0.01, 0, -0.017, - -0.02, -0.015, 1.04, 0, -0.013, + 1.060, -0.045, -0.010, 0, -0.010, + -0.030, 1.055, -0.025, 0, -0.008, + -0.025, -0.020, 1.040, 0, -0.005, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'leica-vivid') { + // LEITZ STREETLIFE VIVID — the same Leica channel separation pushed into the + // digital era: ~20% of chroma gain (still well under Velvia's 39%, so it + // sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so + // VIVID is a brighter read of one look rather than a second, neutral one. + // No positive lift here: the brightness is the sim's own exposure value + // (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of + // the clip — white lands at most at 1.000 on the red row. + matrix = [ + 1.125, -0.100, -0.010, 0, -0.015, + -0.055, 1.110, -0.055, 0, -0.005, + -0.050, -0.070, 1.100, 0, -0.005, 0, 0, 0, 1, 0, ]; } else if (baseFilter === 'monochrome') { @@ -161,43 +205,31 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments matrix = multiplyMatrices(satMat, matrix); } - // 4. Color Chrome effect (enhances deep colors without shifting white point) - if (baseFilter !== 'monochrome' && colorChrome !== 'none') { - const factor = colorChrome === 'strong' ? 0.25 : 0.12; - // Boost reds and blues slightly, deep shadows get more saturated - const chromeMat = [ - 1 + factor, -factor/2, -factor/2, 0, 0, - -factor/2, 1, -factor/2, 0, 0, - -factor/2, -factor/2, 1 + factor, 0, 0, - 0, 0, 0, 1, 0, - ]; - matrix = multiplyMatrices(chromeMat, matrix); - } - - // 4b. Color Chrome Effect Blue — deep-blue richness boost (blue channel only) - if (baseFilter !== 'monochrome' && colorChromeBlue !== 'none') { - const factor = colorChromeBlue === 'strong' ? 0.35 : 0.15; - const blueMat = [ - 1, 0, 0, 0, 0, - 0, 1, 0, 0, 0, - 0, 0, 1 + factor, 0, 0, - 0, 0, 0, 1, 0, - ]; - matrix = multiplyMatrices(blueMat, matrix); - } + // 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the + // already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a + // matrix gain on R/B is a global saturation push that also moves greys, and a + // blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are + // consumed by getToneUniforms instead. // 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage if (baseFilter !== 'monochrome') { const rgbTemp = kelvinToRGB(temperature); - // Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B) - const tintR = tint > 0 ? (tint / 10) * 0.08 : 0; - const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0; - const tintB = tint > 0 ? (tint / 10) * 0.08 : 0; + // Tint is the green↔magenta axis, and it is symmetric: + pushes magenta + // (red and blue up, green down), - pushes green (green up, red and blue + // down). The old version only ever ADDED a channel (magenta lifted R+B, + // green lifted G), so the knob brightened the frame instead of tinting it. + // Normalising the product keeps that at a tint shift, not an exposure one. + const tintMagenta = (tint / 10) * 0.08; + const wbGain = normalizeGainLuma([ + rgbTemp.r * (1 + tintMagenta), + rgbTemp.g * (1 - tintMagenta), + rgbTemp.b * (1 + tintMagenta), + ]); const wbMat = [ - rgbTemp.r + tintR, 0, 0, 0, 0, - 0, rgbTemp.g + tintG, 0, 0, 0, - 0, 0, rgbTemp.b + tintB, 0, 0, + wbGain.r, 0, 0, 0, 0, + 0, wbGain.g, 0, 0, 0, + 0, 0, wbGain.b, 0, 0, 0, 0, 0, 1, 0, ]; matrix = multiplyMatrices(wbMat, matrix); diff --git a/src/utils/defaultRecipes.ts b/src/utils/defaultRecipes.ts index b1a2ee9..619d76a 100644 --- a/src/utils/defaultRecipes.ts +++ b/src/utils/defaultRecipes.ts @@ -44,11 +44,19 @@ export const FILM_SIMS: FilmSim[] = [ { id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } }, - // Leica digital look (M/Q/SL) is NOT a film stock: neutral-but-warm colour - // with restrained saturation, deep open shadows and gentle highlight rolloff. - // Matrix-only here (like every sim) — the tonal half lives in the leica - // matrix (mild contrast + warm neutrality); users stack DR/tone on top. - { id: 'sim-leica', name: 'LEITZ STREETLIFE', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + // Leica digital look (M/Q/SL) is not a film stock, so it ships as its own + // pair instead of one look that has to compromise: + // CLASSIC — the stock character: deep solid darks, natural shadows, rich but + // quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both + // renderers), never a global saturation boost, and a whisker of grain + // keeps it feeling like film rather than digital sharpness. + // VIVID — the same Leica separation opened up for the digital era: lifted + // exposure, lifted shadows, a little colour. It reads bright and flattering + // straight off the shutter, no editing pass needed. + // Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's + // own adjustments, which every apply path now carries into the panel. + { id: 'sim-leica', name: 'LEITZ STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } }, + { id: 'sim-leica-vivid', name: 'LEITZ STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } }, ]; // Film sim → full Recipe (never persisted; sims are applied on the fly). diff --git a/src/utils/entitlement.ts b/src/utils/entitlement.ts index 8630bd9..5423d1d 100644 --- a/src/utils/entitlement.ts +++ b/src/utils/entitlement.ts @@ -1,5 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { FrameId } from '../types'; +import { IS_PRO_BUILD } from '../provariant'; // Lite (free, forever) vs PRO (one unlock key). The gate is on the FILE, not on // the preview: a Lite user may pick any preset, frame or watermark, compose @@ -18,6 +19,10 @@ export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe']; export const isFreeRecipe = (id: string | null | undefined): boolean => !id || id.startsWith('sim-'); +// Lite may store its own recipes, up to this many: creating and editing them is +// free, deleting one is PRO — so a Lite list only ever grows to this size. +export const LITE_RECIPE_LIMIT = 3; + // Burned into every Lite export, bottom-right of the photo/frame area. Fractions // of the stamp canvas, same space the custom mark uses. export const LITE_MARK = { @@ -43,8 +48,11 @@ export function proLookInUse(look: Look): string | null { return null; } +// The Pro apk is the product: it is unlocked by being the paid build, and its +// SETTINGS sheet never shows a key field. The Lite apk unlocks the same features +// with a keygen key, which is what a buyer of the cheap tier gets instead. export const loadPro = async (): Promise => - (await AsyncStorage.getItem(PRO_KEY)) === '1'; + IS_PRO_BUILD || (await AsyncStorage.getItem(PRO_KEY)) === '1'; // ---- unlock key ------------------------------------------------------------- // Offline product key: 12 payload chars + 4 checksum chars, any punctuation diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index db96046..1b2ed43 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -5,12 +5,13 @@ import * as MediaLibrary from 'expo-media-library/legacy'; import { Asset } from 'expo-asset'; import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; -import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray } from './toneShader'; +import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray, GLOW_SKSL, glowUniformArray } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; import { writeJpegExif } from './exifWrite'; import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta'; +import { ensureMediaLibraryPermission } from './mediaPermission'; import RecipescamExport from '../../modules/recipescam-export'; const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); @@ -341,49 +342,72 @@ export async function processAndExportPhoto( // 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 hard contrast curve, - // Gaussian-blur that selection (radius 15..50px), Screen it back over the - // photo at reduced opacity. Screen against a near-black pixel is a no-op, so - // shadows stay where they were while lit areas spread their light. + // exactly as described: extract the highlights with a Bright Pass Filter on + // the LUMINANCE, Gaussian-blur that selection (radius 15..50px), Screen it + // back over the photo at reduced opacity. Below the knee the pass returns + // exact 0.0 and Screen against black is a no-op, so the shadows stay where + // they were while only the lit areas spread their light. // One 0..10 knob drives both the blur radius and the opacity, and the radius // is a fraction of the width so any source size blooms alike. // ponytail: split into radius/amount knobs only if the 1D ramp reads coarse. if ((adjustments.hdf ?? 0) > 0) { const strength = (adjustments.hdf ?? 0) / 10; const sigma = width * (0.004 + 0.015 * strength); // ~10..50px at 2560 wide - // Highlight extraction: out = 2.5*in - 1.5 -> black below 0.6, speculars - // saturate white. Composed on TOP of the recipe matrix so a monochrome look - // glows white instead of leaking the original colours back through. - const hiMatrix = [ - 2.5, 0, 0, 0, -1.5, - 0, 2.5, 0, 0, -1.5, - 0, 0, 2.5, 0, -1.5, - 0, 0, 0, 1, 0, - ]; - // The order is load-bearing and NOT the paint's: a paint color filter runs - // after the paint's image filter, i.e. the blur would smear the recipe - // matrix into the selection instead of pulling the highlights out first — - // measured as a flat +6/255 haze in the shadows with no glow on the - // speculars. Chain both as image filters instead (inner runs first). - const selection = Skia.ImageFilter.MakeColorFilter( - Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter), - null - ); + // Bright pass by LUMINANCE (see GLOW_SKSL). The old per-channel matrix let + // a saturated blue through untinted and left a coloured smear in the + // darks; one luma knee + one gain kills both. + let glowEffect: ReturnType = null; + try { + glowEffect = 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); + const glowShader = + glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null; const glowPaint = Skia.Paint(); glowPaint.setBlendMode(BlendMode.Screen); glowPaint.setAlphaf(0.15 + 0.35 * strength); - glowPaint.setImageFilter( - Skia.ImageFilter.MakeCompose( - Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null), - selection - ) - ); - // Same source the base draw used, so the glow sits on the processed pixels. - if (paintShader) { - glowPaint.setShader(paintShader); + if (glowShader != null) { + glowPaint.setShader(glowShader); + // Recipe matrix stays where it always was — inside the filter chain, + // under the blur (inner runs first) — so the bloom lands on GRADED + // 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) + ) + ); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint); } else { - canvas.drawImage(skImage, 0, 0, glowPaint); + // GLOW_SKSL unavailable: keep the old per-channel curve (composed on TOP + // of the recipe matrix, the order that keeps a monochrome look white). + // A leaky bloom beats no bloom at all. + const hiMatrix = [ + 2.5, 0, 0, 0, -1.5, + 0, 2.5, 0, 0, -1.5, + 0, 0, 2.5, 0, -1.5, + 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 + ) + ) + ); + if (paintShader) { + glowPaint.setShader(paintShader); + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint); + } else { + canvas.drawImage(skImage, 0, 0, glowPaint); + } } } @@ -747,8 +771,8 @@ export async function processAndExportPhoto( // 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. - const mediaPermission = await MediaLibrary.requestPermissionsAsync(); - if (mediaPermission.granted) { + 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. diff --git a/src/utils/locationUtils.ts b/src/utils/locationUtils.ts index c289794..9f2c60a 100644 --- a/src/utils/locationUtils.ts +++ b/src/utils/locationUtils.ts @@ -11,9 +11,27 @@ export async function requestLocationPermissions(): Promise { } } +// The OS permission screen takes the window focus for a moment and the camera +// session goes down with it, so a capture that asks for location comes back +// "Camera is closed" — one ask per shot turned a burst into failed frames. +// Check the status (no UI, cheap) and raise the dialog at most once per run. +let locationAsked = false; + +async function ensureLocationPermission(): Promise { + try { + if ((await Location.getForegroundPermissionsAsync()).granted) return true; + if (locationAsked) return false; + locationAsked = true; + return (await Location.requestForegroundPermissionsAsync()).status === 'granted'; + } catch (error) { + console.error('Error requesting location permissions:', error); + return false; + } +} + export async function getCurrentGPS(): Promise { try { - const hasPermission = await requestLocationPermissions(); + const hasPermission = await ensureLocationPermission(); if (!hasPermission) return null; const location = await Location.getCurrentPositionAsync({ @@ -67,8 +85,9 @@ export async function reverseGeocode(latitude: number, longitude: number): Promi // Foreground permission is not optional here: expo-location's Android // geocoder throws LocationUnauthorizedException without it. A photo picked // before the camera ever asked for location therefore resolved to no name at - // all and the stamp fell back to "STREET VIEW" — so ask before every call. - await requestLocationPermissions(); + // all and the stamp fell back to "STREET VIEW" — so make sure it is held + // before every call (same once-per-run ask as getCurrentGPS). + await ensureLocationPermission(); try { const geo = await Location.reverseGeocodeAsync({ latitude, longitude }); if (geo && geo.length > 0) return localityName(geo[0]); diff --git a/src/utils/mediaPermission.ts b/src/utils/mediaPermission.ts new file mode 100644 index 0000000..ccd33bf --- /dev/null +++ b/src/utils/mediaPermission.ts @@ -0,0 +1,20 @@ +import * as MediaLibrary from 'expo-media-library/legacy'; + +// Raising the media permission opens the OS permission screen, which takes the +// window focus for a moment — and the camera session goes down with it. Asking +// once per export therefore turned a burst into frames that came back +// "ImageCaptureException: Camera is closed". Check the status (no UI, cheap) +// and raise the dialog at most once per run. +let asked = false; + +export async function ensureMediaLibraryPermission(): Promise { + try { + if ((await MediaLibrary.getPermissionsAsync()).granted) return true; + if (asked) return false; + asked = true; + return (await MediaLibrary.requestPermissionsAsync()).granted; + } catch (e) { + console.warn('Media library permission check failed:', e); + return false; + } +} diff --git a/src/utils/nativeExport.ts b/src/utils/nativeExport.ts index 16b0ff1..4310c14 100644 --- a/src/utils/nativeExport.ts +++ b/src/utils/nativeExport.ts @@ -12,6 +12,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments } from '../type import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { getToneUniforms } from './toneShader'; import { getCinemaUniforms } from './cinemaShader'; +import { ensureMediaLibraryPermission } from './mediaPermission'; import { nextPhotoPath, stampExifOnFile } from './photoMeta'; export interface NativeExportResult { @@ -65,8 +66,11 @@ export async function processAndExportPhotoNative( adjustments.exposureCompensation ?? 0 ); // Base filter is passed so a stock's own tone pass is picked up, but the - // Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg) is - // preview/engine only. Nothing in the app calls nativeExport (dev probe). + // Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg) + // and the Color Chrome/Chrome-Blue depths are preview/engine only, so a + // native-export build silently drops those three. ponytail: port them into + // applyTone() when the native path stops being a dev probe + // (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle). const tone = getToneUniforms(adjustments, recipe.baseFilter); const cinema = getCinemaUniforms(recipe.cinema); // Final screen sharpen (engine #9): the sharpening knob (0..10) overrides @@ -114,8 +118,8 @@ export async function processAndExportPhotoNative( sourceUri ); - const mediaPermission = await MediaLibrary.requestPermissionsAsync(); - if (mediaPermission.granted) { + const mediaGranted = await ensureMediaLibraryPermission(); + if (mediaGranted) { try { const asset = await MediaLibrary.createAssetAsync(exportFile); return { uri: exportFile, savedToLibrary: true, assetId: asset.id }; diff --git a/src/utils/recipeShare.ts b/src/utils/recipeShare.ts new file mode 100644 index 0000000..61c3c31 --- /dev/null +++ b/src/utils/recipeShare.ts @@ -0,0 +1,99 @@ +import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types'; +import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes'; + +// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the +// recipe JSON, so a shared recipe imports back into the app on another phone. +// +// ponytail: obfuscation, not cryptography — the key ships inside the app and +// the keystream is a plain xorshift. It stops hand-editing and casual peeking, +// which is all a shared look needs; swap in expo-crypto/AES at the same two +// functions if the format ever has to resist a determined attacker. +const KEY = 'RecipesCam::recipe-share::v1'; +const ALGORITHM = 'xor16-v1'; + +const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none']; +const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe']; + +const hash = (s: string): number => { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +}; + +// xorshift32: one 16-bit word of keystream per call. +const stream = (seed: number) => { + let s = seed >>> 0 || 0x9e3779b9; + return () => { + s ^= s << 13; + s >>>= 0; + s ^= s >>> 17; + s ^= s << 5; + s >>>= 0; + return s & 0xffff; + }; +}; + +// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names +// with Vietnamese diacritics included) survives the round trip. +const scramble = (text: string, salt: number): string => { + const k = stream(hash(`${KEY}|${salt}`)); + let out = ''; + for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0'); + return out; +}; + +const unscramble = (hex: string, salt: number): string => { + if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.'); + const k = stream(hash(`${KEY}|${salt}`)); + let out = ''; + for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k()); + return out; +}; + +// The shareable half of a recipe: never the local id, and only known fields, so +// a file from a newer version cannot smuggle anything into storage. +export function exportRecipeXml(recipe: Recipe): string { + const shareable = { + name: recipe.name, + baseFilter: recipe.baseFilter, + adjustments: recipe.adjustments, + frameId: recipe.frameId, + useGeotag: recipe.useGeotag, + }; + const salt = Math.floor(Math.random() * 0xffffffff) >>> 0; + return [ + '', + ``, + ` ${scramble(JSON.stringify(shareable), salt)}`, + '', + '', + ].join('\n'); +} + +// Throws with a user-readable reason. Fields are validated and clamped to what +// this build understands: a recipe from a newer app imports as best it can +// instead of poisoning the store. +export function importRecipeXml(xml: string): Omit { + const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1]; + const payload = /([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, ''); + if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.'); + + let raw: any; + try { + raw = JSON.parse(unscramble(payload, parseInt(salt, 16))); + } catch { + raw = null; + } + if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') { + throw new Error('Recipe file is damaged or from another app.'); + } + + const name = raw.name.trim().slice(0, 40) || 'Imported recipe'; + const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none'; + const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial) }; + const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none'; + return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag }; +} diff --git a/src/utils/toneShader.ts b/src/utils/toneShader.ts index ea327bd..4f1eb9d 100644 --- a/src/utils/toneShader.ts +++ b/src/utils/toneShader.ts @@ -48,6 +48,8 @@ uniform float shTb; uniform float hlTr; uniform float hlTg; uniform float hlTb; +uniform float cc; +uniform float ccb; vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); @@ -69,6 +71,31 @@ vec4 main(vec2 xy) { // their own tint, so the two ends of the curve can drift opposite ways // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0); + // Color Chrome / Color Chrome FX Blue: the two stock-dialed colour effects + // DEEPEN what is already chromatic and leave neutrals exactly where they are + // (Fuji: "deeper tone in highly saturated colour"; FX Blue does it for the + // blue/cyan side only). Both therefore need per-pixel chroma — a 4x5 colour + // matrix is one linear map, so any gain it applies also moves greys, and a + // blue-only gain drags the whole white point. + float mxc = max(max(rgb.r, rgb.g), rgb.b); + float mnc = min(min(rgb.r, rgb.g), rgb.b); + // Chroma ratio with a small floor: a near-black pixel with a hair of cast + // has ratio 1.0 but no colour to deepen, and must stay put. + float ccChroma = (mxc - mnc) / max(mxc, 0.10); + // Color Chrome rides the chroma itself: a muted colour barely moves, a vivid + // one gains density. The 0.25 knee keeps skin, haze and pastels untouched. + float ccMask = cc * smoothstep(0.25, 0.85, ccChroma); + // FX Blue: only where blue clearly leads red AND green (so magenta/purple + // stay out), and richest in a bright blue — a dark blue has no tonality left + // to deepen. + float ccbBlue = clamp((rgb.b - rgb.r) * 2.0, 0.0, 1.0) * clamp((rgb.b - 0.5 * (rgb.r + rgb.g) + 0.05) * 3.0, 0.0, 1.0); + float ccbMask = ccb * ccbBlue * smoothstep(0.15, 0.60, ccChroma) * smoothstep(0.20, 0.70, t); + float deep = clamp(ccMask + ccbMask, 0.0, 1.0); + // Density = lightness down with the colour difference riding along, so hue is + // preserved and the colour cannot collapse toward black (same reason the tone + // curve above keeps chroma). A touch of chroma is given up as it deepens. + float l3 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + rgb = clamp(vec3(l3 * (1.0 - 0.28 * deep)) + (rgb - vec3(l3)) * (1.0 - 0.10 * deep), 0.0, 1.0); // Vibrance: push the LESS-saturated pixels harder than the vivid ones. float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); float mx = max(max(rgb.r, rgb.g), rgb.b); @@ -79,6 +106,69 @@ vec4 main(vec2 xy) { } `; +// Bright Pass Filter for the LIGHT GLOW pass (HDF), SkSL over a child image +// shader — the pattern TONE_SKSL above already proved on device. +// +// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark +// *itself*: a saturated blue (B = 1.0) came out of it fully lit, so a dark blue +// shadow bloomed and a dark saturated colour smeared its hue into the darks. +// Photoshop's Bright Pass filters on the LUMINANCE instead: one knee decides how +// much light a pixel carries, and one gain scales all three channels, so below +// the knee the output is exactly 0.0 (Screen against black = no-op, the shadows +// are untouched) and above it every channel keeps its ratio — the hue cannot +// drift, only the brightness blooms. +// +// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 = +// 0), wide enough that a specular ramps in instead of clipping on/off. +export const GLOW_T0 = 0.55; +export const GLOW_T1 = 0.85; + +export const GLOW_SKSL = ` +uniform shader src; +uniform float t0; +uniform float t1; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722)); + return vec4(c.rgb * smoothstep(t0, t1, luma), c.a); +} +`; + +// Flat uniform buffer for `makeShaderWithChildren` — same order as GLOW_SKSL's +// declarations (t0, t1). +export function glowUniformArray(): number[] { + 'worklet'; + return [GLOW_T0, GLOW_T1]; +} + +// Same values for the declarative path, which indexes uniforms by +// NAME (a flat array is only valid for the JS makeShaderWithChildren API). +export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 }; + +// CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4 +// builds with MakeMatrixConvolution, re-expressed as a plain shader because RN +// Skia 2.6 exposes no convolution image filter to the declarative JSX writer. +// `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so +// the preview, the camera worklet and the file all sharpen at the same radius. +export const CLARITY_SKSL = ` +uniform shader src; +uniform float a; +uniform float2 px; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + vec4 s = src.eval(xy + float2(0.0, -px.y)) + + src.eval(xy + float2(0.0, px.y)) + + src.eval(xy + float2(-px.x, 0.0)) + + src.eval(xy + float2( px.x, 0.0)); + return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a); +} +`; + +// Named uniforms for , same names as CLARITY_SKSL declares. +export function clarityUniforms(a: number, pxX: number, pxY: number) { + return { a, px: [pxX, pxY] }; +} + export interface ToneUniforms { // All zero → no tone adjustment needed (caller can skip the shader pass). dr: number; // 0..1 @@ -87,6 +177,8 @@ export interface ToneUniforms { vib: number; // -1..1 (adjustments.vibrance / 10) shT: [number, number, number]; // shadow split-tone RGB bias, -1..1 hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1 + cc: number; // 0..1 Color Chrome depth (0 = 'none') + ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none') } // Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix: @@ -108,13 +200,30 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): const film = (baseFilter && FILM_TONE[baseFilter]) || {}; const shT: [number, number, number] = film.shT ?? [0, 0, 0]; const hlT: [number, number, number] = film.hlT ?? [0, 0, 0]; - return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT }; + // Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a + // monochrome look, so both are forced off there: the effect is colour-only + // (the preview/export matrix skips them for monochrome for the same reason). + const colour = baseFilter !== 'monochrome'; + const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) => + !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45; + const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) => + !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5; + return { + dr, + hl: hl + (film.hl ?? 0), + sh: sh + (film.sh ?? 0), + vib, + shT, + hlT, + cc: chromeDepth(adj.colorChrome), + ccb: blueDepth(adj.colorChromeBlue), + }; } // Flat uniform buffer for `makeShaderWithChildren` / `` — the // order must match TONE_SKSL's declarations. export function toneUniformArray(u: ToneUniforms): number[] { - return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2]]; + return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb]; } export function toneIsActive(u: ToneUniforms): boolean { @@ -128,6 +237,8 @@ export function toneIsActive(u: ToneUniforms): boolean { u.shT[2] !== 0 || u.hlT[0] !== 0 || u.hlT[1] !== 0 || - u.hlT[2] !== 0 + u.hlT[2] !== 0 || + u.cc !== 0 || + u.ccb !== 0 ); } diff --git a/tools/set-variant.mjs b/tools/set-variant.mjs new file mode 100644 index 0000000..fab6043 --- /dev/null +++ b/tools/set-variant.mjs @@ -0,0 +1,18 @@ +// Point the JS bundle at one tier before a build. +// node tools/set-variant.mjs lite -> src/provariant.ts has IS_PRO_BUILD = false +// node tools/set-variant.mjs pro -> src/provariant.ts has IS_PRO_BUILD = true +// The gradle flavor (assembleLiteRelease / assembleProRelease) sets the package +// name and label; this file is what entitlement.ts reads. Run the matching pair, +// or you ship a Pro apk that thinks it is Lite. `npm run apk:lite|apk:pro` does both. +import { writeFileSync } from 'node:fs'; + +const tier = process.argv[2]; +if (tier !== 'lite' && tier !== 'pro') { + throw new Error('usage: node tools/set-variant.mjs lite|pro'); +} + +writeFileSync( + new URL('../src/provariant.ts', import.meta.url), + `// Generated by tools/set-variant.mjs - do not edit by hand.\n// false = Lite apk (free, unlockable with a key), true = Pro apk (sold as-is).\nexport const IS_PRO_BUILD = ${tier === 'pro'};\n` +); +console.log('src/provariant.ts ->', tier);