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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+5
@@ -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:
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+71
-108
@@ -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
@@ -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
@@ -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"><</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>
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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] +
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user