diff --git a/App.tsx b/App.tsx index 9eda618..d57397e 100644 --- a/App.tsx +++ b/App.tsx @@ -1,21 +1,21 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useCameraPermission, type Camera } from 'react-native-vision-camera'; +import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import Header from './src/components/Header'; +import TopBar from './src/components/TopBar'; import Viewfinder from './src/components/Viewfinder'; -import TabSelector, { TabId } from './src/components/TabSelector'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; +import ToolRail, { TabId } from './src/components/ToolRail'; import PreviewModal from './src/components/PreviewModal'; import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; +import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; @@ -31,24 +31,13 @@ export default function App() { const [selectedRecipe, setSelectedRecipe] = useState(null); // Custom temporary overrides for the sliders - const [adjustments, setAdjustments] = useState({ - exposure: 0, - contrast: 0, - saturation: 0, - temperature: 5500, - tint: 0, - highlight: 0, - shadow: 0, - denoise: 0, - clarity: 0, - grain: 0, - colorChrome: 'none', - }); + const [adjustments, setAdjustments] = useState({ ...DEFAULT_ADJUSTMENTS }); const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); - const [activeTab, setActiveTab] = useState('recipes'); + // Progressive disclosure: a tool chip opens its panel; null = photo hero only. + const [openTab, setOpenTab] = useState(null); const [libraryImageUri, setLibraryImageUri] = useState(null); const [lastPhotoUri, setLastPhotoUri] = useState(null); @@ -58,7 +47,11 @@ export default function App() { // Hardware Permissions (vision-camera) const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); - const cameraRef = useRef(null); + // 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', + }); // vision-camera may return a raw absolute path on Android; surface a file:// URI. const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`); @@ -123,7 +116,8 @@ export default function App() { } else { setMode(session.mode || 'camera'); } - if (session.adjustments) setAdjustments(session.adjustments); + if (session.adjustments) + setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); if (session.gpsInfo) setGpsInfo(session.gpsInfo); @@ -267,7 +261,7 @@ export default function App() { Alert.alert('Permission Needed', 'Grant camera permission to take photos.'); return; } - if (!cameraRef.current) { + if (!photoOutput) { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } @@ -285,17 +279,23 @@ export default function App() { } } - const photo = await cameraRef.current.takePhoto({}); - const sourceUri = toUri(photo.path); + const tShot = Date.now(); + const { filePath } = await photoOutput.capturePhotoToFile({}, {}); + console.error(`[capture] shot-took ${Date.now() - tShot}ms`); + const sourceUri = toUri(filePath); + const tExp = Date.now(); const result = await processAndExportPhoto( sourceUri, { ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag }, selectedFrame, useGeotag, captureGps, - { dpi: 300, sharpen: true } // 300dpi header + full-res screen sharpen + // 300dpi header + full-res screen sharpen. evFromCamera: the AE bias + // already applied exposure compensation at capture โ€” don't add 2^EV. + { dpi: 300, sharpen: true, evFromCamera: true } ); + console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`); if (result) { setLastPhotoUri(result.uri); @@ -358,14 +358,11 @@ export default function App() { }; return ( - - - - {/* Top Header */} -
+ + - {/* Main Viewfinder Frame */} - + {/* Fullscreen hero: photo or live preview fills the whole screen */} + - {/* Selector Tabs */} - + {/* Top overlay: mode label + recipe name (left), Save current (right) */} + - {/* Adjustments Panel */} - - tool rail -> shutter */} + + {openTab !== null && ( + + + + )} + + setOpenTab(openTab === tab ? null : tab)} /> + + setPreviewVisible(true)} + isLibraryMode={mode === 'library'} + onSwitchToCamera={() => handleSetMode('camera')} /> - {/* Bottom Camera Buttons */} - setPreviewVisible(true)} - isLibraryMode={mode === 'library'} - onSwitchToCamera={() => handleSetMode('camera')} - /> - {/* Preview Modal */} )} - + ); } diff --git a/android/app/build.gradle b/android/app/build.gradle index 0e9eadf..cfe240a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,6 +96,10 @@ android { versionName "1.0.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + + ndk { + abiFilters "armeabi-v7a", "arm64-v8a" + } } signingConfigs { debug { diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 551eb41..a8ab82e 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -11,4 +11,9 @@ -keep class com.swmansion.reanimated.** { *; } -keep class com.facebook.react.turbomodule.** { *; } +# androidx.camera +-keep class androidx.camera.** { *; } +-keep interface androidx.camera.** { *; } +-dontwarn androidx.camera.** + # Add any project specific keep options here: diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7da6be8..4d4274b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + @@ -8,7 +9,7 @@ - + diff --git a/android/build.gradle b/android/build.gradle index 0554dd1..215d906 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -22,3 +22,8 @@ allprojects { apply plugin: "expo-root-project" apply plugin: "com.facebook.react.rootproject" + +// v5: vision-camera module builds own C++ (NativeBufferHelper.cpp) with +// `minSdkVersion getExtOrIntegerDefault("minSdkVersion")` = rootProject.ext.minSdkVersion. +// C++ #if __ANDROID_API__ >= 26 else throws "HardwareBuffers require minSdk 26 or higher!" on every frame. +rootProject.ext.minSdkVersion = 26 diff --git a/android/gradle.properties b/android/gradle.properties index a16c21f..b1849e5 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,7 +10,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit @@ -21,6 +21,7 @@ org.gradle.parallel=true # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +android.enableJetifier=true # Enable AAPT2 PNG crunching android.enablePngCrunchInReleaseBuilds=true diff --git a/android/settings.gradle b/android/settings.gradle index ae4504a..1295eb3 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -37,3 +37,11 @@ expoAutolinking.useExpoVersionCatalog() include ':app' includeBuild(expoAutolinking.reactNativeGradlePlugin) + +// v5: vision-camera(-worklets) native needs minSdk 26 (AHardwareBuffer). Parallel project +// configuration races root build.gradle's assignment, so pin the ext here before any project config. +gradle.beforeProject { project -> + if (project == project.rootProject) { + project.ext.minSdkVersion = 26 + } +} diff --git a/app.json b/app.json index 4819c34..be7a226 100644 --- a/app.json +++ b/app.json @@ -7,7 +7,10 @@ "icon": "./assets/icon.png", "userInterfaceStyle": "light", "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "NSCameraUsageDescription": "Allow CamRecipe Pro to access your camera." + } }, "android": { "adaptiveIcon": { @@ -34,13 +37,6 @@ }, "plugins": [ "expo-asset", - [ - "react-native-vision-camera", - { - "enableFrameProcessors": true, - "cameraPermissionText": "Allow CamRecipe Pro to access your camera." - } - ], [ "expo-location", { diff --git a/index.ts b/index.ts index 1d6e981..776fea8 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,21 @@ import { registerRootComponent } from 'expo'; +import React from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import App from './App'; -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// SafeAreaProvider: root overlays (TopBar / CameraControls) read insets so the +// fullscreen viewfinder can run edge-to-edge under the system bars. +// (index.ts is plain TS โ€” no JSX here.) +function Root() { + return React.createElement( + SafeAreaProvider, + null, + React.createElement(App) + ); +} + +// registerRootComponent calls AppRegistry.registerComponent('main', () => Root); // It also ensures that whether you load the app in Expo Go or in a native build, // the environment is set up appropriately -registerRootComponent(App); +registerRootComponent(Root); diff --git a/package-lock.json b/package-lock.json index 00be1eb..eea29a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "babel-preset-expo": "~57.0.0", "expo": "~57.0.7", "expo-asset": "~57.0.5", - "expo-camera": "~57.0.2", "expo-dev-client": "~57.0.6", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", @@ -29,11 +28,14 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", + "react-native-nitro-image": "^0.15.2", + "react-native-nitro-modules": "^0.37.1", "react-native-reanimated": "^4.5.0", "react-native-safe-area-context": "^5.8.0", - "react-native-vision-camera": "^4.7.3", + "react-native-vision-camera": "^5.2.3", + "react-native-vision-camera-skia": "^5.2.3", + "react-native-vision-camera-worklets": "^5.2.3", "react-native-web": "^0.21.2", - "react-native-worklets-core": "^1.6.3", "tailwindcss": "^3.4.19" }, "devDependencies": { @@ -2038,12 +2040,6 @@ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, - "node_modules/@types/emscripten": { - "version": "1.41.5", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", - "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", - "license": "MIT" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2450,15 +2446,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/barcode-detector": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.1.tgz", - "integrity": "sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==", - "license": "MIT", - "dependencies": { - "zxing-wasm": "3.1.1" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3391,26 +3378,6 @@ "react-native": "*" } }, - "node_modules/expo-camera": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.3.tgz", - "integrity": "sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==", - "license": "MIT", - "dependencies": { - "barcode-detector": "^3.0.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*", - "react-native-web": "*" - }, - "peerDependenciesMeta": { - "react-native-web": { - "optional": true - } - } - }, "node_modules/expo-constants": { "version": "57.0.6", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz", @@ -6603,6 +6570,35 @@ "react-native": "*" } }, + "node_modules/react-native-nitro-image": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/react-native-nitro-image/-/react-native-nitro-image-0.15.2.tgz", + "integrity": "sha512-WiaGnORmfNIIqtRJW76TWYNet1YZx1MWDlSOxd0jfcIBN4KjkVVMDVpvO01eCfpxKVG0ez9nA2iLQkPVlGFj+A==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + }, + "peerDependenciesMeta": { + "react-native-nitro-modules": { + "optional": true + } + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.37.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.37.1.tgz", + "integrity": "sha512-KpW6EQVQ/bfegpCGxN9Be+ndvsfj56t4IESEvCASu9gWpJa/NDfHpIeIwCOvZQ3eXmLEj6YD4wCLqcC1FJPr2w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-reanimated": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.0.tgz", @@ -6668,25 +6664,51 @@ } }, "node_modules/react-native-vision-camera": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz", - "integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.2.3.tgz", + "integrity": "sha512-OHjgM8BPzxppTqbF1FEltj+fxvisj4xdghM7ypoT1p6cOe28HByhOJP1c7LkBiUiZuotbsdbQwGdJ8nU7ZvPzA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-image": "*", + "react-native-nitro-modules": "*" + }, + "peerDependenciesMeta": { + "react-native-nitro-modules": { + "optional": true + } + } + }, + "node_modules/react-native-vision-camera-skia": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera-skia/-/react-native-vision-camera-skia-5.2.3.tgz", + "integrity": "sha512-uog1ZvG7vmcWspg4MuxSMKk/qkFC5tRBTa9nygKIg2rM+vrbbnJt/XBYcuJIoCzx+RCiPRkQP/ltB5Le4GfjnQ==", "license": "MIT", "peerDependencies": { "@shopify/react-native-skia": "*", "react": "*", "react-native": "*", "react-native-reanimated": "*", - "react-native-worklets-core": "*" + "react-native-vision-camera": "*", + "react-native-vision-camera-worklets": "*", + "react-native-worklets": "*" + } + }, + "node_modules/react-native-vision-camera-worklets": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera-worklets/-/react-native-vision-camera-worklets-5.2.3.tgz", + "integrity": "sha512-POwjbl1853KFSWYw3/yb3NnRpCWjg7w4dS5LvFVHeMGvPv1Qyu5CCOqYKcB3TUJlg3L4Tn/iBgs0p1NHcCrmxA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*", + "react-native-vision-camera": "*", + "react-native-worklets": "*" }, "peerDependenciesMeta": { - "@shopify/react-native-skia": { - "optional": true - }, - "react-native-reanimated": { - "optional": true - }, - "react-native-worklets-core": { + "react-native-nitro-modules": { "optional": true } } @@ -6750,19 +6772,6 @@ "react-native": "0.83 - 0.86" } }, - "node_modules/react-native-worklets-core": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/react-native-worklets-core/-/react-native-worklets-core-1.6.3.tgz", - "integrity": "sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA==", - "license": "MIT", - "dependencies": { - "string-hash-64": "^1.0.3" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -7294,12 +7303,6 @@ "node": ">= 0.10.0" } }, - "node_modules/string-hash-64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/string-hash-64/-/string-hash-64-1.0.3.tgz", - "integrity": "sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw==", - "license": "MIT" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7406,18 +7409,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -7974,34 +7965,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zxing-wasm": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.1.tgz", - "integrity": "sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==", - "license": "MIT", - "dependencies": { - "@types/emscripten": "^1.41.5", - "type-fest": "^5.8.0" - }, - "peerDependencies": { - "@types/emscripten": ">=1.39.6" - } - }, - "node_modules/zxing-wasm/node_modules/type-fest": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", - "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 51e1b89..8713769 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,14 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", + "react-native-nitro-image": "^0.15.2", + "react-native-nitro-modules": "^0.37.1", "react-native-reanimated": "^4.5.0", "react-native-safe-area-context": "^5.8.0", - "react-native-vision-camera": "^4.7.3", + "react-native-vision-camera": "^5.2.3", + "react-native-vision-camera-skia": "^5.2.3", + "react-native-vision-camera-worklets": "^5.2.3", "react-native-web": "^0.21.2", - "react-native-worklets-core": "^1.6.3", "tailwindcss": "^3.4.19" }, "devDependencies": { diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 9542e9c..13c635a 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -1,21 +1,21 @@ -import React, { useState } from 'react'; -import { - View, - Text, - TouchableOpacity, - ScrollView, - FlatList, - TextInput, - Modal, -} from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; -import { Trash2, Plus, Check } from 'lucide-react-native'; +import { Trash2 } from 'lucide-react-native'; import { Recipe, ColorAdjustments, FrameId } from '../types'; import { FRAMES } from '../utils/frameUtils'; +// Compact bottom sheet above the tool rail: +// bottom row = the tool's chips (presets / parameters), horizontally scrollable +// rows above = one full-width slider per OPEN parameter, labeled with its name. +// Tapping a parameter chip toggles its slider row; chips stay visible. +// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels. + +type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame'; + interface AdjustmentPanelProps { - activeTab: 'recipes' | 'iq' | 'wb' | 'filters' | 'frame'; + activeTab: TabId; recipes: Recipe[]; currentRecipeId: string; adjustments: ColorAdjustments; @@ -29,6 +29,33 @@ interface AdjustmentPanelProps { onDeleteRecipe: (id: string) => void; } +interface ChipDef { + key: string; + label: string; + active: boolean; + onPress: () => void; + onPressDelete?: () => void; // custom presets carry an inline trash button + trash?: boolean; + disabled?: boolean; // informational label, not tappable + amberValue?: boolean; // amber text driven by edited value, not selection +} + +interface ParamDef { + key: string; + label: string; + value: number; + default: number; + min: number; + max: number; + step: number; + display: (v: number) => string; + onChange: (v: number) => void; +} + +const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +const sign = (v: number) => (v > 0 ? `+${v}` : String(v)); +const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`; + export default function AdjustmentPanel({ activeTab, recipes, @@ -40,342 +67,334 @@ export default function AdjustmentPanel({ onUpdateAdjustments, onUpdateFrame, onToggleGeotag, - onSaveRecipe, onDeleteRecipe, }: AdjustmentPanelProps) { - const [modalVisible, setModalVisible] = useState(false); - const [newRecipeName, setNewRecipeName] = useState(''); + // The single parameter whose slider row is open โ€” a new selection replaces + // the previous row (one row at a time, above the chips). + const [openParam, setOpenParam] = useState(null); + useEffect(() => { + setOpenParam(null); + }, [activeTab]); - const triggerHaptic = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const toggleParam = (key: string) => { + haptic(); + setOpenParam((prev) => (prev === key ? null : key)); }; - const renderRecipeItem = React.useCallback( - ({ item }: { item: Recipe }) => ( - - ), - [currentRecipeId, onSelectRecipe, onDeleteRecipe] - ); + const a = adjustments; - const handleSliderValueChange = () => { - // optional: light haptic while sliding, or only on completion. - // Usually Haptic on sliding is too heavy, so we don't trigger it on every frame, - // or we only trigger on sliding complete. - }; - - const handleSavePress = () => { - if (newRecipeName.trim()) { - onSaveRecipe(newRecipeName.trim()); - setNewRecipeName(''); - setModalVisible(false); - triggerHaptic(); - } - }; - - const renderSlider = ( - label: string, - value: number, - min: number, - max: number, - step: number, - onValueChange: (val: number) => void, - displayValueMapper?: (val: number) => string - ) => { - const displayVal = displayValueMapper ? displayValueMapper(value) : value.toString(); + const renderChip = (c: ChipDef) => { + const tone = c.active + ? 'border-amber-500/70 bg-amber-500/15' + : 'border-zinc-700/60 bg-titan/80'; + const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300'; return ( - - - {label.toUpperCase()} - {displayVal} - - + + {c.trash ? ( + + { haptic(); c.onPress(); }} activeOpacity={0.7}> + {c.label} + + { + e.stopPropagation(); + haptic(); + c.onPressDelete && c.onPressDelete(); + }} + className="p-1.5 ml-1" + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + + ) : c.disabled ? ( + + {c.label} + + ) : ( + { haptic(); c.onPress(); }} + className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`} + activeOpacity={0.7} + > + + {c.label} + + + )} ); }; - switch (activeTab) { - case 'recipes': - return ( - - - SELECT OR CREATE PRESETS - setModalVisible(true)} - className="flex-row items-center bg-amber-500 px-3 py-1 rounded space-x-1" - activeOpacity={0.7} - > - - SAVE CURRENT - - + // Continuous parameter chips: label + live value; amber text when edited; + // amber border while its slider row is open. + const paramChips = (params: ParamDef[]) => + params.map((p) => + renderChip({ + key: p.key, + label: `${p.label.toUpperCase()} ${p.display(p.value)}`, + active: openParam === p.key, + amberValue: openParam !== p.key && p.value !== p.default, + onPress: () => toggleParam(p.key), + }) + ); - item.id} - numColumns={2} - className="p-2" - columnWrapperStyle={{ justifyContent: 'space-between' }} - renderItem={renderRecipeItem} - /> + const choiceChips = ( + keyPrefix: string, + options: { v: string; d: string }[], + current: string, + onPick: (v: string) => void + ) => + options.map((o) => + renderChip({ + key: `${keyPrefix}:${o.v}`, + label: o.d, + active: current === o.v, + onPress: () => onPick(o.v), + }) + ); - {/* Save Recipe Modal */} - setModalVisible(false)} + // ---- per-tab parameter definitions --------------------------------------- + const paramDefs: Record = { + recipes: [], + iq: [ + { key: 'exposure', label: 'EXPOSURE', value: a.exposure, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ exposure: v }) }, + { key: 'ev', label: 'EV', value: a.exposureCompensation ?? 0, default: 0, min: -3, max: 3, step: 0.5, display: oneEv, onChange: (v) => onUpdateAdjustments({ exposureCompensation: v }) }, + { key: 'contrast', label: 'CONTRAST', value: a.contrast, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ contrast: v }) }, + { key: 'color', label: 'COLOR', value: a.saturation, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ saturation: v }) }, + { key: 'highlight', label: 'HIGHLIGHT', value: a.highlight ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ highlight: v }) }, + { key: 'shadow', label: 'SHADOW', value: a.shadow ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ shadow: v }) }, + ], + wb: [ + { key: 'temperature', label: 'COLOR TEMP', value: a.temperature, default: 5500, min: 2500, max: 10000, step: 100, display: (v) => `${v}K`, onChange: (v) => onUpdateAdjustments({ temperature: v }) }, + { key: 'tint', label: 'TINT', value: a.tint ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ tint: v }) }, + ], + filters: [ + { key: 'denoise', label: 'NOISE REDUCTION', value: a.denoise, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ denoise: v }) }, + { key: 'clarity', label: 'CLARITY', value: a.clarity, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ clarity: v }) }, + { key: 'sharpening', label: 'SHARPENING', value: a.sharpening ?? 0, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ sharpening: v }) }, + { key: 'grain', label: 'MONOCHROME GRAIN', value: a.grain, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ grain: v }) }, + ], + frame: [], + }; + + const wbPresetOf = (): string => { + const t = a.temperature ?? 5500; + const ti = a.tint ?? 0; + if (t === 5500 && ti === -3) return 'daylight-3r'; + if (t === 6500 && ti === 1) return 'cloudy'; + if (t === 8000 && ti === 2) return 'shade'; + if (t === 3200 && ti === 0) return 'tungsten'; + if (t === 4000 && ti === 3) return 'fluorescent'; + if (t === 5500 && ti === 0) return 'daylight'; + return 'auto'; + }; + const wbApply = (val: string) => { + switch (val) { + case 'daylight': return onUpdateAdjustments({ temperature: 5500, tint: 0 }); + case 'daylight-3r': return onUpdateAdjustments({ temperature: 5500, tint: -3 }); + case 'cloudy': return onUpdateAdjustments({ temperature: 6500, tint: 1 }); + case 'shade': return onUpdateAdjustments({ temperature: 8000, tint: 2 }); + case 'tungsten': return onUpdateAdjustments({ temperature: 3200, tint: 0 }); + case 'fluorescent': return onUpdateAdjustments({ temperature: 4000, tint: 3 }); + default: return onUpdateAdjustments({ temperature: 5500, tint: 0 }); + } + }; + + // ---- per-tab chip rows (bottom, above the tool rail) ----------------------- + const chipsRow = (() => { + switch (activeTab) { + case 'recipes': { + if (recipes.length === 0) { + return ( + + NO PRESETS - TAP SAVE ABOVE + + ); + } + const chips: ChipDef[] = recipes.map((r) => ({ + key: r.id, + label: r.name, + active: r.id === currentRecipeId, + trash: !!r.isCustom, + onPress: () => onSelectRecipe(r), + onPressDelete: () => onDeleteRecipe(r.id), + })); + return ( + - - - - SAVE NEW RECIPE - - - - setModalVisible(false)} - className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" - > - CANCEL - - - SAVE RECIPE - - - - - - - ); + {chips.map(renderChip)} + + ); + } - case 'iq': - return ( - - {renderSlider('Exposure', adjustments.exposure, -10, 10, 1, (val) => - onUpdateAdjustments({ exposure: val }) - )} - {renderSlider('Contrast', adjustments.contrast, -10, 10, 1, (val) => - onUpdateAdjustments({ contrast: val }) - )} - {renderSlider('Saturation', adjustments.saturation, -10, 10, 1, (val) => - onUpdateAdjustments({ saturation: val }) - )} - - ); + case 'iq': + return ( + + {paramChips(paramDefs.iq)} + {renderChip({ key: 'sep-dr', label: 'DYNAMIC RANGE', disabled: true, active: false, onPress: () => {} })} + {choiceChips( + 'dr', + [ + { v: 'auto', d: 'AUTO' }, + { v: '100', d: 'DR100' }, + { v: '200', d: 'DR200' }, + { v: '400', d: 'DR400' }, + ], + String(a.dynamicRange ?? 'auto'), + (val) => + onUpdateAdjustments({ + dynamicRange: (val === 'auto' ? 'auto' : parseInt(val, 10)) as ColorAdjustments['dynamicRange'], + }) + )} + + ); - case 'wb': - return ( - - {renderSlider( - 'Color Temp (Kelvin)', - adjustments.temperature, - 2500, - 10000, - 100, - (val) => onUpdateAdjustments({ temperature: val }), - (val) => `${val}K` - )} - {renderSlider('Tint (Green-Magenta)', adjustments.tint, -10, 10, 1, (val) => - onUpdateAdjustments({ tint: val }) - )} - - - - COLOR CHROME EFFECT - - - {(['none', 'weak', 'strong'] as const).map((chrome) => { - const isActive = adjustments.colorChrome === chrome; - return ( - { - onUpdateAdjustments({ colorChrome: chrome }); - triggerHaptic(); - }} - className={`flex-1 py-1.5 rounded-md items-center ${ - isActive ? 'bg-amber-500' : 'bg-transparent' - }`} - activeOpacity={0.7} - > - - {chrome.toUpperCase()} - - - ); - })} - - - - ); + case 'wb': + return ( + + {choiceChips( + 'wb', + [ + { v: 'auto', d: 'AUTO' }, + { v: 'daylight', d: 'DAYLIGHT' }, + { v: 'daylight-3r', d: 'DAYLIGHT -3R' }, + { v: 'cloudy', d: 'CLOUDY' }, + { v: 'shade', d: 'SHADE' }, + { v: 'tungsten', d: 'TUNGSTEN' }, + { v: 'fluorescent', d: 'FLUOR' }, + ], + wbPresetOf(), + wbApply + )} + {paramChips(paramDefs.wb)} + {renderChip({ key: 'sep-cx', label: 'COLOR CHROME', disabled: true, active: false, onPress: () => {} })} + {choiceChips( + 'cx', + [ + { v: 'none', d: 'OFF' }, + { v: 'weak', d: 'WEAK' }, + { v: 'strong', d: 'STRONG' }, + ], + a.colorChrome || 'none', + (val) => onUpdateAdjustments({ colorChrome: val as ColorAdjustments['colorChrome'] }) + )} + {renderChip({ key: 'sep-cxb', label: 'CHROME BLUE', disabled: true, active: false, onPress: () => {} })} + {choiceChips( + 'cxb', + [ + { v: 'none', d: 'OFF' }, + { v: 'weak', d: 'WEAK' }, + { v: 'strong', d: 'STRONG' }, + ], + a.colorChromeBlue || 'none', + (val) => onUpdateAdjustments({ colorChromeBlue: val as ColorAdjustments['colorChromeBlue'] }) + )} + + ); - case 'filters': - return ( - - - NOTE: DENOISE & CLARITY APPLY AT EXPORT ONLY โ€” LIVE PREVIEW UNCHANGED - - {renderSlider('Denoise (Export)', adjustments.denoise, 0, 10, 1, (val) => - onUpdateAdjustments({ denoise: val }) - )} - {renderSlider('Clarity (Export)', adjustments.clarity, -10, 10, 1, (val) => - onUpdateAdjustments({ clarity: val }) - )} - {renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) => - onUpdateAdjustments({ grain: val }) - )} - - ); + case 'filters': + return ( + + {paramChips(paramDefs.filters)} + {renderChip({ + key: 'note', + label: 'NR/CLARITY/SHARPEN APPLY AT EXPORT', + disabled: true, + active: false, + onPress: () => {}, + })} + + ); - case 'frame': - return ( - - - - SELECT PNG FRAME OVERLAY - - - {FRAMES.map((f: { id: FrameId; name: string }) => { - const isActive = selectedFrame === f.id; - return ( - { - onUpdateFrame(f.id); - triggerHaptic(); - }} - className={`px-3 py-2 m-1 rounded border font-mono ${ - isActive - ? 'bg-amber-500/10 border-amber-500 text-amber-500' - : 'bg-titan border-zinc-800 text-zinc-400' - }`} - activeOpacity={0.7} - > - - {f.name} - - - ); - })} - - + case 'frame': + return ( + + {choiceChips( + 'fr', + FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })), + selectedFrame, + (val) => onUpdateFrame(val as FrameId) + )} + {renderChip({ key: 'sep-gps', label: 'GPS WATERMARK', disabled: true, active: false, onPress: () => {} })} + {renderChip({ + key: 'gps', + label: useGeotag ? 'GPS ON' : 'GPS OFF', + active: useGeotag, + onPress: () => onToggleGeotag(!useGeotag), + })} + + ); - - - - GPS GEOTAG WATERMARK - - - Stamps coordinates & place names onto photo - - - { - onToggleGeotag(!useGeotag); - triggerHaptic(); - }} - className={`w-12 h-6 rounded-full p-0.5 justify-center ${ - useGeotag ? 'bg-amber-500 items-end' : 'bg-zinc-800 items-start' - }`} - activeOpacity={0.8} - > - - {useGeotag && } - - - - - ); + default: + return null; + } + })(); - default: - return null; - } -} + // ---- the single open slider row (ABOVE the chips row) ---------------------- + const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null; -interface RecipeItemProps { - item: Recipe; - isActive: boolean; - onSelect: (recipe: Recipe) => void; - onDelete: (id: string) => void; - triggerHaptic: () => void; -} - -const RecipeItem = React.memo(({ item, isActive, onSelect, onDelete, triggerHaptic }: RecipeItemProps) => { return ( - { - onSelect(item); - triggerHaptic(); - }} - className={`flex-1 m-1.5 p-3 rounded-lg border ${ - isActive - ? 'bg-amber-500/10 border-amber-500' - : 'bg-titan border-zinc-800/80' - }`} - activeOpacity={0.7} - > - - - - {item.name} - - - {item.baseFilter.toUpperCase()} - - - {item.isCustom && ( + + {openParamDef && ( + { - e.stopPropagation(); - onDelete(item.id); - triggerHaptic(); - }} - className="p-1" + onPress={() => toggleParam(openParamDef.key)} + className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2" + activeOpacity={0.7} > - + < - )} - - + + + + {openParamDef.label} + + + {openParamDef.display(openParamDef.value)} + + + + + + )} + {chipsRow} + ); -}); - +} diff --git a/src/components/CameraControls.tsx b/src/components/CameraControls.tsx index 684a0c5..a28d64a 100644 --- a/src/components/CameraControls.tsx +++ b/src/components/CameraControls.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, TouchableOpacity, Image } from 'react-native'; +import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Image as ImageIcon, Eye, Download, Camera } from 'lucide-react-native'; interface CameraControlsProps { @@ -19,15 +20,20 @@ export default function CameraControls({ isLibraryMode, onSwitchToCamera, }: CameraControlsProps) { + const insets = useSafeAreaInsets(); + return ( - - {/* Pick Image Button */} + + {/* Pick Image Button (left) */} - + {/* Dynamic Center Button: Shutter or Save */} @@ -37,13 +43,15 @@ export default function CameraControls({ activeOpacity={0.8} > {isLibraryMode ? ( - - + + + + ) : ( - - - + + + )} @@ -52,17 +60,17 @@ export default function CameraControls({ {/* Right Action Button: Eye (preview) or Camera (switch back) */} {isLibraryMode ? ( - + ) : lastPhotoUri ? ( - + ) : ( - + )} diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index 9e3f1b6..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; -import { Camera, Image as ImageIcon, Sliders } from 'lucide-react-native'; - -interface HeaderProps { - mode: 'camera' | 'library'; - setMode: (mode: 'camera' | 'library') => void; - title: string; -} - -export default function Header({ mode, setMode, title }: HeaderProps) { - return ( - - - - - {title.toUpperCase()} - - - - - setMode('camera')} - className={`flex-row items-center px-3 py-1.5 rounded-md space-x-1.5 ${ - mode === 'camera' ? 'bg-amber-500' : 'bg-transparent' - }`} - activeOpacity={0.7} - > - - - CAMERA - - - - setMode('library')} - className={`flex-row items-center px-3 py-1.5 rounded-md space-x-1.5 ${ - mode === 'library' ? 'bg-amber-500' : 'bg-transparent' - }`} - activeOpacity={0.7} - > - - - LIBRARY - - - - - ); -} diff --git a/src/components/PreviewModal.tsx b/src/components/PreviewModal.tsx index 8ac1bef..bf8b442 100644 --- a/src/components/PreviewModal.tsx +++ b/src/components/PreviewModal.tsx @@ -121,7 +121,7 @@ export default function PreviewModal({ {photoUri && !saved && ( - NOT SAVED TO GALLERY โ€” TAP DONE TO SAVE + NOT SAVED TO GALLERY - TAP DONE TO SAVE )} diff --git a/src/components/TabSelector.tsx b/src/components/TabSelector.tsx deleted file mode 100644 index fe75ec7..0000000 --- a/src/components/TabSelector.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; - -export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame'; - -interface TabSelectorProps { - activeTab: TabId; - setActiveTab: (tab: TabId) => void; -} - -export default function TabSelector({ activeTab, setActiveTab }: TabSelectorProps) { - const tabs: { id: TabId; label: string }[] = [ - { id: 'recipes', label: 'RECIPES' }, - { id: 'iq', label: 'EXPOSURE' }, - { id: 'wb', label: 'WB & CHROME' }, - { id: 'filters', label: 'FILTERS' }, - { id: 'frame', label: 'FRAME & GPS' }, - ]; - - return ( - - - {tabs.map((tab) => { - const isActive = activeTab === tab.id; - return ( - setActiveTab(tab.id)} - className={`px-4 py-2 mx-1.5 rounded-full border ${ - isActive - ? 'bg-amber-500 border-amber-500' - : 'bg-titan border-zinc-800' - }`} - activeOpacity={0.7} - > - - {tab.label} - - - ); - })} - - - ); -} diff --git a/src/components/ToolRail.tsx b/src/components/ToolRail.tsx new file mode 100644 index 0000000..0997ce8 --- /dev/null +++ b/src/components/ToolRail.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; +import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, type LucideIcon } from 'lucide-react-native'; +import * as Haptics from 'expo-haptics'; + +export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame'; + +interface ToolRailProps { + activeTab: TabId | null; + onToggle: (tab: TabId) => void; +} + +const TOOLS: { id: TabId; label: string; icon: LucideIcon }[] = [ + { id: 'recipes', label: 'PRESETS', icon: LayoutGrid }, + { id: 'iq', label: 'LIGHT', icon: Sun }, + { id: 'wb', label: 'WB', icon: Thermometer }, + { id: 'filters', label: 'FX', icon: Sparkles }, + { id: 'frame', label: 'FRAME', icon: Frame }, +]; + +export default function ToolRail({ activeTab, onToggle }: ToolRailProps) { + return ( + + + {TOOLS.map((tool) => { + const isActive = activeTab === tool.id; + const Icon = tool.icon; + return ( + { + onToggle(tool.id); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + className={`mx-1 flex-row items-center rounded-full border px-3 py-2 ${ + isActive + ? 'border-amber-500/70 bg-amber-500/15' + : 'border-zinc-700/60 bg-black/40' + }`} + activeOpacity={0.7} + > + + + {tool.label} + + + ); + })} + + + ); +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx new file mode 100644 index 0000000..5e871bc --- /dev/null +++ b/src/components/TopBar.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, TextInput, Modal } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Plus } from 'lucide-react-native'; +import * as Haptics from 'expo-haptics'; + +interface TopBarProps { + mode: 'camera' | 'library'; + recipeName: string; + onSaveRecipe: (name: string) => void; +} + +export default function TopBar({ mode, recipeName, onSaveRecipe }: TopBarProps) { + const insets = useSafeAreaInsets(); + const [modalVisible, setModalVisible] = useState(false); + const [newRecipeName, setNewRecipeName] = useState(''); + + const handleSavePress = () => { + const name = newRecipeName.trim(); + if (!name) return; + onSaveRecipe(name); + setNewRecipeName(''); + setModalVisible(false); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }; + + return ( + <> + + + + {mode === 'camera' ? 'LIVE' : 'LIBRARY'} + + + {recipeName} + + + + setModalVisible(true)} + className="flex-row items-center rounded-full border border-zinc-700/80 bg-black/45 px-4 py-2.5" + activeOpacity={0.7} + > + + + SAVE + + + + + setModalVisible(false)} + > + + + + SAVE CURRENT AS RECIPE + + + + setModalVisible(false)} + className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" + > + CANCEL + + + SAVE RECIPE + + + + + + + ); +} diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 466c0e4..30b909d 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, Dimensions, TouchableOpacity, Platform } from 'react-native'; -import { Camera, useCameraDevice, useSkiaFrameProcessor, type CameraDevice } from 'react-native-vision-camera'; -import { useSharedValue } from 'react-native-reanimated'; +import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native'; +import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera'; +import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia'; +import { createSynchronizable } from 'react-native-worklets'; import { Canvas, useImage, @@ -14,9 +15,15 @@ import { Rect, ColorMatrix, Image as SkiaImage, + ImageShader, + TileMode, + FilterMode, + MipmapMode, } from '@shopify/react-native-skia'; +import { Lock } from 'lucide-react-native'; import { Recipe, GPSInfo, FrameId } from '../types'; -import { getSkiaColorMatrix } from '../utils/colorUtils'; +import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; +import { TONE_SKSL, getToneUniforms } from '../utils/toneShader'; import { formatCoordinate } from '../utils/locationUtils'; interface ViewfinderProps { @@ -28,13 +35,9 @@ interface ViewfinderProps { libraryImageUri: string | null; cameraPermissionGranted: boolean; onRequestCameraPermission: () => void; - cameraRef?: React.RefObject; + photoOutput: CameraPhotoOutput; } -const { width: screenWidth } = Dimensions.get('window'); -const viewfinderWidth = screenWidth - 32; -const viewfinderHeight = (viewfinderWidth * 4) / 3; - const noiseEffect = Skia.RuntimeEffect.Make(` vec4 main(vec2 pos) { float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453); @@ -42,7 +45,26 @@ const noiseEffect = Skia.RuntimeEffect.Make(` } `); -const containerStyle = { width: viewfinderWidth, height: viewfinderHeight }; +// Tone runtime effect (DR/Highlight/Shadow) for the live camera preview worklet. +// Hoisted at module scope so the worklet runtime can capture it โ€” same pattern +// as the official vision-camera-skia shader examples. Skia caches compiled +// effects by source string, so Make here runs once per app launch. +// Compile failure at module scope throws (JavascriptException โ†’ SIGABRT), so +// swallow it into null โ€” callers already fall back to matrix-only. +const toneEffect = (() => { + try { + return Skia.RuntimeEffect.Make(TONE_SKSL); + } catch (e) { + console.error('[camtone] TONE_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; +const F_LINEAR = FilterMode.Linear; +const M_NONE = MipmapMode.None; export default function Viewfinder({ mode, @@ -53,85 +75,334 @@ export default function Viewfinder({ libraryImageUri, cameraPermissionGranted, onRequestCameraPermission, - cameraRef, + photoOutput, }: ViewfinderProps) { - const hookedDevice = useCameraDevice('back'); - // VisionCamera fetches the device list once when this component mounts. When the - // camera permission is granted *after* mount (first-run flow: GRANT button, then - // OS dialog), that list stays empty until remount/restart, so the camera would - // report "not available". Poll the native list briefly after permission is - // granted and prefer a freshly found device. - const [grantedDevice, setGrantedDevice] = useState(); - useEffect(() => { - if (!cameraPermissionGranted) return; - let stop = false; - const iv = setInterval(() => { - const back = Camera.getAvailableCameraDevices().find((d) => d.position === 'back'); - if (back && !stop) { - setGrantedDevice(back); - clearInterval(iv); - clearTimeout(to); - } - }, 250); - const to = setTimeout(() => clearInterval(iv), 6000); - return () => { - stop = true; - clearInterval(iv); - clearTimeout(to); - }; - }, [cameraPermissionGranted]); - const device = grantedDevice ?? hookedDevice; + // Fullscreen hero: the viewfinder fills the entire screen, every overlay + // rect derives from window size. + const { width: vw, height: vh } = useWindowDimensions(); + + // useCameraDevice resolves the back camera fresh on every render, so once the + // permission lands (first-run flow: GRANT button โ†’ OS dialog) a re-render is + // enough to pick the device up โ€” no manual polling needed. + const device = useCameraDevice('back'); + const skiaImage = useImage(libraryImageUri || ''); const customFont = useFont( require('../../assets/CourierPrime-Regular.ttf'), - Math.round(viewfinderWidth * 0.032) + Math.round(vw * 0.032) ); const adjustments = recipe.adjustments; - const colorMatrix = getSkiaColorMatrix( - recipe.baseFilter, - adjustments.exposure, - adjustments.contrast, - adjustments.saturation, - adjustments.temperature, - adjustments.tint, - adjustments.colorChrome + // Camera captures apply exposure compensation as a hardware AE bias (see the + // setExposureBias effect below) โ€” never via the matrix here. Library + // stills have no hardware bias, so the preview matrix gains 2^EV. + const colorMatrix = + mode === 'library' + ? applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), adjustments.exposureCompensation ?? 0) + : getSkiaColorMatrix(recipe.baseFilter, adjustments); + + // The Skia onFrame worklet runs on a separate runtime โ€” keep the latest color + // matrix in a cross-thread synchronizable value instead of a Reanimated + // SharedValue (which lives on the UI runtime). + const colorMatrixSync = useMemo( + () => createSynchronizable(colorMatrix), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] ); + // Tone uniforms [dr, hl, sh] for the preview worklet; all-zero = no tone pass. + const toneParams = (() => { + const u = getToneUniforms(adjustments); + return [u.dr, u.hl, u.sh]; + })(); + const toneSync = useMemo( + () => createSynchronizable([0, 0, 0]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + useEffect(() => { + colorMatrixSync.setBlocking(colorMatrix); + toneSync.setBlocking(toneParams); + }, [colorMatrix, toneParams, colorMatrixSync, toneSync]); + + // Exposure compensation โ†’ hardware AE bias. Passed imperatively (never as a + // declarative prop): v5 calls setExposureBias while the session is still + // starting up and throws "Camera is not active" โ€” seen on the Xiaomi at boot. + // The controller only exists after onStarted, so it is safe to bias then. + // Deps on the controller identity (not just cameraActive): a session restart + // swaps the controller and silently resets the bias to 0. + const skiaCameraRef = useRef(null); + const [cameraActive, setCameraActive] = useState(false); + // Camera session pauses when the app backgrounds; toggling isActive back on + // resume restarts it. Without this the preview stayed black after unlock. + const [appActive, setAppActive] = useState(AppState.currentState === 'active'); + useEffect(() => { + const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active')); + return () => sub.remove(); + }, []); + // Tap-to-focus / AE-AF-AWB lock point (view coords). null = auto continuous. + const [focusPoint, setFocusPoint] = useState<{ x: number; y: number } | null>(null); + const controller = cameraActive ? skiaCameraRef.current?.controller : undefined; + const evStops = adjustments.exposureCompensation ?? 0; + // Latest slider value, so stale retries from a previous value back off when + // the user has already dragged further (dragging fast used to queue 5 retries + // per intermediate value โ€” each one cancelled by the next, spamming [ev]). + const evStopsRef = useRef(evStops); + evStopsRef.current = evStops; + useEffect(() => { + if (!controller) return; + const d = controller.device; + if (d && d.supportsExposureBias) { + console.error( + `[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}` + ); + } else { + console.error(`[ev] device has no exposure bias support`); + } + }, [controller]); + useEffect(() => { + if (!controller) return; + const d = controller.device; + if (!d || !d.supportsExposureBias) { + console.error(`[ev] device has no exposure bias support`); + return; + } + // CameraX exposes *compensation indexes*, not EV stops: the Android + // implementation runs setExposureCompensationIndex(exposure.toInt()), and + // 1 index = 1/8 EV on this device class (range -24..24 -> ยฑ3 EV). The UI + // slider is real EV, so map EV -> index and clamp to the sensor range. + // Without this, +0.5 EV truncated to index 0 (no change) and +3 EV became + // index 3 = 0.375 EV โ€” the "EV barely changes" report. + const index = Math.round(evStops * 8); + const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index)); + // CameraX cancels an in-flight bias change whenever another compensation + // request lands (session reconfig, AE restart) โ€” retry a few times, but + // only while this value is still the latest one the user asked for. + const timers: ReturnType[] = []; + let attempt = 0; + const trySet = () => { + if (evStopsRef.current !== evStops) return; // stale โ€” a newer drag is pending + attempt += 1; + controller + .setExposureBias(clamped) + .then(() => { + console.error(`[ev] ev=${evStops} -> idx=${clamped} ok, read=${controller.exposureBias}`); + }) + .catch((e) => { + console.error(`[ev] ev=${evStops} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`); + if (attempt < 5 && String(e).includes('Cancel')) { + timers.push(setTimeout(trySet, 150)); + } + }); + }; + trySet(); + return () => timers.forEach((t) => clearTimeout(t)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, evStops]); + + // Tap-to-focus: lock AE/AF/AWB onto the tapped region (SceneAdaptiveness + // 'locked' + autoResetAfter null = stays locked until another tap or unlock). + // CameraX AE otherwise keeps re-metering the scene and cancels out the tone + // 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) => { + if (!cameraActive) return; + const cam = skiaCameraRef.current; + const d = cam?.controller?.device; + if (!d) return; + if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) { + console.error('[focus] metering unsupported on this device'); + return; + } + setFocusPoint({ x, y }); + try { + await cam.focusTo( + { x, y }, + { responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null } + ); + console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`); + } catch (e) { + console.error('[focus] lock failed: ' + String(e).slice(0, 120)); + } + }; + const handleUnlockFocus = async () => { + const cam = skiaCameraRef.current; + if (!cam) return; + try { + await cam.resetFocus(); + setFocusPoint(null); + console.error('[focus] reset to continuous auto'); + } catch (e) { + console.error('[focus] reset failed: ' + String(e).slice(0, 120)); + } + }; + + // Instrumentation for device logs (user captures only logcat E level, so + // console.log/warn never show up โ€” these are error-level on purpose). + const lastPreviewLog = useRef(0); + useEffect(() => { + const now = Date.now(); + if (now - lastPreviewLog.current < 400) return; + lastPreviewLog.current = now; + console.error( + `[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}` + ); + }, [colorMatrix, toneParams, evStops]); + + // Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs + // its frame pipeline whenever onFrame changes, which during slider drags made + // the preview flicker. All live values flow through the synchronizables. + type SkiaCameraOnFrame = NonNullable['onFrame']>; + const toneFailLogged = useMemo(() => createSynchronizable(false), []); + const handleFrame = useCallback( + (frame, render) => { + 'worklet'; + render(({ canvas, frameTexture }) => { + const paint = Skia.Paint(); + paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking())); + const tone = toneSync.getBlocking(); + const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0); + if (hasTone) { + try { + const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); + const shader = toneEffect.makeShaderWithChildren(tone, [imageShader]); + if (shader != null) { + // drawRect, not drawImage: paint shaders only apply to geometry + // fills โ€” drawImage ignores the paint shader, which is why the + // DR/Highlight/Shadow tone never showed on the live preview. + const w = frameTexture.width(); + const h = frameTexture.height(); + paint.setShader(shader); + canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint); + return; + } + } catch (e) { + if (!toneFailLogged.getBlocking()) { + toneFailLogged.setBlocking(true); + console.error('[camtone] shader failed, matrix-only: ' + e); + } + } + } + canvas.drawImage(frameTexture, 0, 0, paint); + }); + frame.dispose(); + }, + [colorMatrixSync, toneSync, toneFailLogged] + ); + const grainOpacity = adjustments.grain / 20; - const colorMatrixSV = useSharedValue(colorMatrix); + // Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap + // point / double-tap again to reset to 100%. Transform is a Skia matrix + // P' = scale*P + (tx,ty); gesture math keeps the focal point stationary. + const [libZoom, setLibZoom] = useState({ s: 1, tx: 0, ty: 0 }); + const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null); + const pinchUsedRef = useRef(false); + const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null); + const clampZoom = (s: number) => Math.max(1, Math.min(6, s)); + // Contain-fit rect of the loaded photo in canvas coordinates (image is + // centered with black letterbox above/below on a portrait screen). + const imageFitRect = useMemo(() => { + const img = skiaImage; + if (!img) return null; + const w = img.width(); + const h = img.height(); + if (!(w > 0 && h > 0)) return null; + const s = Math.min(vw / w, vh / h); + const dw = w * s; + const dh = h * s; + return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh }; + }, [skiaImage, vw, vh]); + const onLibTouchStart = (e: any) => { + const ts = e.nativeEvent.touches; + if (ts.length >= 2) { + const [a, b] = ts; + pinchUsedRef.current = true; + lastTapRef.current = null; + pinchRef.current = { + d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY), + s: libZoom.s, + tx: libZoom.tx, + ty: libZoom.ty, + fx: (a.locationX + b.locationX) / 2, + fy: (a.locationY + b.locationY) / 2, + }; + } + }; + const onLibTouchMove = (e: any) => { + const ts = e.nativeEvent.touches; + let p = pinchRef.current; + if (ts.length >= 2 && !p) { + // Responder grant fires once on the first finger; a second finger that + // lands later only produces move events, so seed the pinch baseline here. + const [a, b] = ts; + pinchUsedRef.current = true; + lastTapRef.current = null; + p = pinchRef.current = { + d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY), + s: libZoom.s, + tx: libZoom.tx, + ty: libZoom.ty, + fx: (a.locationX + b.locationX) / 2, + fy: (a.locationY + b.locationY) / 2, + }; + } + if (ts.length >= 2 && p) { + const [a, b] = ts; + const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); + const s = clampZoom((p.s * d) / Math.max(p.d, 1)); + const k = s / p.s; + setLibZoom({ + s, + tx: p.fx - k * (p.fx - p.tx), + ty: p.fy - k * (p.fy - p.ty), + }); + } + }; + const onLibTouchEnd = (e: any) => { + const wasPinch = pinchUsedRef.current; + pinchRef.current = null; + pinchUsedRef.current = false; + if (wasPinch) return; // a double-tap is always a single finger + const x = e.nativeEvent.locationX; + const y = e.nativeEvent.locationY; + const now = Date.now(); + const prev = lastTapRef.current; + lastTapRef.current = { x, y, t: now }; + if (prev && now - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50) { + lastTapRef.current = null; + setLibZoom((z) => { + if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100% + // Clamp the focal point into the photo's contain rect so a double-tap + // that lands in the black letterbox cannot fling the photo away. + let fx = x; + let fy = y; + const fit = imageFitRect; + if (fit) { + fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw); + fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh); + } + const s = 2.5; + return { s, tx: fx - s * fx, ty: fy - s * fy }; // zoom into the tapped point + }); + } + }; + // Skia affine 3x3, row-major per SkMatrix getMatrix(): + // [s, 0, tx] x' = s*x + tx + // [0, s, ty] y' = s*y + ty + // [0, 0, 1] + // Deliberately NOT built via Skia.Matrix().translate().scale(): on the + // native Android side both are pre-concats (M = TยทM then M = SยทM) so the + // final matrix is SยทT and the translation gets scaled (P' = sยท(P + t)) โ€” + // zooming then flings the photo up/left instead of holding the focal point. + const libZoomMatrix = useMemo( + () => [libZoom.s, 0, libZoom.tx, 0, libZoom.s, libZoom.ty, 0, 0, 1], + [libZoom] + ); - useEffect(() => { - colorMatrixSV.value = getSkiaColorMatrix( - recipe.baseFilter, - adjustments.exposure, - adjustments.contrast, - adjustments.saturation, - adjustments.temperature, - adjustments.tint, - adjustments.colorChrome - ); - }, [ - recipe.baseFilter, - adjustments.exposure, - adjustments.contrast, - adjustments.saturation, - adjustments.temperature, - adjustments.tint, - adjustments.colorChrome, - ]); - - const frameProcessor = useSkiaFrameProcessor((frame) => { - 'worklet'; - const paint = Skia.Paint(); - paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value)); - frame.render(paint); - }, [colorMatrixSV]); - - const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05; - const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06; - const bottomBorder = viewfinderHeight * 0.18; - const barHeight = viewfinderHeight * 0.12; + const borderWidth = Math.min(vw, vh) * 0.05; + const sideBorder = Math.min(vw, vh) * 0.06; + const bottomBorder = vh * 0.18; + const barHeight = vh * 0.12; const renderFrameOverlay = () => { if (selectedFrame === 'none') return null; @@ -139,10 +410,10 @@ export default function Viewfinder({ if (selectedFrame === 'classic-white') { return ( - - - - + + + + ); } @@ -150,15 +421,15 @@ export default function Viewfinder({ if (selectedFrame === 'polaroid') { return ( - - - - + + + + - - + + ); } @@ -186,8 +457,6 @@ export default function Viewfinder({ const lonStr = formatCoordinate(gpsInfo.longitude, 'lon'); const locationName = gpsInfo.locality || 'STREET VIEW'; const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN'); - // Only tag the provenance when we know it (photos from the OS camera/library); - // old saved sessions without a source keep the original retro caption. const sourceTag = gpsInfo.source === 'exif' ? 'EXIF' @@ -198,8 +467,10 @@ export default function Viewfinder({ : null; const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8'; - const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24); - const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16; + // Baseline above the bottom chrome (shutter rail) so the watermark stays + // readable; polaroid frames push it further up onto the white area edge. + const yOffset = vh - (selectedFrame === 'polaroid' ? vh * 0.18 + 24 : 150); + const xOffset = selectedFrame === 'polaroid' ? vw * 0.08 : 16; return ( @@ -215,7 +486,7 @@ export default function Viewfinder({ {grainOpacity > 0 && noiseEffect && ( - + @@ -232,20 +503,13 @@ export default function Viewfinder({ - - - LIVE - ); if (mode === 'camera') { if (!cameraPermissionGranted) { return ( - + Camera permission is required to use the viewfinder. @@ -262,10 +526,7 @@ export default function Viewfinder({ if (!device) { return ( - + Camera not available on this device @@ -274,51 +535,109 @@ export default function Viewfinder({ } return ( - - + setCameraActive(true)} + onFrame={handleFrame} /> + {/* Tap-to-focus layer: fullscreen responder (all visual overlays above + are pointerEvents none). locationX/Y are relative to this view. */} + true} + onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)} + /> + {focusPoint && ( + + + + + )} + {focusPoint && ( + + + AE/AF LOCKED โ€” TAP TO UNLOCK + + )} {renderCameraOverlays()} ); } - // Library mode + // Library mode โ€” fullscreen, full-photo (contain), pinch/double-tap zoom. + const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0; return ( - + {libraryImageUri && skiaImage ? ( - - - - - - {grainOpacity > 0 && noiseEffect && ( - - - - + <> + + + + {toneEffect && toneOn && imageFitRect ? ( + // DR/Highlight/Shadow tone curve. 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"). + + + + + + ) : ( + + )} - )} - {renderFrameOverlay()} - {renderGPSWatermark()} - + {grainOpacity > 0 && noiseEffect && ( + + + + + + )} + {/* Frame + GPS watermark belong to the photo โ†’ zoom with it. Grain + is a fullscreen screen effect โ†’ stays fixed above. */} + + {renderFrameOverlay()} + {renderGPSWatermark()} + + + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onLibTouchStart} + onResponderMove={onLibTouchMove} + onResponderRelease={onLibTouchEnd} + /> + ) : ( diff --git a/src/types/index.ts b/src/types/index.ts index 5150338..2a8629a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,12 +6,16 @@ export interface ColorAdjustments { saturation: number; // -10 to +10 temperature: number; // 2500 to 10000 (Kelvin) tint: number; // -10 to +10 - highlight: number; // -10 to +10 (Highlight contrast adjustment) - shadow: number; // -10 to +10 (Shadow contrast adjustment) + highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens) + shadow: number; // -10 to +10 (+ lifts shadows, - deepens) denoise: number; // 0 to 10 (mapped to blur sigma) clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect + colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue) + dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve) + sharpening: number; // 0 to 10 (output screen sharpen strength) + exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. } export interface Recipe { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 30d2e2e..39d57aa 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -1,3 +1,7 @@ +import { ColorAdjustments } from '../types'; + +export type BaseFilter = 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none'; + // 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). @@ -23,16 +27,21 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } return { r, g, b }; } -// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and adjustments -export function getSkiaColorMatrix( - baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none', - exposure: number, // -10 to +10 - contrast: number, // -10 to +10 - saturation: number, // -10 to +10 - temperature: number, // 2500 to 10000 - tint: number, // -10 to +10 - colorChrome: 'none' | 'weak' | 'strong' -): number[] { +// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and +// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the +// exposure-compensation EV gain are intentionally NOT part of the matrix โ€” +// dynamicRange/highlight/shadow run as a tone shader (see toneShader.ts), +// and EV is applied either as hardware AE bias (camera) or by the caller via +// applyExposureGain (library), never doubled. +export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] { + const exposure = adj.exposure; + const contrast = adj.contrast; + const saturation = adj.saturation; + const temperature = adj.temperature; + const tint = adj.tint; + const colorChrome = adj.colorChrome || 'none'; + const colorChromeBlue = adj.colorChromeBlue || 'none'; + // 1. Start with Identity Matrix let matrix = [ 1, 0, 0, 0, 0, @@ -110,6 +119,18 @@ export function getSkiaColorMatrix( 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); + } + // 5. White Balance (Kelvin Temperature and Tint) โ€” skip for monochrome to prevent color leakage if (baseFilter !== 'monochrome') { const rgbTemp = kelvinToRGB(temperature); @@ -146,6 +167,21 @@ export function getSkiaColorMatrix( return matrix; } +// Apply photographic EV compensation (stops) as a linear gain: 2^EV on every +// RGB channel (offsets scale too, matching true exposure). Only used for +// stills that were NOT exposed with a hardware AE bias (library images). +export function applyExposureGain(matrix: number[], evStops: number): number[] { + if (!evStops) return matrix; + const s = Math.pow(2, evStops); + const gainMat = [ + s, 0, 0, 0, 0, + 0, s, 0, 0, 0, + 0, 0, s, 0, 0, + 0, 0, 0, 1, 0, + ]; + return multiplyMatrices(gainMat, matrix); +} + // Compute a tint color + opacity from the matrix for camera overlay preview // Applies the matrix to white (1,1,1,1) to see what color cast the filter creates export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } { @@ -177,7 +213,7 @@ function multiplyMatrices(a: number[], b: number[]): number[] { a[r * 5 + 2] * b[2 * 5 + c] + a[r * 5 + 3] * b[3 * 5 + c]; } - // Handle the 5th column translation translation + // Handle the 5th column translation result[r * 5 + 4] = a[r * 5 + 0] * b[0 * 5 + 4] + a[r * 5 + 1] * b[1 * 5 + 4] + diff --git a/src/utils/defaultRecipes.ts b/src/utils/defaultRecipes.ts index 2ca0170..2e8c789 100644 --- a/src/utils/defaultRecipes.ts +++ b/src/utils/defaultRecipes.ts @@ -1,4 +1,25 @@ -import { Recipe } from '../types'; +import { Recipe, ColorAdjustments } from '../types'; + +// Fallback values for every adjustable knob. Old recipes/sessions persisted in +// AsyncStorage lack the newer fields โ€” merge this under stored JSON to keep +// every adjustment well-defined (see storageUtils.getAllRecipes / App session). +export const DEFAULT_ADJUSTMENTS: 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: 'auto', + sharpening: 0, + exposureCompensation: 0, +}; export const DEFAULT_RECIPES: Recipe[] = [ { @@ -17,6 +38,10 @@ export const DEFAULT_RECIPES: Recipe[] = [ clarity: 2, grain: 4, colorChrome: 'weak', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, }, frameId: 'none', useGeotag: true, @@ -37,6 +62,10 @@ export const DEFAULT_RECIPES: Recipe[] = [ clarity: 4, grain: 1, colorChrome: 'strong', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, }, frameId: 'none', useGeotag: true, @@ -57,6 +86,10 @@ export const DEFAULT_RECIPES: Recipe[] = [ clarity: 1, grain: 2, colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, }, frameId: 'none', useGeotag: true, @@ -77,6 +110,10 @@ export const DEFAULT_RECIPES: Recipe[] = [ clarity: 3, grain: 6, colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, }, frameId: 'none', useGeotag: true, @@ -97,6 +134,10 @@ export const DEFAULT_RECIPES: Recipe[] = [ clarity: -2, grain: 5, colorChrome: 'strong', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, }, frameId: 'classic-white', useGeotag: true, diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 7284750..6566d04 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -1,12 +1,14 @@ -import { Skia, ImageFormat, BlendMode, TileMode, type SkImage } from '@shopify/react-native-skia'; +import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia'; +import { File } from 'expo-file-system'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library/legacy'; import { Asset } from 'expo-asset'; import { Recipe, GPSInfo, FrameId } from '../types'; -import { getSkiaColorMatrix } from './colorUtils'; +import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; +import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader'; import { drawFrameOnCanvas } from './frameUtils'; import { formatCoordinate } from './locationUtils'; -import { base64ToBytes, bytesToBase64, patchJpegDpi } from './jpegDpi'; +import { patchJpegDpi } from './jpegDpi'; export interface ExportResult { uri: string; @@ -19,12 +21,24 @@ export interface ExportOptions { // Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the // final pixels before encoding. Pixels already export at full resolution. sharpen?: boolean; + // True for photos captured through the live camera: exposure compensation was + // already applied as a hardware AE bias at capture, so no 2^EV matrix gain + // should be added on top. Library stills omit this and get the matrix gain. + evFromCamera?: boolean; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 // convolution whose kernel sums to 1 (brightness preserved), amount ~0.5. // ponytail: fixed subtle preset tuned for full-res desktop/screen output; turn // into an amount/radius UI control if users ask for adjustable strength. +// GPU-backed surface when the Skia GPU context is available (convolutions/sharpen +// run on the Adreno shader cores instead of the CPU โ€” big win on 50MP captures), +// falling back to the CPU raster surface. MakeOffscreen returns null when no GPU +// context exists yet, so the fallback keeps this safe in library-only cold starts. +function createSurface(width: number, height: number): SkSurface | null { + return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height); +} + function screenSharpenImage(image: SkImage, amount = 0.5): SkImage { const w = image.width(); const h = image.height(); @@ -34,7 +48,7 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage { [0, -amount, 0, -amount, center, -amount, 0, -amount, 0], 1, 0, 1, 1, TileMode.Clamp, false, null, null ); - const surf = Skia.Surface.Make(w, h); + const surf = createSurface(w, h); if (!surf) return image; // fall back to unsharpened on allocation failure const paint = Skia.Paint(); paint.setImageFilter(filter); @@ -58,6 +72,10 @@ export async function processAndExportPhoto( gpsInfo: GPSInfo | null, options?: ExportOptions ): Promise { + const t0 = Date.now(); + // Direct console.error templates (user log captures are E-level only). Steps + // use full static prefixes โ€” release minification dropped step literals when + // they were passed through a wrapper, so no indirection here. try { // 1. Read source image file into Skia const skiaData = await Skia.Data.fromURI(sourceUri); @@ -71,12 +89,14 @@ export async function processAndExportPhoto( console.error('Failed to parse image from URI'); return null; } + console.error(`[export] decode ${Date.now() - t0}ms`); const width = skImage.width(); const height = skImage.height(); + console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`); - // 2. Create offscreen canvas - const surface = Skia.Surface.Make(width, height); + // 2. Create offscreen canvas (GPU-backed when available โ€” see createSurface) + const surface = createSurface(width, height); if (!surface) { console.error('Failed to create Skia surface'); return null; @@ -87,19 +107,48 @@ export async function processAndExportPhoto( const adjustments = recipe.adjustments; - // 3. Build Color Matrix Filter - const matrix = getSkiaColorMatrix( - recipe.baseFilter, - adjustments.exposure, - adjustments.contrast, - adjustments.saturation, - adjustments.temperature, - adjustments.tint, - adjustments.colorChrome - ); - const colorFilter = Skia.ColorFilter.MakeMatrix(matrix); + // 3. Build Color Matrix Filter (+2^EV gain for library stills; camera + // captures already got the bias at exposure time โ†’ evFromCamera skips it). + const evStops = adjustments.exposureCompensation ?? 0; + const evMatrix = options?.evFromCamera + ? getSkiaColorMatrix(recipe.baseFilter, adjustments) + : applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops); + const colorFilter = Skia.ColorFilter.MakeMatrix(evMatrix); paint.setColorFilter(colorFilter); + // 3b. Tone shader โ€” DR / Highlight / Shadow curve on top of the matrix. + // Keep the built shader in its own variable: only drawRect (which samples + // through the shader) when a real shader exists. If the RuntimeEffect fails + // to compile / makeShaderWithChildren returns null, paint has no shader and a + // drawRect would fill solid opaque color โ†’ black/blank output. + const tone = getToneUniforms(adjustments); + const hasTone = toneIsActive(tone); + let toneShader: SkShader | null = null; + if (hasTone) { + let toneEffect: ReturnType = null; + try { + toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL); + } catch (e) { + console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e); + } + if (toneEffect) { + const imageShader = skImage.makeShaderOptions( + TileMode.Clamp, + TileMode.Clamp, + FilterMode.Linear, + MipmapMode.None + ); + toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh], [imageShader]); + if (toneShader) { + paint.setShader(toneShader); + } else { + console.error('[export] tone shader build failed - falling back to matrix-only'); + } + } else { + console.error('[export] TONE_SKSL compile failed - falling back to matrix-only'); + } + } + // 4. Denoise / Clarity Image Filters (export-only enhancements) let imageFilter = null; @@ -145,8 +194,16 @@ export async function processAndExportPhoto( paint.setImageFilter(imageFilter); } - // 5. Draw primary image with color filters & enhancements - canvas.drawImage(skImage, 0, 0, paint); + // 5. Draw primary image with color filters & enhancements. Tone path samples + // the image through the paint shader (child imageShader) with a rect so the + // whole chain (tone โ†’ matrix โ†’ denoise/clarity) applies โ€” but only when a + // real shader was built; otherwise plain drawImage (never drawRect on a + // shader-less paint, which paints solid color). + if (toneShader) { + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint); + } else { + canvas.drawImage(skImage, 0, 0, paint); + } // 6. Add Monochrome Grain Overlay if (adjustments.grain > 0) { @@ -215,27 +272,42 @@ export async function processAndExportPhoto( } // 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip) + console.error(`[export] composite ${Date.now() - t0}ms`); let resultImage = surface.makeImageSnapshot(); - if (options?.sharpen) { - resultImage = screenSharpenImage(resultImage); + // Sharpening knob (0..10) overrides the legacy boolean sharpen flag: + // legacy camera captures keep sharpen:true โ†’ 0.5 fallback when knob is 0. + const sharpKnob = adjustments.sharpening ?? 0; + const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0; + if (sharpenAmount > 0) { + resultImage = screenSharpenImage(resultImage, sharpenAmount); } - let base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95); + console.error(`[export] sharpen ${Date.now() - t0}ms`); + // Encode straight to raw JPEG bytes โ€” no base64. The old path round-tripped + // the whole 12MP frame through JS base64 decode/encode for the DPI patch and + // then writeAsStringAsync, which alone took ~6s of the render. + let bytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95); - if (!base64) { + if (!bytes || bytes.length === 0) { console.error('Failed to encode image to JPEG'); return null; } + console.error(`[export] encode ${Date.now() - t0}ms`); - // 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested + // 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested โ€” + // in-place Uint8Array work, no base64 round-trip. if (options?.dpi && options.dpi > 0) { - const patched = patchJpegDpi(base64ToBytes(base64), options.dpi); - base64 = bytesToBase64(patched); + bytes = patchJpegDpi(bytes, options.dpi); } // 10. Write binary bytes to temporary local file (overwrites previous export) - await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, { - encoding: FileSystem.EncodingType.Base64, - }); + try { + new File(EXPORT_FILE).write(bytes); + } catch (e) { + console.error('Failed to write export file: ' + e); + return null; + } + console.error(`[export] write ${Date.now() - t0}ms`); + console.error(`[export] total-done ${Date.now() - t0}ms`); // 11. Request Media Library permission & Save to device gallery const mediaPermission = await MediaLibrary.requestPermissionsAsync(); @@ -249,6 +321,7 @@ export async function processAndExportPhoto( } else { console.warn('Media Library permission denied. Image saved to temporary cache only.'); } + console.error(`[export] gallery-save ${Date.now() - t0}ms`); return { uri: EXPORT_FILE, savedToLibrary: false }; } catch (error) { diff --git a/src/utils/storageUtils.ts b/src/utils/storageUtils.ts index 1ea93ae..fb1b65f 100644 --- a/src/utils/storageUtils.ts +++ b/src/utils/storageUtils.ts @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Recipe } from '../types'; -import { DEFAULT_RECIPES } from './defaultRecipes'; +import { DEFAULT_RECIPES, DEFAULT_ADJUSTMENTS } from './defaultRecipes'; const CUSTOM_RECIPES_KEY = '@camrecipe_pro:custom_recipes'; @@ -17,7 +17,9 @@ export async function getCustomRecipes(): Promise { export async function getAllRecipes(): Promise { const custom = await getCustomRecipes(); - return [...DEFAULT_RECIPES, ...custom]; + // Backfill newer adjustment fields onto recipes persisted before they existed. + const fill = (r: Recipe): Recipe => ({ ...r, adjustments: { ...DEFAULT_ADJUSTMENTS, ...r.adjustments } }); + return [...DEFAULT_RECIPES.map(fill), ...custom.map(fill)]; } export async function saveCustomRecipe(recipe: Omit): Promise { diff --git a/src/utils/toneShader.ts b/src/utils/toneShader.ts new file mode 100644 index 0000000..44a8729 --- /dev/null +++ b/src/utils/toneShader.ts @@ -0,0 +1,74 @@ +import { ColorAdjustments } from '../types'; + +// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). +// SkSL runtime effect over a child image shader, driven by luma so hue is +// preserved (single multiplicative factor per pixel). +// +// Soft-knee masks keep the knobs independent, like camera tone curves: +// hl affects ONLY the bright end (knee 0.80..1.00), +// sh affects ONLY the dark end (knee 0.00..0.30), +// leaving the whole 0.30..0.80 mid/upper band untouched. Earlier knees +// (0.40..0.95 / 0.05..0.55, then 0.55..1.0 / 0.0..0.45, then 0.70..1.0) still +// reached mid/upper grays, so HL read as a global gray/brightness change +// instead of isolating the bright end โ€” on-device testing confirmed it. +// +// HL is multiplicative on BOTH signs so +/โˆ’ act on the same region: +// + scales 0.85+ up toward white (natural clip at 1.0), โˆ’ scales them down. +// A + additive lift using (1-t) dies at tโ†’1.0 and never touched the specular +// end โ€” that asymmetry made +HL look like a mid-tone push, not a highlight +// change. (SH keeps its original form; additive + worked there.) +// +// dr - DR strength 0..1: lifts shadows slightly and rolls highlights +// (Fuji extended DR); 0/auto/DR100 = no extra curve. +// hl - highlight -1..1: + lifts toward white, - rolls the bright side down. +// sh - shadow -1..1: + lifts the dark side, - deepens it. +export const TONE_SKSL = ` +uniform shader src; +uniform float dr; +uniform float hl; +uniform float sh; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); + float hlMask = smoothstep(0.80, 1.0, t); + float shMask = 1.0 - smoothstep(0.0, 0.30, t); + // NOTE: never name a local 'out' โ€” it is a reserved SkSL qualifier. + float o = t; + // Highlight: bright end only, multiplicative on both signs (symmetric region). + if (hl >= 0.0) { + o *= 1.0 + hl * 0.30 * hlMask; + } else { + o *= 1.0 + hl * 0.45 * hlMask; + } + // Shadow: dark end only. + if (sh >= 0.0) { + o += sh * 0.80 * shMask * (1.0 - t); + } else { + o *= 1.0 + sh * 0.80 * shMask; + } + // Dynamic range: gentle shadow lift + highlight roll (protect brights). + o += dr * 0.12 * shMask * (1.0 - t); + o -= dr * 0.18 * hlMask * t; + float f = clamp(o / max(t, 0.0004), 0.0, 4.0); + return vec4(clamp(c.rgb * f, 0.0, 1.0), c.a); +} +`; + +export interface ToneUniforms { + // All zero โ†’ no tone adjustment needed (caller can skip the shader pass). + dr: number; // 0..1 + hl: number; // -1..1 (adjustments.highlight / 10) + sh: number; // -1..1 (adjustments.shadow / 10) +} + +export function getToneUniforms(adj: ColorAdjustments): ToneUniforms { + const drRaw = adj.dynamicRange ?? 'auto'; + const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300; + const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10)); + const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10)); + return { dr, hl, sh }; +} + +export function toneIsActive(u: ToneUniforms): boolean { + return u.dr !== 0 || u.hl !== 0 || u.sh !== 0; +}