fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish

This commit is contained in:
2026-09-08 10:25:34 +07:00
parent 515f408c67
commit 8eca31ce58
25 changed files with 1450 additions and 822 deletions
+70 -61
View File
@@ -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<Recipe | null>(null);
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({
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<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('recipes');
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
const [openTab, setOpenTab] = useState<TabId | null>(null);
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
@@ -58,7 +47,11 @@ export default function App() {
// Hardware Permissions (vision-camera)
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
const cameraRef = useRef<Camera>(null);
// Full-resolution photo output — attached to the live <Camera> 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 (
<SafeAreaView className="flex-1 bg-carbon">
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
{/* Top Header */}
<Header mode={mode} setMode={handleSetMode} title="CamRecipe Pro" />
<View className="flex-1 bg-black">
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
{/* Main Viewfinder Frame */}
<View className="flex-1 justify-center items-center px-4 py-2 bg-carbon">
{/* Fullscreen hero: photo or live preview fills the whole screen */}
<View className="absolute inset-0 bg-black">
<Viewfinder
mode={mode}
recipe={
@@ -384,41 +381,53 @@ export default function App() {
libraryImageUri={libraryImageUri}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
cameraRef={cameraRef}
photoOutput={photoOutput}
/>
</View>
{/* Selector Tabs */}
<TabSelector activeTab={activeTab} setActiveTab={setActiveTab} />
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
<TopBar mode={mode} recipeName={activeRecipe.name} onSaveRecipe={handleSaveRecipe} />
{/* Adjustments Panel */}
<View style={{ height: 250 }} className="bg-carbon">
<AdjustmentPanel
activeTab={activeTab}
recipes={recipes}
currentRecipeId={selectedRecipe?.id || ''}
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
<View className="absolute left-0 right-0 bottom-0 z-10">
{openTab !== null && (
<View
style={{
backgroundColor: 'rgba(8,8,10,0.96)',
borderTopWidth: 1,
borderTopColor: 'rgba(63,63,70,0.7)',
}}
className="rounded-t-2xl overflow-hidden border-l border-r border-zinc-800/60"
>
<AdjustmentPanel
activeTab={openTab}
recipes={recipes}
currentRecipeId={selectedRecipe?.id || ''}
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
/>
</View>
)}
<ToolRail activeTab={openTab} onToggle={(tab) => setOpenTab(openTab === tab ? null : tab)} />
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={() => setPreviewVisible(true)}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
</View>
{/* Bottom Camera Buttons */}
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={() => setPreviewVisible(true)}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
{/* Preview Modal */}
<PreviewModal
visible={previewVisible}
@@ -437,6 +446,6 @@ export default function App() {
</Text>
</View>
)}
</SafeAreaView>
</View>
);
}
+4
View File
@@ -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 {
+5
View File
@@ -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:
+2 -1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
@@ -8,7 +9,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
+5
View File
@@ -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
+2 -1
View File
@@ -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
+8
View File
@@ -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
}
}
+4 -8
View File
@@ -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",
{
+15 -2
View File
@@ -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);
+71 -108
View File
@@ -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"
}
}
}
}
+5 -2
View File
@@ -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": {
+344 -325
View File
@@ -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<string | null>(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 }) => (
<RecipeItem
item={item}
isActive={item.id === currentRecipeId}
onSelect={onSelectRecipe}
onDelete={onDeleteRecipe}
triggerHaptic={triggerHaptic}
/>
),
[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 (
<View className="mb-4 px-4">
<View className="flex-row justify-between items-center mb-1">
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider">{label.toUpperCase()}</Text>
<Text className="text-amber-500 font-mono text-xs font-semibold">{displayVal}</Text>
</View>
<Slider
value={value}
minimumValue={min}
maximumValue={max}
step={step}
onValueChange={onValueChange}
onSlidingComplete={triggerHaptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
<View key={c.key} className="mx-1">
{c.trash ? (
<View className={`flex-row items-center rounded-full border py-1.5 pl-3 pr-1 ${tone}`}>
<TouchableOpacity onPress={() => { haptic(); c.onPress(); }} activeOpacity={0.7}>
<Text className={`font-mono text-[11px] font-bold ${textTone}`}>{c.label}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
haptic();
c.onPressDelete && c.onPressDelete();
}}
className="p-1.5 ml-1"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Trash2 size={12} color="#ef4444" />
</TouchableOpacity>
</View>
) : c.disabled ? (
<View className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5">
<Text className="font-mono text-[10px] font-semibold text-zinc-500">{c.label}</Text>
</View>
) : (
<TouchableOpacity
onPress={() => { haptic(); c.onPress(); }}
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
activeOpacity={0.7}
>
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
{c.label}
</Text>
</TouchableOpacity>
)}
</View>
);
};
switch (activeTab) {
case 'recipes':
return (
<View className="flex-1 bg-carbon">
<View className="flex-row justify-between items-center px-4 py-2 bg-titan border-b border-zinc-800">
<Text className="text-zinc-400 font-mono text-xs">SELECT OR CREATE PRESETS</Text>
<TouchableOpacity
onPress={() => setModalVisible(true)}
className="flex-row items-center bg-amber-500 px-3 py-1 rounded space-x-1"
activeOpacity={0.7}
>
<Plus size={14} color="#000000" />
<Text className="text-black font-mono text-xs font-bold">SAVE CURRENT</Text>
</TouchableOpacity>
</View>
// 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),
})
);
<FlatList
data={recipes}
keyExtractor={(item) => 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 */}
<Modal
transparent
animationType="slide"
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
// ---- per-tab parameter definitions ---------------------------------------
const paramDefs: Record<TabId, ParamDef[]> = {
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 (
<View className="px-3 py-2">
<Text className="font-mono text-[10px] text-zinc-500">NO PRESETS - TAP SAVE ABOVE</Text>
</View>
);
}
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 (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
<View className="flex-1 justify-end bg-black/60">
<View className="bg-titan border-t border-zinc-800 rounded-t-3xl p-6 pb-8">
<Text className="text-white font-mono text-sm font-bold tracking-wider mb-4">
SAVE NEW RECIPE
</Text>
<TextInput
value={newRecipeName}
onChangeText={setNewRecipeName}
placeholder="Enter recipe name (e.g. Fuji Retro 🏮)"
placeholderTextColor="#71717a"
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
autoFocus
/>
<View className="flex-row space-x-3">
<TouchableOpacity
onPress={() => setModalVisible(false)}
className="flex-1 bg-zinc-800 py-3 rounded-lg items-center"
>
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSavePress}
className="flex-1 bg-amber-500 py-3 rounded-lg items-center"
>
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
{chips.map(renderChip)}
</ScrollView>
);
}
case 'iq':
return (
<ScrollView className="flex-1 bg-carbon py-3">
{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 })
)}
</ScrollView>
);
case 'iq':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{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'],
})
)}
</ScrollView>
);
case 'wb':
return (
<ScrollView className="flex-1 bg-carbon py-3">
{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 })
)}
<View className="px-4 mb-4">
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-2">
COLOR CHROME EFFECT
</Text>
<View className="flex-row bg-titan p-0.5 rounded-lg border border-zinc-800">
{(['none', 'weak', 'strong'] as const).map((chrome) => {
const isActive = adjustments.colorChrome === chrome;
return (
<TouchableOpacity
key={chrome}
onPress={() => {
onUpdateAdjustments({ colorChrome: chrome });
triggerHaptic();
}}
className={`flex-1 py-1.5 rounded-md items-center ${
isActive ? 'bg-amber-500' : 'bg-transparent'
}`}
activeOpacity={0.7}
>
<Text
className={`font-mono text-xs font-bold ${
isActive ? 'text-black' : 'text-zinc-400'
}`}
>
{chrome.toUpperCase()}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
</ScrollView>
);
case 'wb':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{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'] })
)}
</ScrollView>
);
case 'filters':
return (
<ScrollView className="flex-1 bg-carbon py-3">
<Text className="text-zinc-600 font-mono text-[10px] px-4 mb-2">
NOTE: DENOISE & CLARITY APPLY AT EXPORT ONLY — LIVE PREVIEW UNCHANGED
</Text>
{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 })
)}
</ScrollView>
);
case 'filters':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{paramChips(paramDefs.filters)}
{renderChip({
key: 'note',
label: 'NR/CLARITY/SHARPEN APPLY AT EXPORT',
disabled: true,
active: false,
onPress: () => {},
})}
</ScrollView>
);
case 'frame':
return (
<ScrollView className="flex-1 bg-carbon py-4">
<View className="px-4 mb-5">
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-3">
SELECT PNG FRAME OVERLAY
</Text>
<View className="flex-row flex-wrap">
{FRAMES.map((f: { id: FrameId; name: string }) => {
const isActive = selectedFrame === f.id;
return (
<TouchableOpacity
key={f.id}
onPress={() => {
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}
>
<Text
className={`font-mono text-xs font-bold ${
isActive ? 'text-amber-500' : 'text-zinc-400'
}`}
>
{f.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
case 'frame':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{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),
})}
</ScrollView>
);
<View className="px-4 flex-row items-center justify-between border-t border-zinc-800/50 pt-4">
<View>
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-wider">
GPS GEOTAG WATERMARK
</Text>
<Text className="text-zinc-500 font-mono text-[10px] mt-0.5">
Stamps coordinates & place names onto photo
</Text>
</View>
<TouchableOpacity
onPress={() => {
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}
>
<View className="w-5 h-5 rounded-full bg-black border border-zinc-950 flex justify-center items-center">
{useGeotag && <Check size={10} color="#f59e0b" />}
</View>
</TouchableOpacity>
</View>
</ScrollView>
);
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 (
<TouchableOpacity
onPress={() => {
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}
>
<View className="flex-row justify-between items-start">
<View className="flex-1 mr-1">
<Text
numberOfLines={1}
className={`font-mono text-xs font-bold ${
isActive ? 'text-amber-500' : 'text-zinc-200'
}`}
>
{item.name}
</Text>
<Text className="text-[10px] text-zinc-500 font-mono mt-0.5">
{item.baseFilter.toUpperCase()}
</Text>
</View>
{item.isCustom && (
<View>
{openParamDef && (
<View
key={openParamDef.key}
className="flex-row items-center border-b border-zinc-800/60 px-3 py-1.5"
>
<TouchableOpacity
onPress={(e) => {
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}
>
<Trash2 size={12} color="#ef4444" />
<Text className="font-mono text-xs font-bold text-zinc-300">&lt;</Text>
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
<View className="flex-1">
<View className="flex-row justify-between items-center mb-0.5">
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest">
{openParamDef.label}
</Text>
<Text
className={`font-mono text-xs font-bold ${
openParamDef.value !== openParamDef.default ? 'text-amber-500' : 'text-zinc-300'
}`}
>
{openParamDef.display(openParamDef.value)}
</Text>
</View>
<Slider
value={openParamDef.value}
minimumValue={openParamDef.min}
maximumValue={openParamDef.max}
step={openParamDef.step}
onValueChange={openParamDef.onChange}
onSlidingComplete={haptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
</View>
</View>
)}
{chipsRow}
</View>
);
});
}
+22 -14
View File
@@ -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 (
<View className="flex-row items-center justify-between px-8 py-6 bg-carbon border-t border-zinc-800/50">
{/* Pick Image Button */}
<View
style={{ paddingBottom: insets.bottom + 10 }}
className="flex-row items-center justify-between px-7 pt-2"
>
{/* Pick Image Button (left) */}
<TouchableOpacity
onPress={onPickImage}
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center"
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<ImageIcon size={20} color="#f59e0b" />
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
{/* Dynamic Center Button: Shutter or Save */}
@@ -37,13 +43,15 @@ export default function CameraControls({
activeOpacity={0.8}
>
{isLibraryMode ? (
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center border-4 border-zinc-800">
<Download size={28} color="#000000" />
<View className="w-20 h-20 rounded-full bg-black/25 items-center justify-center">
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center border-2 border-black/20">
<Download size={28} color="#000000" />
</View>
</View>
) : (
<View className="w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center">
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center">
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
<View className="w-20 h-20 rounded-full bg-black/25 items-center justify-center">
<View className="w-14 h-14 rounded-full bg-white items-center justify-center border-2 border-black/10">
<View className="w-11 h-11 rounded-full bg-zinc-100 border border-zinc-300" />
</View>
</View>
)}
@@ -52,17 +60,17 @@ export default function CameraControls({
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
<TouchableOpacity
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
className={`w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden ${
className={`w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden ${
isLibraryMode ? '' : ''
}`}
activeOpacity={0.7}
>
{isLibraryMode ? (
<Camera size={20} color="#f59e0b" />
<Camera size={20} color="#e4e4e7" strokeWidth={1.75} />
) : lastPhotoUri ? (
<Image source={{ uri: lastPhotoUri }} className="w-full h-full object-cover" />
<Image source={{ uri: lastPhotoUri }} style={StyleSheet.absoluteFill} className="object-cover" />
) : (
<Eye size={20} color="#a1a1aa" />
<Eye size={20} color="#a1a1aa" strokeWidth={1.75} />
)}
</TouchableOpacity>
</View>
-58
View File
@@ -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 (
<View className="flex-row items-center justify-between px-4 py-3 bg-carbon border-b border-zinc-800/50">
<View className="flex-row items-center space-x-2">
<Sliders size={18} color="#f59e0b" />
<Text className="text-white font-mono text-sm tracking-wider font-bold">
{title.toUpperCase()}
</Text>
</View>
<View className="flex-row bg-titan rounded-lg p-0.5 border border-zinc-800">
<TouchableOpacity
onPress={() => 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 size={14} color={mode === 'camera' ? '#000000' : '#a1a1aa'} />
<Text
className={`font-mono text-xs font-semibold ${
mode === 'camera' ? 'text-black' : 'text-zinc-400'
}`}
>
CAMERA
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => 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}
>
<ImageIcon size={14} color={mode === 'library' ? '#000000' : '#a1a1aa'} />
<Text
className={`font-mono text-xs font-semibold ${
mode === 'library' ? 'text-black' : 'text-zinc-400'
}`}
>
LIBRARY
</Text>
</TouchableOpacity>
</View>
</View>
);
}
+1 -1
View File
@@ -121,7 +121,7 @@ export default function PreviewModal({
<View>
{photoUri && !saved && (
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
NOT SAVED TO GALLERY — TAP DONE TO SAVE
NOT SAVED TO GALLERY - TAP DONE TO SAVE
</Text>
)}
<View className="flex-row justify-around items-center px-4">
-54
View File
@@ -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 (
<View className="bg-carbon border-b border-zinc-800">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-2 py-2"
contentContainerStyle={{ alignItems: 'center' }}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<TouchableOpacity
key={tab.id}
onPress={() => 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}
>
<Text
className={`font-mono text-xs tracking-widest font-bold ${
isActive ? 'text-black' : 'text-zinc-400'
}`}
>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
+60
View File
@@ -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 (
<View className="py-1.5">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8 }}
>
{TOOLS.map((tool) => {
const isActive = activeTab === tool.id;
const Icon = tool.icon;
return (
<TouchableOpacity
key={tool.id}
onPress={() => {
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}
>
<Icon size={14} color={isActive ? '#f59e0b' : '#e4e4e7'} strokeWidth={1.75} />
<Text
className={`ml-1.5 font-mono text-[10px] font-bold tracking-widest ${
isActive ? 'text-amber-500' : 'text-zinc-300'
}`}
>
{tool.label}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
+96
View File
@@ -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 (
<>
<View
style={{ paddingTop: insets.top + 6 }}
className="absolute top-0 left-0 right-0 z-20 flex-row items-start justify-between px-4"
>
<View className="max-w-[62%] rounded-xl border border-zinc-800/80 bg-black/45 px-3 py-2">
<Text className="text-amber-500 font-mono text-[9px] font-bold tracking-[0.2em]">
{mode === 'camera' ? 'LIVE' : 'LIBRARY'}
</Text>
<Text
numberOfLines={1}
className="text-white font-mono text-sm font-bold tracking-wide"
>
{recipeName}
</Text>
</View>
<TouchableOpacity
onPress={() => 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}
>
<Plus size={14} color="#f59e0b" />
<Text className="ml-1 font-mono text-xs font-bold tracking-widest text-amber-500">
SAVE
</Text>
</TouchableOpacity>
</View>
<Modal
transparent
animationType="slide"
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
>
<View className="flex-1 justify-end bg-black/60">
<View style={{ paddingBottom: insets.bottom + 16 }} className="bg-titan border-t border-zinc-800 rounded-t-3xl p-6">
<Text className="text-white font-mono text-sm font-bold tracking-wider mb-4">
SAVE CURRENT AS RECIPE
</Text>
<TextInput
value={newRecipeName}
onChangeText={setNewRecipeName}
placeholder="Recipe name (e.g. Fuji Retro)"
placeholderTextColor="#71717a"
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
autoFocus
onSubmitEditing={handleSavePress}
/>
<View className="flex-row space-x-3">
<TouchableOpacity
onPress={() => setModalVisible(false)}
className="flex-1 bg-zinc-800 py-3 rounded-lg items-center"
>
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSavePress}
className="flex-1 bg-amber-500 py-3 rounded-lg items-center"
>
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</>
);
}
+461 -142
View File
@@ -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<Camera | null>;
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<CameraDevice | undefined>();
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<number[]>(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<number[]>([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<SkiaCameraRef>(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<typeof setTimeout>[] = [];
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<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
const handleFrame = useCallback<SkiaCameraOnFrame>(
(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 (
<Group color="white">
<Rect x={0} y={0} width={viewfinderWidth} height={borderWidth} />
<Rect x={0} y={viewfinderHeight - borderWidth} width={viewfinderWidth} height={borderWidth} />
<Rect x={0} y={0} width={borderWidth} height={viewfinderHeight} />
<Rect x={viewfinderWidth - borderWidth} y={0} width={borderWidth} height={viewfinderHeight} />
<Rect x={0} y={0} width={vw} height={borderWidth} />
<Rect x={0} y={vh - borderWidth} width={vw} height={borderWidth} />
<Rect x={0} y={0} width={borderWidth} height={vh} />
<Rect x={vw - borderWidth} y={0} width={borderWidth} height={vh} />
</Group>
);
}
@@ -150,15 +421,15 @@ export default function Viewfinder({
if (selectedFrame === 'polaroid') {
return (
<Group>
<Rect x={0} y={0} width={viewfinderWidth} height={sideBorder} color="#faf9f6" />
<Rect x={0} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
<Rect x={viewfinderWidth - sideBorder} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
<Rect x={0} y={viewfinderHeight - bottomBorder} width={viewfinderWidth} height={bottomBorder} color="#faf9f6" />
<Rect x={0} y={0} width={vw} height={sideBorder} color="#faf9f6" />
<Rect x={0} y={0} width={sideBorder} height={vh} color="#faf9f6" />
<Rect x={vw - sideBorder} y={0} width={sideBorder} height={vh} color="#faf9f6" />
<Rect x={0} y={vh - bottomBorder} width={vw} height={bottomBorder} color="#faf9f6" />
<Rect
x={sideBorder - 1}
y={sideBorder - 1}
width={viewfinderWidth - 2 * sideBorder + 2}
height={viewfinderHeight - sideBorder - bottomBorder + 2}
width={vw - 2 * sideBorder + 2}
height={vh - sideBorder - bottomBorder + 2}
color="#e5e5e5"
style="stroke"
strokeWidth={1}
@@ -170,8 +441,8 @@ export default function Viewfinder({
if (selectedFrame === 'cinematic') {
return (
<Group color="black">
<Rect x={0} y={0} width={viewfinderWidth} height={barHeight} />
<Rect x={0} y={viewfinderHeight - barHeight} width={viewfinderWidth} height={barHeight} />
<Rect x={0} y={0} width={vw} height={barHeight} />
<Rect x={0} y={vh - barHeight} width={vw} height={barHeight} />
</Group>
);
}
@@ -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 (
<Group>
@@ -215,7 +486,7 @@ export default function Viewfinder({
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
@@ -232,20 +503,13 @@ export default function Viewfinder({
<View className="h-[1px] w-full bg-white mt-[33%]" />
<View className="h-[1px] w-full bg-white mb-[33%]" />
</View>
<View className="absolute bottom-2 right-2 bg-black/60 rounded-full px-2 py-0.5">
<Text className="text-[9px] text-amber-500 font-mono">LIVE</Text>
</View>
</>
);
if (mode === 'camera') {
if (!cameraPermissionGranted) {
return (
<View
style={containerStyle}
className="bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-center p-6"
>
<View className="flex-1 bg-black items-center justify-center p-8">
<Text className="text-zinc-400 font-mono text-center mb-4">
Camera permission is required to use the viewfinder.
</Text>
@@ -262,10 +526,7 @@ export default function Viewfinder({
if (!device) {
return (
<View
style={containerStyle}
className="bg-zinc-950 border border-zinc-800 rounded-2xl items-center justify-center p-6"
>
<View className="flex-1 bg-black items-center justify-center p-8">
<Text className="text-zinc-500 font-mono text-center text-xs">
Camera not available on this device
</Text>
@@ -274,51 +535,109 @@ export default function Viewfinder({
}
return (
<View
style={containerStyle}
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
>
<Camera
ref={cameraRef}
<View className="flex-1 relative overflow-hidden bg-black">
<SkiaCamera
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={true}
// Android: Skia frame processor crashes on devices w/o hardware-buffered
// ImageAnalysis frames (e.g. Xiaomi 14 Ultra). Keep raw preview there;
// filters still apply on capture/export. iOS keeps live filtered preview.
frameProcessor={Platform.OS === 'android' ? undefined : frameProcessor}
isActive={appActive}
outputs={[photoOutput]}
pixelFormat="yuv"
onStarted={() => setCameraActive(true)}
onFrame={handleFrame}
/>
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
are pointerEvents none). locationX/Y are relative to this view. */}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
/>
{focusPoint && (
<View
pointerEvents="none"
className="absolute"
style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }}
>
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400" />
<View className="absolute left-1/2 top-1/2 -ml-px -mt-px w-[2px] h-[2px] rounded-full bg-amber-400" />
</View>
)}
{focusPoint && (
<TouchableOpacity
onPress={handleUnlockFocus}
className="absolute right-4 top-24 bg-black/70 border border-amber-400/70 rounded-full px-3 py-1.5 flex-row items-center space-x-1.5"
activeOpacity={0.7}
>
<Lock size={11} color="#f59e0b" />
<Text className="text-amber-400 font-mono text-[10px] font-bold">AE/AF LOCKED — TAP TO UNLOCK</Text>
</TouchableOpacity>
)}
{renderCameraOverlays()}
</View>
);
}
// Library mode
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
return (
<View
style={containerStyle}
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
>
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
<Canvas style={StyleSheet.absoluteFill}>
<Group>
<ColorMatrix matrix={colorMatrix} />
<SkiaImage
image={skiaImage}
fit="cover"
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
/>
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{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").
<Rect x={imageFitRect.dx} y={imageFitRect.dy} width={imageFitRect.dw} height={imageFitRect.dh}>
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader
image={skiaImage}
fit="fill"
tx="clamp"
ty="clamp"
rect={rect(imageFitRect.dx, imageFitRect.dy, imageFitRect.dw, imageFitRect.dh)}
/>
</Shader>
</Rect>
) : (
<SkiaImage
image={skiaImage}
fit="contain"
rect={rect(0, 0, vw, vh)}
/>
)}
</Group>
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
</Canvas>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
is a fullscreen screen effect → stays fixed above. */}
<Group matrix={libZoomMatrix}>
{renderFrameOverlay()}
{renderGPSWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
</>
) : (
<View className="items-center justify-center p-6">
<Text className="text-zinc-500 font-mono text-center text-xs">
+6 -2
View File
@@ -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 {
+47 -11
View File
@@ -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] +
+42 -1
View File
@@ -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,
+102 -29
View File
@@ -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<ExportResult | null> {
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<typeof Skia.RuntimeEffect.Make> = 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) {
+4 -2
View File
@@ -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<Recipe[]> {
export async function getAllRecipes(): Promise<Recipe[]> {
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<Recipe, 'id' | 'isCustom'>): Promise<Recipe> {
+74
View File
@@ -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;
}