first commit
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
TextInput,
|
||||
Modal,
|
||||
} 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 { Recipe, ColorAdjustments, FrameId } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
|
||||
interface AdjustmentPanelProps {
|
||||
activeTab: 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
recipes: Recipe[];
|
||||
currentRecipeId: string;
|
||||
adjustments: ColorAdjustments;
|
||||
selectedFrame: FrameId;
|
||||
useGeotag: boolean;
|
||||
onSelectRecipe: (recipe: Recipe) => void;
|
||||
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
||||
onUpdateFrame: (frameId: FrameId) => void;
|
||||
onToggleGeotag: (enabled: boolean) => void;
|
||||
onSaveRecipe: (name: string) => void;
|
||||
onDeleteRecipe: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function AdjustmentPanel({
|
||||
activeTab,
|
||||
recipes,
|
||||
currentRecipeId,
|
||||
adjustments,
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
onSelectRecipe,
|
||||
onUpdateAdjustments,
|
||||
onUpdateFrame,
|
||||
onToggleGeotag,
|
||||
onSaveRecipe,
|
||||
onDeleteRecipe,
|
||||
}: AdjustmentPanelProps) {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newRecipeName, setNewRecipeName] = useState('');
|
||||
|
||||
const triggerHaptic = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const renderRecipeItem = React.useCallback(
|
||||
({ item }: { item: Recipe }) => (
|
||||
<RecipeItem
|
||||
item={item}
|
||||
isActive={item.id === currentRecipeId}
|
||||
onSelect={onSelectRecipe}
|
||||
onDelete={onDeleteRecipe}
|
||||
triggerHaptic={triggerHaptic}
|
||||
/>
|
||||
),
|
||||
[currentRecipeId, onSelectRecipe, onDeleteRecipe]
|
||||
);
|
||||
|
||||
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();
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<FlatList
|
||||
data={recipes}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
className="p-2"
|
||||
columnWrapperStyle={{ justifyContent: 'space-between' }}
|
||||
renderItem={renderRecipeItem}
|
||||
/>
|
||||
|
||||
{/* Save Recipe Modal */}
|
||||
<Modal
|
||||
transparent
|
||||
animationType="slide"
|
||||
visible={modalVisible}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
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 '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 'filters':
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-carbon py-3">
|
||||
{renderSlider('Highlight Contrast', adjustments.highlight, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ highlight: val })
|
||||
)}
|
||||
{renderSlider('Shadow Contrast', adjustments.shadow, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ shadow: val })
|
||||
)}
|
||||
{renderSlider('Denoise (Blur)', adjustments.denoise, 0, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ denoise: val })
|
||||
)}
|
||||
{renderSlider('Clarity (Sharp/Soft)', adjustments.clarity, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ clarity: val })
|
||||
)}
|
||||
{renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ grain: val })
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
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 && (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
triggerHaptic();
|
||||
}}
|
||||
className="p-1"
|
||||
>
|
||||
<Trash2 size={12} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Image } from 'react-native';
|
||||
import { Image as ImageIcon, Eye } from 'lucide-react-native';
|
||||
|
||||
interface CameraControlsProps {
|
||||
lastPhotoUri: string | null;
|
||||
onCapture: () => void;
|
||||
onPickImage: () => void;
|
||||
onOpenPreview: () => void;
|
||||
isLibraryMode: boolean;
|
||||
}
|
||||
|
||||
export default function CameraControls({
|
||||
lastPhotoUri,
|
||||
onCapture,
|
||||
onPickImage,
|
||||
onOpenPreview,
|
||||
isLibraryMode,
|
||||
}: CameraControlsProps) {
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-8 py-6 bg-carbon border-t border-zinc-800/50">
|
||||
{/* Pick Image Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onPickImage}
|
||||
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ImageIcon size={20} color="#f59e0b" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Shutter Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
disabled={isLibraryMode}
|
||||
className={`w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center ${
|
||||
isLibraryMode ? 'opacity-40' : 'opacity-100'
|
||||
}`}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center active:scale-95">
|
||||
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Last Captured Image Preview Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onOpenPreview}
|
||||
disabled={!lastPhotoUri}
|
||||
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{lastPhotoUri ? (
|
||||
<Image source={{ uri: lastPhotoUri }} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Eye size={20} color="#a1a1aa" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, Share } from 'react-native';
|
||||
import { X, Share2, Download } from 'lucide-react-native';
|
||||
|
||||
interface PreviewModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
photoUri: string | null;
|
||||
recipeName: string;
|
||||
}
|
||||
|
||||
export default function PreviewModal({
|
||||
visible,
|
||||
onClose,
|
||||
photoUri,
|
||||
recipeName,
|
||||
}: PreviewModalProps) {
|
||||
const handleShare = async () => {
|
||||
if (!photoUri) return;
|
||||
try {
|
||||
await Share.share({
|
||||
url: photoUri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sharing failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View className="flex-1 bg-black justify-between py-6 px-4">
|
||||
{/* Top bar */}
|
||||
<View className="flex-row justify-between items-center px-2">
|
||||
<Text className="text-zinc-500 font-mono text-xs">PRESET: {recipeName.toUpperCase()}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={18} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Image display */}
|
||||
<View className="flex-1 justify-center items-center my-4">
|
||||
{photoUri ? (
|
||||
<Image
|
||||
source={{ uri: photoUri }}
|
||||
className="w-full aspect-[3/4] max-h-[75vh] rounded-lg border border-zinc-800"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-zinc-400 font-mono text-sm">NO IMAGE CAPTURED</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View className="flex-row justify-around items-center px-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
disabled={!photoUri}
|
||||
className="flex-row items-center space-x-2 bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Share2 size={16} color="#f59e0b" />
|
||||
<Text className="text-white font-mono text-xs font-bold">SHARE</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="flex-row items-center space-x-2 bg-amber-500 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Download size={16} color="#000000" />
|
||||
<Text className="text-black font-mono text-xs font-bold">DONE</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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,253 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { CameraView } from 'expo-camera';
|
||||
import {
|
||||
Canvas,
|
||||
Image as SkiaImage,
|
||||
ColorMatrix,
|
||||
useImage,
|
||||
useFont,
|
||||
Text as SkiaText,
|
||||
rect,
|
||||
Shader,
|
||||
Skia,
|
||||
Group,
|
||||
Rect,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from '../utils/colorUtils';
|
||||
import { formatCoordinate } from '../utils/locationUtils';
|
||||
|
||||
interface ViewfinderProps {
|
||||
mode: 'camera' | 'library';
|
||||
recipe: Recipe;
|
||||
selectedFrame: FrameId;
|
||||
useGeotag: boolean;
|
||||
gpsInfo: GPSInfo | null;
|
||||
libraryImageUri: string | null;
|
||||
cameraPermissionGranted: boolean;
|
||||
onRequestCameraPermission: () => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const viewfinderWidth = screenWidth - 32; // padding sides
|
||||
const viewfinderHeight = (viewfinderWidth * 4) / 3; // 3:4 aspect ratio
|
||||
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
vec4 main(vec2 pos) {
|
||||
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
return vec4(vec3(r), 1.0);
|
||||
}
|
||||
`);
|
||||
|
||||
export default function Viewfinder({
|
||||
mode,
|
||||
recipe,
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
gpsInfo,
|
||||
libraryImageUri,
|
||||
cameraPermissionGranted,
|
||||
onRequestCameraPermission,
|
||||
}: ViewfinderProps) {
|
||||
// Load library image if available
|
||||
const skiaImage = useImage(libraryImageUri || '');
|
||||
|
||||
// Load Courier Prime font for the GPS watermark
|
||||
const customFont = useFont(
|
||||
require('../../assets/CourierPrime-Regular.ttf'),
|
||||
Math.round(viewfinderWidth * 0.032) // ~12-14px scaled dynamically
|
||||
);
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
const colorMatrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
);
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
|
||||
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 renderFrameOverlay = () => {
|
||||
if (selectedFrame === 'none') return null;
|
||||
|
||||
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} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
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={sideBorder - 1}
|
||||
y={sideBorder - 1}
|
||||
width={viewfinderWidth - 2 * sideBorder + 2}
|
||||
height={viewfinderHeight - sideBorder - bottomBorder + 2}
|
||||
color="#e5e5e5"
|
||||
style="stroke"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderGPSWatermark = () => {
|
||||
if (!useGeotag || !gpsInfo || !customFont) return null;
|
||||
|
||||
const latStr = formatCoordinate(gpsInfo.latitude, 'lat');
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
|
||||
const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24);
|
||||
const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset - 32}
|
||||
text={`📍 ${locationName}`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset - 16}
|
||||
text={`${latStr}, ${lonStr}`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset}
|
||||
text={`${timestampStr} | SS 1/125 f/2.8`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLiveOverlay = () => {
|
||||
return (
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
if (mode === 'camera') {
|
||||
if (!cameraPermissionGranted) {
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
className="bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-center p-6"
|
||||
>
|
||||
<Text className="text-zinc-400 font-mono text-center mb-4">
|
||||
Camera permission is required to use the viewfinder.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onRequestCameraPermission}
|
||||
className="bg-amber-500 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="text-black font-mono text-xs font-bold">GRANT PERMISSION</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
>
|
||||
<CameraView style={StyleSheet.absoluteFill} facing="back" />
|
||||
|
||||
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
|
||||
<View className="w-[1px] h-full bg-white ml-[33%]" />
|
||||
<View className="w-[1px] h-full bg-white mr-[33%]" />
|
||||
</View>
|
||||
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
|
||||
<View className="h-[1px] w-full bg-white mt-[33%]" />
|
||||
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
||||
</View>
|
||||
|
||||
{renderLiveOverlay()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-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>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
) : (
|
||||
<View className="items-center justify-center p-6">
|
||||
<Text className="text-zinc-500 font-mono text-center text-xs">
|
||||
{libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'}
|
||||
</Text>
|
||||
<Text className="text-zinc-600 font-mono text-center text-[10px] mt-1">
|
||||
Tap the gallery icon below to load a photo
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic';
|
||||
|
||||
export interface ColorAdjustments {
|
||||
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
|
||||
contrast: number; // -10 to +10
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
isCustom?: boolean;
|
||||
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
|
||||
adjustments: ColorAdjustments;
|
||||
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic'
|
||||
useGeotag: boolean;
|
||||
}
|
||||
|
||||
export interface GPSInfo {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locality?: string;
|
||||
country?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Tanner Helland's Kelvin to RGB approximation
|
||||
// Kelvin range: 1000K to 40000K (we use 2500K to 10000K)
|
||||
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
|
||||
const temp = Math.max(1000, Math.min(40000, kelvin)) / 100;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
// Red
|
||||
if (temp <= 66) {
|
||||
r = 255;
|
||||
} else {
|
||||
r = temp - 60;
|
||||
r = 329.698727446 * Math.pow(r, -0.1332047592);
|
||||
r = Math.max(0, Math.min(255, r));
|
||||
}
|
||||
|
||||
// Green
|
||||
if (temp <= 66) {
|
||||
g = temp;
|
||||
g = 99.4708025861 * Math.log(g) - 161.1195681661;
|
||||
g = Math.max(0, Math.min(255, g));
|
||||
} else {
|
||||
g = temp - 60;
|
||||
g = 288.1221695283 * Math.pow(g, -0.0755148492);
|
||||
g = Math.max(0, Math.min(255, g));
|
||||
}
|
||||
|
||||
// Blue
|
||||
if (temp >= 66) {
|
||||
b = 255;
|
||||
} else {
|
||||
if (temp <= 19) {
|
||||
b = 0;
|
||||
} else {
|
||||
b = temp - 10;
|
||||
b = 138.5177312231 * Math.log(b) - 305.0447927307;
|
||||
b = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
}
|
||||
|
||||
return { r: r / 255, g: g / 255, b: b / 255 };
|
||||
}
|
||||
|
||||
// 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
|
||||
saturation: number, // -10 to +10
|
||||
temperature: number, // 2500 to 10000
|
||||
tint: number, // -10 to +10
|
||||
colorChrome: 'none' | 'weak' | 'strong'
|
||||
): number[] {
|
||||
// 1. Start with Identity Matrix
|
||||
let matrix = [
|
||||
1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
|
||||
// 2. Base Filters
|
||||
if (baseFilter === 'classic-neg') {
|
||||
// Classic Neg: high contrast, desaturated reds/greens, warm/amber cast
|
||||
matrix = [
|
||||
1.15, -0.05, -0.05, 0, 0.05,
|
||||
-0.05, 1.05, -0.05, 0, 0.02,
|
||||
-0.08, -0.08, 0.95, 0, -0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'provia') {
|
||||
// Provia: standard natural colors, balanced tones
|
||||
matrix = [
|
||||
1.02, 0, 0, 0, 0,
|
||||
0, 1.02, 0, 0, 0,
|
||||
0, 0, 1.02, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'velvia') {
|
||||
// Velvia: vivid colors, high saturation, deep blacks
|
||||
matrix = [
|
||||
1.25, -0.05, -0.05, 0, -0.02,
|
||||
-0.05, 1.25, -0.05, 0, -0.02,
|
||||
-0.05, -0.05, 1.25, 0, -0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'monochrome') {
|
||||
// Classic black and white filter (luminance weights)
|
||||
const r = 0.2126;
|
||||
const g = 0.7152;
|
||||
const b = 0.0722;
|
||||
matrix = [
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Apply Saturation adjustment (standard color matrix transformation)
|
||||
if (baseFilter !== 'monochrome' && saturation !== 0) {
|
||||
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
|
||||
const invS = 1 - s;
|
||||
const r = 0.213 * invS;
|
||||
const g = 0.715 * invS;
|
||||
const b = 0.072 * invS;
|
||||
|
||||
const satMat = [
|
||||
r + s, g, b, 0, 0,
|
||||
r, g + s, b, 0, 0,
|
||||
r, g, b + s, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
|
||||
matrix = multiplyMatrices(satMat, matrix);
|
||||
}
|
||||
|
||||
// 4. Color Chrome effect (enhances deep colors without shifting white point)
|
||||
if (baseFilter !== 'monochrome' && colorChrome !== 'none') {
|
||||
const factor = colorChrome === 'strong' ? 0.25 : 0.12;
|
||||
// Boost reds and blues slightly, deep shadows get more saturated
|
||||
const chromeMat = [
|
||||
1 + factor, -factor/2, -factor/2, 0, 0,
|
||||
-factor/2, 1, -factor/2, 0, 0,
|
||||
-factor/2, -factor/2, 1 + factor, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(chromeMat, matrix);
|
||||
}
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint)
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
// Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B)
|
||||
const tintR = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0;
|
||||
const tintB = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
|
||||
const wbMat = [
|
||||
rgbTemp.r + tintR, 0, 0, 0, 0,
|
||||
0, rgbTemp.g + tintG, 0, 0, 0,
|
||||
0, 0, rgbTemp.b + tintB, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(wbMat, matrix);
|
||||
|
||||
// 6. Exposure adjustment (Simple offset in fifth column and slight scaling)
|
||||
if (exposure !== 0) {
|
||||
const scale = 1 + (exposure / 10) * 0.2; // scaling multiplier
|
||||
const offset = (exposure / 10) * 0.15; // translation offset
|
||||
const expMat = [
|
||||
scale, 0, 0, 0, offset,
|
||||
0, scale, 0, 0, offset,
|
||||
0, 0, scale, 0, offset,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(expMat, matrix);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// 4x5 Matrix multiplication utility: A * B
|
||||
function multiplyMatrices(a: number[], b: number[]): number[] {
|
||||
const result = new Array(20).fill(0);
|
||||
for (let r = 0; r < 4; r++) {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
result[r * 5 + c] =
|
||||
a[r * 5 + 0] * b[0 * 5 + c] +
|
||||
a[r * 5 + 1] * b[1 * 5 + c] +
|
||||
a[r * 5 + 2] * b[2 * 5 + c] +
|
||||
a[r * 5 + 3] * b[3 * 5 + c];
|
||||
}
|
||||
// Handle the 5th column translation translation
|
||||
result[r * 5 + 4] =
|
||||
a[r * 5 + 0] * b[0 * 5 + 4] +
|
||||
a[r * 5 + 1] * b[1 * 5 + 4] +
|
||||
a[r * 5 + 2] * b[2 * 5 + 4] +
|
||||
a[r * 5 + 3] * b[3 * 5 + 4] +
|
||||
a[r * 5 + 4];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Recipe } from '../types';
|
||||
|
||||
export const DEFAULT_RECIPES: Recipe[] = [
|
||||
{
|
||||
id: 'classic-neg-default',
|
||||
name: 'CLASSIC NEG.',
|
||||
baseFilter: 'classic-neg',
|
||||
adjustments: {
|
||||
exposure: 2,
|
||||
contrast: 1,
|
||||
saturation: -1,
|
||||
temperature: 6300,
|
||||
tint: 2,
|
||||
highlight: -2,
|
||||
shadow: 1,
|
||||
denoise: 1,
|
||||
clarity: 2,
|
||||
grain: 4,
|
||||
colorChrome: 'weak',
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'velvia-default',
|
||||
name: 'VELVIA VIVID',
|
||||
baseFilter: 'velvia',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
contrast: 2,
|
||||
saturation: 3,
|
||||
temperature: 5500,
|
||||
tint: 0,
|
||||
highlight: 1,
|
||||
shadow: -1,
|
||||
denoise: 2,
|
||||
clarity: 4,
|
||||
grain: 1,
|
||||
colorChrome: 'strong',
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'provia-default',
|
||||
name: 'PROVIA STD',
|
||||
baseFilter: 'provia',
|
||||
adjustments: {
|
||||
exposure: 1,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 5000,
|
||||
tint: 0,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 2,
|
||||
clarity: 1,
|
||||
grain: 2,
|
||||
colorChrome: 'none',
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'acros-default',
|
||||
name: 'ACROS MONO',
|
||||
baseFilter: 'monochrome',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
contrast: 4,
|
||||
saturation: 0,
|
||||
temperature: 5000,
|
||||
tint: 0,
|
||||
highlight: 3,
|
||||
shadow: -2,
|
||||
denoise: 0,
|
||||
clarity: 3,
|
||||
grain: 6,
|
||||
colorChrome: 'none',
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'retro-amber-default',
|
||||
name: 'RETRO AMBER 🏮',
|
||||
baseFilter: 'classic-neg',
|
||||
adjustments: {
|
||||
exposure: 3,
|
||||
contrast: 2,
|
||||
saturation: 1,
|
||||
temperature: 7500,
|
||||
tint: -2,
|
||||
highlight: -3,
|
||||
shadow: 2,
|
||||
denoise: 3,
|
||||
clarity: -2,
|
||||
grain: 5,
|
||||
colorChrome: 'strong',
|
||||
},
|
||||
frameId: 'classic-white',
|
||||
useGeotag: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Skia, ImageFormat, BlendMode, TileMode } from '@shopify/react-native-skia';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { Asset } from 'expo-asset';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from './colorUtils';
|
||||
import { drawFrameOnCanvas } from './frameUtils';
|
||||
import { formatCoordinate } from './locationUtils';
|
||||
|
||||
export async function processAndExportPhoto(
|
||||
sourceUri: string,
|
||||
recipe: Recipe,
|
||||
frameId: FrameId,
|
||||
useGeotag: boolean,
|
||||
gpsInfo: GPSInfo | null
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// 1. Read source image file into Skia
|
||||
const skiaData = await Skia.Data.fromURI(sourceUri);
|
||||
if (!skiaData) {
|
||||
console.error('Failed to load image data from URI');
|
||||
return null;
|
||||
}
|
||||
const skImage = Skia.Image.MakeImageFromEncoded(skiaData);
|
||||
|
||||
if (!skImage) {
|
||||
console.error('Failed to parse image from URI');
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = skImage.width();
|
||||
const height = skImage.height();
|
||||
|
||||
// 2. Create offscreen canvas
|
||||
const surface = Skia.Surface.Make(width, height);
|
||||
if (!surface) {
|
||||
console.error('Failed to create Skia surface');
|
||||
return null;
|
||||
}
|
||||
|
||||
const canvas = surface.getCanvas();
|
||||
const paint = Skia.Paint();
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
|
||||
// 3. Build Color Matrix Filter
|
||||
const matrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
);
|
||||
const colorFilter = Skia.ColorFilter.MakeMatrix(matrix);
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 4. Highlight & Shadow / Denoise / Clarity Image Filters
|
||||
let imageFilter = null;
|
||||
|
||||
// Denoise (Subtle blur to smooth noise)
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px
|
||||
imageFilter = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
|
||||
}
|
||||
|
||||
// Clarity (Convolution Sharpening if positive)
|
||||
if (adjustments.clarity > 0) {
|
||||
const sharpAmount = (adjustments.clarity / 10) * 0.8;
|
||||
const kernel = [
|
||||
0, -sharpAmount, 0,
|
||||
-sharpAmount, 1 + 4 * sharpAmount, -sharpAmount,
|
||||
0, -sharpAmount, 0,
|
||||
];
|
||||
const sharpenFilter = Skia.ImageFilter.MakeMatrixConvolution(
|
||||
3,
|
||||
3,
|
||||
kernel,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
TileMode.Clamp,
|
||||
true,
|
||||
imageFilter
|
||||
);
|
||||
if (sharpenFilter) {
|
||||
imageFilter = sharpenFilter;
|
||||
}
|
||||
} else if (adjustments.clarity < 0) {
|
||||
// Bloom/mist effect if negative
|
||||
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
||||
const mistFilter = Skia.ImageFilter.MakeBlur(mistSigma, mistSigma, TileMode.Clamp, imageFilter);
|
||||
if (mistFilter) {
|
||||
imageFilter = mistFilter;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFilter) {
|
||||
paint.setImageFilter(imageFilter);
|
||||
}
|
||||
|
||||
// 5. Draw primary image with color filters & enhancements
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
|
||||
// 6. Add Monochrome Grain Overlay
|
||||
if (adjustments.grain > 0) {
|
||||
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
|
||||
const grainPaint = Skia.Paint();
|
||||
grainPaint.setBlendMode(BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainOpacity);
|
||||
|
||||
// Procedural noise shader
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
vec4 main(vec2 pos) {
|
||||
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
return vec4(vec3(r), 1.0);
|
||||
}
|
||||
`);
|
||||
const noiseShader = noiseEffect ? noiseEffect.makeShader([]) : null;
|
||||
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Render Frame Border (Polaroid / Classic White / Cinematic)
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
|
||||
// 8. Render GPS Geotag Watermark
|
||||
if (useGeotag && gpsInfo) {
|
||||
const fontAsset = Asset.fromModule(require('../../assets/CourierPrime-Regular.ttf'));
|
||||
if (!fontAsset.localUri) {
|
||||
await fontAsset.downloadAsync();
|
||||
}
|
||||
|
||||
const fontData = await FileSystem.readAsStringAsync(fontAsset.localUri!, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(fontData));
|
||||
|
||||
if (typeface) {
|
||||
const fontSize = Math.round(width * 0.032); // 3.2% of image width
|
||||
const font = Skia.Font(typeface, fontSize);
|
||||
const textPaint = Skia.Paint();
|
||||
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
|
||||
|
||||
const latStr = formatCoordinate(gpsInfo.latitude, 'lat');
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
|
||||
const yOffset = height - (frameId === 'polaroid' ? height * 0.15 : fontSize * 1.5);
|
||||
const xOffset = frameId === 'polaroid' ? width * 0.08 : fontSize * 1.2;
|
||||
|
||||
canvas.drawText(`📍 ${locationName}`, xOffset, yOffset - fontSize * 2.2, textPaint, font);
|
||||
canvas.drawText(`${latStr}, ${lonStr}`, xOffset, yOffset - fontSize * 1.1, textPaint, font);
|
||||
canvas.drawText(`${timestampStr} | SS 1/125 f/2.8`, xOffset, yOffset, textPaint, font);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG
|
||||
const resultImage = surface.makeImageSnapshot();
|
||||
const jpegBytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
|
||||
|
||||
if (!jpegBytes) {
|
||||
console.error('Failed to encode image to JPEG bytes');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 10. Write binary bytes to temporary local file
|
||||
const tempFileUri = `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
|
||||
const base64Bytes = Skia.Data.fromBytes(jpegBytes).toString(); // Convert to base64
|
||||
|
||||
await FileSystem.writeAsStringAsync(tempFileUri, base64Bytes, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
// 11. Request Media Library permission & Save to device gallery
|
||||
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (mediaPermission.granted) {
|
||||
await MediaLibrary.saveToLibraryAsync(tempFileUri);
|
||||
} else {
|
||||
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
||||
}
|
||||
|
||||
return tempFileUri;
|
||||
} catch (error) {
|
||||
console.error('Error during photo processing and export:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia';
|
||||
import { FrameId } from '../types';
|
||||
|
||||
export interface FrameDefinition {
|
||||
id: FrameId;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const FRAMES: FrameDefinition[] = [
|
||||
{ id: 'none', name: 'NO FRAME' },
|
||||
{ id: 'classic-white', name: 'CLASSIC BORDER' },
|
||||
{ id: 'polaroid', name: 'RETRO POLAROID' },
|
||||
{ id: 'cinematic', name: 'CINEMATIC BARS' },
|
||||
];
|
||||
|
||||
export function drawFrameOnCanvas(
|
||||
canvas: SkCanvas,
|
||||
width: number,
|
||||
height: number,
|
||||
frameId: FrameId
|
||||
) {
|
||||
if (frameId === 'none') return;
|
||||
|
||||
const paint = Skia.Paint();
|
||||
|
||||
if (frameId === 'classic-white') {
|
||||
// A clean white border around the picture
|
||||
paint.setColor(Skia.Color('#ffffff'));
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const borderWidth = Math.min(width, height) * 0.05; // 5% border
|
||||
|
||||
// Top border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint);
|
||||
// Bottom border
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint);
|
||||
// Left border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
|
||||
} else if (frameId === 'polaroid') {
|
||||
// Polaroid style: thick white borders, thicker at the bottom
|
||||
paint.setColor(Skia.Color('#faf9f6')); // Off-white
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const sideBorder = Math.min(width, height) * 0.06; // 6% borders on sides/top
|
||||
const bottomBorder = height * 0.18; // 18% border at bottom
|
||||
|
||||
// Top border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, sideBorder), paint);
|
||||
// Left border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, sideBorder, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - sideBorder, 0, sideBorder, height), paint);
|
||||
// Bottom border
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - bottomBorder, width, bottomBorder), paint);
|
||||
|
||||
// Inner thin border (grey line separating image and card)
|
||||
const innerPaint = Skia.Paint();
|
||||
innerPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
innerPaint.setStrokeWidth(1);
|
||||
innerPaint.setStyle(1); // Stroke
|
||||
|
||||
canvas.drawRect(
|
||||
Skia.XYWHRect(
|
||||
sideBorder - 1,
|
||||
sideBorder - 1,
|
||||
width - 2 * sideBorder + 2,
|
||||
height - sideBorder - bottomBorder + 2
|
||||
),
|
||||
innerPaint
|
||||
);
|
||||
} else if (frameId === 'cinematic') {
|
||||
// Cinematic black bars on top and bottom
|
||||
paint.setColor(Skia.Color('#000000'));
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const barHeight = height * 0.12; // 12% cinematic letterbox height
|
||||
|
||||
// Top letterbox
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint);
|
||||
// Bottom letterbox
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as Location from 'expo-location';
|
||||
import { GPSInfo } from '../types';
|
||||
|
||||
export async function requestLocationPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
return status === 'granted';
|
||||
} catch (error) {
|
||||
console.error('Error requesting location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentGPS(): Promise<GPSInfo | null> {
|
||||
try {
|
||||
const hasPermission = await requestLocationPermissions();
|
||||
if (!hasPermission) return null;
|
||||
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
|
||||
const { latitude, longitude } = location.coords;
|
||||
let locality = 'UNKNOWN LOCATION';
|
||||
let country = '';
|
||||
|
||||
try {
|
||||
const geocode = await Location.reverseGeocodeAsync({ latitude, longitude });
|
||||
if (geocode && geocode.length > 0) {
|
||||
const address = geocode[0];
|
||||
// Combine city, region, street or subregion to form a retro looking place string
|
||||
locality = address.subregion || address.city || address.district || address.region || 'LOCAL REGION';
|
||||
locality = locality.toUpperCase();
|
||||
country = (address.country || '').toUpperCase();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Reverse geocoding failed, using coordinates only:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
locality,
|
||||
country,
|
||||
timestamp: location.timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching GPS info:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
|
||||
const absolute = Math.abs(val);
|
||||
const degrees = Math.floor(absolute);
|
||||
const minutes = Math.floor((absolute - degrees) * 60);
|
||||
const seconds = ((absolute - degrees - minutes / 60) * 3600).toFixed(2);
|
||||
const direction = type === 'lat' ? (val >= 0 ? 'N' : 'S') : (val >= 0 ? 'E' : 'W');
|
||||
|
||||
return `${degrees}° ${minutes}' ${seconds}" ${direction}`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Recipe } from '../types';
|
||||
import { DEFAULT_RECIPES } from './defaultRecipes';
|
||||
|
||||
const CUSTOM_RECIPES_KEY = '@camrecipe_pro:custom_recipes';
|
||||
|
||||
export async function getCustomRecipes(): Promise<Recipe[]> {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(CUSTOM_RECIPES_KEY);
|
||||
if (!data) return [];
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom recipes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllRecipes(): Promise<Recipe[]> {
|
||||
const custom = await getCustomRecipes();
|
||||
return [...DEFAULT_RECIPES, ...custom];
|
||||
}
|
||||
|
||||
export async function saveCustomRecipe(recipe: Omit<Recipe, 'id' | 'isCustom'>): Promise<Recipe> {
|
||||
const custom = await getCustomRecipes();
|
||||
const newRecipe: Recipe = {
|
||||
...recipe,
|
||||
id: `custom_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
const updated = [...custom, newRecipe];
|
||||
await AsyncStorage.setItem(CUSTOM_RECIPES_KEY, JSON.stringify(updated));
|
||||
return newRecipe;
|
||||
}
|
||||
|
||||
export async function deleteCustomRecipe(id: string): Promise<void> {
|
||||
const custom = await getCustomRecipes();
|
||||
const updated = custom.filter(r => r.id !== id);
|
||||
await AsyncStorage.setItem(CUSTOM_RECIPES_KEY, JSON.stringify(updated));
|
||||
}
|
||||
Reference in New Issue
Block a user