first commit
This commit is contained in:
@@ -1,20 +1,286 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SafeAreaView, View, StatusBar, Alert, ActivityIndicator, Text } from 'react-native';
|
||||
import { useCameraPermissions } from 'expo-camera';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
import Header from './src/components/Header';
|
||||
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 PreviewModal from './src/components/PreviewModal';
|
||||
|
||||
import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types';
|
||||
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
|
||||
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
|
||||
import { processAndExportPhoto } from './src/utils/exportEngine';
|
||||
|
||||
// @ts-ignore
|
||||
import './global.css';
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
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 [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
|
||||
const [useGeotag, setUseGeotag] = useState(true);
|
||||
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('recipes');
|
||||
|
||||
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
|
||||
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Hardware Permissions
|
||||
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||||
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
loadGPS();
|
||||
}, []);
|
||||
|
||||
const loadRecipes = async () => {
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
if (list.length > 0) {
|
||||
applyRecipe(list[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGPS = async () => {
|
||||
const info = await getCurrentGPS();
|
||||
if (info) {
|
||||
setGpsInfo(info);
|
||||
}
|
||||
};
|
||||
|
||||
const applyRecipe = (recipe: Recipe) => {
|
||||
setSelectedRecipe(recipe);
|
||||
setAdjustments(recipe.adjustments);
|
||||
setSelectedFrame(recipe.frameId || 'none');
|
||||
setUseGeotag(recipe.useGeotag);
|
||||
};
|
||||
|
||||
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
||||
setAdjustments((prev) => {
|
||||
const next = { ...prev, ...updates };
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateFrame = (frameId: FrameId) => {
|
||||
setSelectedFrame(frameId);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
frameId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGeotag = async (enabled: boolean) => {
|
||||
setUseGeotag(enabled);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
useGeotag: enabled,
|
||||
});
|
||||
}
|
||||
if (enabled && !gpsInfo) {
|
||||
const locationGranted = await requestLocationPermissions();
|
||||
if (locationGranted) {
|
||||
loadGPS();
|
||||
} else {
|
||||
Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.');
|
||||
setUseGeotag(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRecipe = async (name: string) => {
|
||||
if (!selectedRecipe) return;
|
||||
const newRecipe = await saveCustomRecipe({
|
||||
name,
|
||||
baseFilter: selectedRecipe.baseFilter,
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
});
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
applyRecipe(newRecipe);
|
||||
Alert.alert('Recipe Saved', `"${name}" has been created.`);
|
||||
};
|
||||
|
||||
const handleDeleteRecipe = async (id: string) => {
|
||||
await deleteCustomRecipe(id);
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
if (selectedRecipe?.id === id && list.length > 0) {
|
||||
applyRecipe(list[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: false,
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setLibraryImageUri(result.assets[0].uri);
|
||||
setMode('library');
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (mode === 'camera') {
|
||||
// For simulator & simplicity, if camera view isn't fully mocked, we use a placeholder or trigger camera picture.
|
||||
// In this setup, we simulate taking a photo or request library image if running in simulator.
|
||||
// Let's create a beautiful simulation that pulls a mock asset or lets you select one if in simulator.
|
||||
Alert.alert(
|
||||
'Capture Mode',
|
||||
'Camera capture requires native device hardware. For testing on emulators, please use the LIBRARY mode to load a photo and apply filters.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else {
|
||||
// Library mode capture/export: processes selected image with recipe, overlays, text
|
||||
if (!libraryImageUri || !selectedRecipe) {
|
||||
Alert.alert('Error', 'Please select an image from the library first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
const exportedUri = await processAndExportPhoto(
|
||||
libraryImageUri,
|
||||
{
|
||||
...selectedRecipe,
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
},
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
gpsInfo
|
||||
);
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
if (exportedUri) {
|
||||
setLastPhotoUri(exportedUri);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing image.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.tsx to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
<SafeAreaView className="flex-1 bg-carbon">
|
||||
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
|
||||
|
||||
{/* Top Header */}
|
||||
<Header mode={mode} setMode={setMode} title="CamRecipe Pro" />
|
||||
|
||||
{/* Main Viewfinder Frame */}
|
||||
<View className="flex-1 justify-center items-center px-4 py-2 bg-carbon">
|
||||
<Viewfinder
|
||||
mode={mode}
|
||||
recipe={
|
||||
selectedRecipe || {
|
||||
id: 'temp',
|
||||
name: 'Custom',
|
||||
baseFilter: 'none',
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
}
|
||||
}
|
||||
selectedFrame={selectedFrame}
|
||||
useGeotag={useGeotag}
|
||||
gpsInfo={gpsInfo}
|
||||
libraryImageUri={libraryImageUri}
|
||||
cameraPermissionGranted={!!cameraPermission?.granted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Selector Tabs */}
|
||||
<TabSelector activeTab={activeTab} setActiveTab={setActiveTab} />
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Bottom Camera Buttons */}
|
||||
<CameraControls
|
||||
lastPhotoUri={lastPhotoUri}
|
||||
onCapture={handleCapture}
|
||||
onPickImage={handlePickImage}
|
||||
onOpenPreview={() => setPreviewVisible(true)}
|
||||
isLibraryMode={mode === 'library' && !libraryImageUri}
|
||||
/>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<PreviewModal
|
||||
visible={previewVisible}
|
||||
onClose={() => setPreviewVisible(false)}
|
||||
photoUri={lastPhotoUri}
|
||||
recipeName={selectedRecipe?.name || 'Custom'}
|
||||
/>
|
||||
|
||||
{/* Global Processing Loader Overlay */}
|
||||
{isProcessing && (
|
||||
<View className="absolute inset-0 bg-black/75 flex items-center justify-center space-y-4">
|
||||
<ActivityIndicator size="large" color="#f59e0b" />
|
||||
<Text className="text-amber-500 font-mono text-sm tracking-widest font-bold">
|
||||
RENDERING PHOTO...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Kế Hoạch Triển Khai Chi Tiết (Implementation Plan) - CamRecipe Pro Mobile
|
||||
|
||||
Dựa trên tài liệu **PROJECT_OVERVIEW.md**, kế hoạch triển khai chi tiết cho dự án **CamRecipe Pro** trên nền tảng di động iOS & Android sử dụng **React Native (Expo)** được thiết lập cụ thể dưới đây.
|
||||
|
||||
---
|
||||
|
||||
## 1. Thiết Lập Môi Trường & Cấu Hình Nền Móng (Mốc 1: Tuần 1)
|
||||
Mục tiêu là thiết lập khung ứng dụng (UI Shell), cài đặt và cấp quyền cho các module phần cứng cơ bản.
|
||||
|
||||
### 1.1 Khởi Tạo Dự Án Expo
|
||||
* **Lệnh thực hiện:** Khởi tạo dự án Expo mới với template TypeScript:
|
||||
```bash
|
||||
npx create-expo-app@latest CamRecipePro --template blank-typescript
|
||||
```
|
||||
* **Cài đặt thư viện giao diện:**
|
||||
* `NativeWind` (v4 trở lên hỗ trợ Expo tốt nhất) và `tailwindcss`.
|
||||
* `lucide-react-native` để hiển thị bộ icons SVG từ bản web.
|
||||
* `expo-font` để nạp phông chữ Retro `Courier Prime.ttf`.
|
||||
|
||||
### 1.2 Cài Đặt Thư Viện Phần Cứng & Quyền Truy Cập (Permissions)
|
||||
Cài đặt các gói Expo SDK để làm việc với phần cứng:
|
||||
```bash
|
||||
npx expo install expo-camera expo-location expo-media-library expo-haptics expo-image-picker @react-native-async-storage/async-storage
|
||||
```
|
||||
Cấu hình các quyền trong tệp `app.json` (hoặc `app.config.js`):
|
||||
* **Camera:** `plugins: [["expo-camera", { "cameraPermission": "Cho phép ứng dụng sử dụng Camera để chụp ảnh với bộ lọc màu." }]]`
|
||||
* **Vị trí (GPS):** `plugins: [["expo-location", { "locationAlwaysPermission": "Cho phép ứng dụng lấy tọa độ để chèn watermark GPS lên hình ảnh." }]]`
|
||||
* **Thư viện ảnh:** `plugins: [["expo-media-library", { "photosPermission": "Cho phép ứng dụng lưu ảnh và truy xuất ảnh để áp bộ lọc." }]]`
|
||||
|
||||
### 1.3 Thiết Kế UI Shell & Navigation
|
||||
* Khung bao quanh: Dùng `<SafeAreaView>` kết hợp cấu hình `StatusBar` ẩn/hiện hoặc tối để giả lập Notch/Dynamic Island.
|
||||
* Bố cục chính: Cấu trúc dọc (Vertical Layout) theo khung tỉ lệ 9:19.5 (như wireframe).
|
||||
* Header: Chuyển đổi giữa chế độ `Camera` và `Library` sử dụng `<TouchableOpacity>`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phát Triển Engine Xử Lý Hình Ảnh GPU Thời Gian Thực (Mốc 2: Tuần 2)
|
||||
Mục tiêu là tích hợp luồng camera native với canvas vẽ của Skia để áp dụng các bộ lọc thời gian thực.
|
||||
|
||||
### 2.1 Cài Đặt Thư Viện Đồ Họa & Camera Nâng Cao
|
||||
```bash
|
||||
npx expo install @shopify/react-native-skia react-native-vision-camera
|
||||
```
|
||||
*Lưu ý: Đối với `react-native-vision-camera`, cần cấu hình Babel plugin và cài đặt Expo Config Plugin tương thích.*
|
||||
|
||||
### 2.2 Xây Dựng Viewfinder Pipeline
|
||||
* **Tích hợp Camera Frame làm Texture:** Kết nối `react-native-vision-camera` với `React Native Skia` thông qua cơ chế ghi nhận frame (Frame Processor) hoặc sử dụng các bindings sẵn có để truyền luồng video trực tiếp làm Texture cho `<Canvas>`.
|
||||
* **Bộ lọc màu (Color Matrix Filter):**
|
||||
* Định nghĩa cấu trúc ma trận màu $4 \times 5$ (hoặc $5 \times 5$ của Skia) cho các bộ lọc kinh điển (Classic Negative, Provia, Velvia).
|
||||
* Quy đổi nhiệt độ màu Kelvin ($2500K - 10000K$) sang các hệ số RGB và đưa vào ma trận màu để tinh chỉnh White Balance mà không làm lệch Tint.
|
||||
* Sử dụng component `<ColorMatrix>` của Skia để xử lý hiệu năng cao trên GPU.
|
||||
* **Đường Cong Ánh Sáng (Highlight & Shadow Contrast):**
|
||||
* Sử dụng `<ComponentTransfer>` của Skia để cấu hình mảng bảng màu `tableValues` riêng biệt cho Highlight và Shadow.
|
||||
* **Tạo Hạt Film Monochrome (Monochrome Grain):**
|
||||
* Sử dụng `<Turbulence>` tạo cấu hình nhiễu hạt ngẫu nhiên dựa trên các tham số tần số (`baseFrequency`) và hạt giống (`seed`).
|
||||
* Áp dụng `<ColorMatrix>` xám hóa (Grayscale) lên lớp nhiễu này và trộn vào luồng camera gốc.
|
||||
* **Khử Nhiễu (Denoise) & Độ Rõ Nét (Clarity):**
|
||||
* *Denoise:* Áp dụng `Skia.ImageFilter.MakeBlur` với tham số `sigma` siêu nhỏ từ $0.3$ đến $0.5$ để mịn da/ảnh nhẹ nhàng.
|
||||
* *Clarity:* Áp dụng `Skia.ImageFilter.MakeMatrixConvolution` cho độ rõ nét dương (tăng độ tương phản cạnh sắc) hoặc kết hợp hiệu ứng Blur + BlendMode Screen cho độ rõ nét âm (tạo sương mù mượt mà).
|
||||
|
||||
---
|
||||
|
||||
## 3. Phát Triển Quản Lý Recipes & Bộ Nhớ Tạm (Mốc 3: Tuần 3)
|
||||
Mục tiêu là quản lý các công thức màu sắc cá nhân hóa và hiển thị danh sách trực quan.
|
||||
|
||||
### 3.1 Cài Đặt AsyncStorage & Định Dạng JSON
|
||||
Mỗi Recipe sẽ có cấu trúc JSON như sau:
|
||||
```json
|
||||
{
|
||||
"id": "uuid-v4-string",
|
||||
"name": "Tên Công Thức (Hỗ trợ Tiếng Việt & Emoji)",
|
||||
"matrix": [...],
|
||||
"wbKelvin": 5500,
|
||||
"contrastHighlight": 1.0,
|
||||
"contrastShadow": 1.0,
|
||||
"denoiseSigma": 0.4,
|
||||
"clarity": 0,
|
||||
"grainFrequency": 0.05,
|
||||
"framePath": "optional-frame-id"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Giao Diện Danh Sách Recipes 2 Cột
|
||||
* Sử dụng `<FlatList numColumns={2}>` để tối ưu hóa hiệu năng render danh sách.
|
||||
* Cột bên trái hiển thị hình ảnh mẫu nhỏ (thumbnail) đã được nạp động hoặc áp sẵn bộ lọc tương ứng để người dùng xem trước.
|
||||
* Chạm vào thẻ công thức để kích hoạt ngay (`applyRecipe()`) mà không cần nút áp dụng phụ, tích hợp rung phản hồi xúc giác nhẹ (`expo-haptics`).
|
||||
* Cửa sổ Modal lưu công thức (`#save-recipe-modal`) hiển thị dưới dạng Bottom Sheet để người dùng đặt tên và lưu lại vào AsyncStorage.
|
||||
|
||||
---
|
||||
|
||||
## 4. Chụp Ảnh Độ Phân Giải Cao & Xuất File (Mốc 4: Tuần 4)
|
||||
Mục tiêu là lưu giữ khoảnh khắc ở chất lượng cao nhất, kết xuất đầy đủ hiệu ứng nghệ thuật và siêu dữ liệu.
|
||||
|
||||
### 4.1 Quy Trình Xử Lý Khi Nhấn Shutter
|
||||
1. **Chụp Ảnh Gốc:** Gọi hàm chụp ảnh tĩnh độ phân giải cao từ `Vision Camera` hoặc `Expo Camera`.
|
||||
2. **Canvas Ẩn (Offscreen Canvas):** Khởi tạo một đối tượng Skia Canvas ẩn có độ phân giải bằng đúng độ phân giải ảnh gốc.
|
||||
3. **Áp Bộ Lọc Chất Lượng Cao:** Thực thi lại toàn bộ chuỗi bộ lọc đồ họa (WB, Exposure, Curves, Grain, Denoise, Clarity) trên canvas ẩn để giữ nguyên chất lượng chi tiết.
|
||||
4. **Vẽ Khung & Watermark GPS:**
|
||||
* Tải font chữ TrueType `Courier Prime.ttf` thông qua `Skia.Typeface.MakeFromFile` hoặc `useFont` từ Expo.
|
||||
* Lấy tọa độ hiện tại từ `expo-location` (hoặc phân tích EXIF gốc của ảnh nếu ở chế độ Library) để render chuỗi text (📍 Địa điểm, Tọa độ, Thông số ISO/Shutter Speed).
|
||||
* Vẽ đè hình ảnh PNG Frame trang trí (nếu chọn) lên trên cùng.
|
||||
5. **Đóng Gói & Xuất Bản:**
|
||||
* Xuất canvas thành mảng byte JPEG: `image.encodeToBytes(ImageFormat.JPEG, 95)`.
|
||||
* Sử dụng `expo-media-library` để tạo tệp tin ảnh mới trong thư viện thiết bị.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kế Hoạch Kiểm Thử & Kiểm Soát Chất Lượng (Testing & QA)
|
||||
* **Kiểm thử Giả lập (Emulators):**
|
||||
* Giả lập GPS thông qua các công cụ của Xcode Simulator (Location menu) hoặc Android Emulator (Extended Controls).
|
||||
* Sử dụng luồng dữ liệu camera ảo (Virtual Camera) để kiểm thử bộ lọc Skia Shader mà không cần thiết bị thật.
|
||||
* **Kiểm thử Thiết bị Thật:**
|
||||
* Thử nghiệm nhanh bằng ứng dụng Expo Go qua mã QR.
|
||||
* Tạo bản build phát triển (`eas build --profile development`) để test hiệu năng bộ nhớ, độ mượt Haptic Feedback, tốc độ render ảnh độ phân giải cao và tính ổn định của luồng xử lý GPU.
|
||||
* Phân phối thử nghiệm qua TestFlight (iOS) và Google Play Console Internal (Android).
|
||||
@@ -0,0 +1,185 @@
|
||||
|
||||
# [SYSTEM PROMPT / WORKFLOW] Kế Hoạch Chuyển Đổi Dự Án CamRecipe Pro Lên iOS & Android
|
||||
|
||||
> **VAI TRÒ CỦA AI:** Bạn là một Chuyên gia Phát triển Di động Cấp cao (Senior Mobile Engineer) chuyên về React Native (Expo) và Đồ họa Máy tính (GPU Image Processing). Bạn có nhiệm vụ đọc hiểu cấu trúc dưới đây và thực thi/phát triển mã nguồn theo đúng đặc tả kỹ thuật, không tự ý thay đổi stack công nghệ hoặc lược bỏ tính năng trừ khi có yêu cầu.
|
||||
|
||||
---
|
||||
|
||||
## 1. TỔNG QUAN DỰ ÁN (PROJECT OVERVIEW)
|
||||
Tài liệu này phác thảo toàn bộ cấu trúc thư mục, stack công nghệ, engine xử lý hình ảnh, đặc tả thiết kế giao diện, kế hoạch debug và kiểm thử để chuyển đổi ứng dụng **CamRecipe Pro** (từ phiên bản Web nguyên mẫu hiện tại) thành một ứng dụng di động native hoàn chỉnh chạy mượt mà trên cả hai nền tảng iOS và Android.
|
||||
|
||||
---
|
||||
|
||||
## 2. STACK CÔNG NGHỆ & THƯ VIỆN SỬ DỤNG (TECHNOLOGY STACK)
|
||||
Để tối ưu hóa hiệu năng (đạt mức 60 FPS) và rút ngắn thời gian phát triển, dự án sẽ áp dụng triệt để nguyên tắc tái sử dụng các thư viện chuẩn hóa (industry-standard):
|
||||
|
||||
| Thành phần | Công nghệ / Thư viện | Vai trò & Giải pháp tái sử dụng |
|
||||
| :--- | :--- | :--- |
|
||||
| **Framework chính** | `React Native` (Expo SDK mới nhất) | Nền tảng phát triển đa nền tảng (Cross-platform). |
|
||||
| **Truy cập phần cứng** | `expo-camera`, `expo-location`, `expo-media-library` | Tái sử dụng API có sẵn của Expo để truy cập Camera, Định vị (GPS) và Thư viện ảnh mà không cần viết Native Bridge. |
|
||||
| **Engine đồ họa** | `@shopify/react-native-skia` + `react-native-vision-camera` | Xử lý Viewfinder & Render thời gian thực. Tận dụng bộ lọc ma trận màu (`Skia.ColorFilter.Matrix`), bộ lọc tích chập (`feConvolveMatrix`) và bộ lọc nhòe (`feGaussianBlur`) trực tiếp trên GPU. |
|
||||
| **Tạo kiểu giao diện** | `NativeWind` (Tailwind CSS cho React Native) | Ánh xạ 1-1 toàn bộ class Tailwind CSS từ bản Web sang Native Components. |
|
||||
| **Hệ thống Icons** | `lucide-react-native` | Sử dụng trực tiếp bộ icon SVG chất lượng cao từ bản Web. |
|
||||
| **Thanh trượt thông số** | `@react-native-community/slider` | Hỗ trợ vuốt chạm vật lý và Phản hồi xúc giác (Haptic Feedback) native. |
|
||||
| **Bộ nhớ tạm (Local)** | `@react-native-async-storage/async-storage` | Lưu trữ cấu trúc JSON Recipes cá nhân xuống thiết bị. |
|
||||
| **Trích xuất Metadata**| `exif-reader` | Giải mã dữ liệu EXIF & GPS từ ảnh trong thư viện. |
|
||||
|
||||
---
|
||||
|
||||
## 3. ĐẶC TẢ THIẾT KẾ GIAO DIỆN NGƯỜI DÙNG (UI/UX SPECIFICATION)
|
||||
|
||||
### A. Triết lý Thiết kế (Design System)
|
||||
* **Phong cách:** Retro-Modern tối giản, cao cấp.
|
||||
* **Bảng màu chủ đạo:**
|
||||
* Đen carbon: `#08080a`
|
||||
* Xám titan: `#1f1f23`
|
||||
* Vàng hổ phách: `#f59e0b`
|
||||
|
||||
### B. Sơ đồ bố cục màn hình (Wireframe Layout - Tỉ lệ 9:19.5)
|
||||
|
||||
```text
|
||||
+-------------------------------------------------+
|
||||
| [X] 9:41 StatusBar [=] | -> Giả lập vùng khuyết notch/Dynamic Island
|
||||
+-------------------------------------------------+
|
||||
| (•) X-TRANS PRO EMULATOR [CAMERA] [LIBRARY] | -> Header: Chế độ chụp & tải ảnh thư viện
|
||||
+-------------------------------------------------+
|
||||
| +-------------------------------------------+ |
|
||||
| | [CLASSIC NEG] [DR400] [SIM LIVE OK] | | -> Viewfinder: Kính ngắm trực tiếp (Skia Canvas)
|
||||
| | | | - Lớp 1: Video/Ảnh mẫu gốc
|
||||
| | (Grid) | | - Lớp 2: Skia Shader (Màu + Khử nhiễu + Hạt)
|
||||
| | | | - Lớp 3: PNG Frame (Trong suốt đè lên)
|
||||
| | | | - Lớp 4: GPS Watermark (Chữ Courier Prime)
|
||||
| | [SS 1/125 f/2.8 +0.7 EV] [ISO 3200] | |
|
||||
| | | |
|
||||
| | 📍 PHỐ CỔ HỘI AN | |
|
||||
| | 15.9000° N, 108.1500° E | |
|
||||
| +-------------------------------------------+ |
|
||||
+-------------------------------------------------+
|
||||
| [ Recipes ] [ Thông số IQ ] [ Màu & WB ]... | -> Tab Selectors: Chuyển đổi các bảng thông số
|
||||
+-------------------------------------------------+
|
||||
| [Dynamic Panels - Chiều cao cố định 250dp] | -> Khu vực nội dung Tab đang chọn
|
||||
| - Tab Recipes: Danh sách 2 cột kèm ảnh mẫu |
|
||||
| - Tab IQ: Thước phơi sáng, Highlight/Shadow |
|
||||
| - Tab Màu & WB: Kelvin, Chrome Blue, Grain... |
|
||||
+-------------------------------------------------+
|
||||
| [x] Đính kèm vị trí (GPS) [Sửa vị trí] | -> Thanh tiện ích nhanh (Quick Utility Bar)
|
||||
+-------------------------------------------------+
|
||||
| [🎨 Thư viện] (( 🔘 )) [📷 Preview] | -> Camera Controls: Nút Chụp & Phím tắt nhanh
|
||||
+-------------------------------------------------+
|
||||
| === | -> Home Indicator ảo trên thiết bị di động
|
||||
+-------------------------------------------------+
|
||||
|
||||
```
|
||||
|
||||
### C. Bản Đồ Ánh Xạ Linh Kiện (Web to React Native Mapping)
|
||||
|
||||
| Giao diện Web (HTML / Tailwind CSS) | Thành phần di động (React Native / NativeWind) | Vai trò & Trải nghiệm người dùng |
|
||||
| --- | --- | --- |
|
||||
| Main Wrapper (`max-w-md bg-black border-zinc-800 rounded-[48px]`) | `<SafeAreaView className="flex-1 bg-black">` | Khung bao bọc toàn màn hình, tự động tránh Notch tai thỏ và bo cong viền. |
|
||||
| Mode Toggles (`#btn-mode-camera`, `#btn-mode-library`) | `<View className="flex-row">` + `<TouchableOpacity>` | Chuyển đổi tức thì nguồn cấp giữa Camera vật lý và Thư viện ảnh. |
|
||||
| Viewfinder Wrapper (`aspect-[3/4] overflow-hidden`) | `<View style={{ aspectRatio: 3/4 }} className="relative overflow-hidden">` | Kính ngắm tỷ lệ nhiếp ảnh hoài cổ 3:4. |
|
||||
| Camera Live Stream (`<video id="webcam">`) | `<Camera>` từ `react-native-vision-camera` | Kết nối camera vật lý, hỗ trợ lấy nét và đảo chiều camera. |
|
||||
| GPU Sim Processing (`filter: url(#fuji-live-filter)`) | `<Canvas>` + `<Shader>` từ `@shopify/react-native-skia` | Nhận texture camera gốc, áp công thức màu GLSL/Skia Shaders (Exposure, WB, Grain) trực tiếp. |
|
||||
| EXIF Geotag Watermark (`#geotag-watermark`) | `<Text style={{ fontFamily: 'Courier Prime' }} className="absolute bottom-4 left-4 text-amber-500">` | Đóng mốc địa danh và tọa độ địa lý màu hổ phách phong cách retro. |
|
||||
| Tab Selectors (`#tab-recipes`, `#tab-adjust`) | `<FlatList horizontal={true}>` hoặc dãy nút bấm `<TouchableOpacity>` | Cuộn ngang mượt mà để chuyển đổi nhanh các nhóm thông số. |
|
||||
| Thước kéo Ruler (`input[type="range"]`) | `@react-native-community/slider` | Thanh trượt mịn kết hợp phản hồi rung Haptic khi kéo. |
|
||||
| Thư viện Recipes 2 cột (`#recipes-list-container`) | `<FlatList numColumns={2}>` | Bảng hiển thị các công thức màu. Cột trái là ảnh mẫu nạp động, cột phải là thông tin chi tiết. Chạm để kích hoạt. |
|
||||
| Cửa sổ lưu (`#save-recipe-modal`) | `<Modal transparent={true} animationType="slide">` | Cửa sổ nhỏ (bottom sheet) trượt từ dưới lên cho phép nhập tên Tiếng Việt để lưu công thức. |
|
||||
| Phím chụp Shutter (`button active:scale-90`) | `<TouchableOpacity className="w-16 h-16 rounded-full bg-white border-4 border-black">` | Nút chụp giả lập vật lý, có hiệu ứng nén nhẹ (scale down) khi nhấn, kích hoạt chuỗi render ảnh độ phân giải cao. |
|
||||
|
||||
---
|
||||
|
||||
## 4. KIẾN TRÚC CORE IMAGE PIPELINE ENGINE (GPU REAL-TIME)
|
||||
|
||||
Hệ thống xử lý hình ảnh được chia làm hai luồng hoạt động song song sử dụng React Native Skia:
|
||||
|
||||
### A. Luồng Kính Ngắm Live Viewfinder (Real-time GPU)
|
||||
|
||||
1. **Nhận Luồng Camera:** Luồng ghi trực tiếp (Camera Frame) được nạp làm texture cho Skia Canvas thông qua Native Bridge tích hợp sẵn của `react-native-vision-camera`.
|
||||
2. **Bộ lọc Màu Ma Trận (Color Filter Matrix):** Tái sử dụng bộ lọc màu tích hợp của Skia nhằm tránh việc tự viết mã GLSL Shader phức tạp:
|
||||
```javascript
|
||||
// Áp dụng trực tiếp ma trận màu sắc có sẵn mà không cần biên dịch shader thủ công
|
||||
const colorFilter = Skia.ColorFilter.Matrix(finalMatrix);
|
||||
|
||||
```
|
||||
|
||||
|
||||
3. **Đồ thị Curves (Highlight/Shadow):** Thực hiện bằng bộ lọc `Skia.ColorFilter.ComponentTransfer` có sẵn, nạp các mảng giá trị `tableValues` đã tính toán trước để thay đổi cường độ điểm ảnh mà không cần can thiệp bằng CPU.
|
||||
4. **Hạt Film Monochrome (Monochrome Grain):**
|
||||
* Tận dụng bộ sinh nhiễu có sẵn: `Skia.Shader.MakeTurbulence(baseFrequencyX, baseFrequencyY, octaves, seed)` tạo hạt nhiễu trực tiếp trên GPU.
|
||||
* Chuyển đổi màu hạt sang xám đơn sắc bằng bộ lọc `ColorFilter.Matrix` tích hợp.
|
||||
|
||||
|
||||
5. **Clarity & Denoise (Khử Nhiễu):**
|
||||
* *Denoise (Khử nhiễu):* Sử dụng bộ lọc `Skia.ImageFilter.MakeBlur(sigmaX, sigmaY, tileMode, input)` với giá trị `sigma` siêu nhỏ ($0.3 - 0.5$) để làm mịn các hạt nhiễu thô.
|
||||
* *Clarity (Độ rõ nét):* Khi giá trị dương, sử dụng bộ lọc tích chập `Skia.ImageFilter.MakeMatrixConvolution` tăng độ sắc nét vùng biên. Khi giá trị âm, sử dụng bộ lọc Bloom kết hợp chế độ hòa trộn `Skia.BlendMode.Screen`.
|
||||
|
||||
|
||||
|
||||
### B. Cơ chế xuất ảnh chất lượng cao (High-Quality Export)
|
||||
|
||||
Khi người dùng bấm nút Shutter (Chụp):
|
||||
|
||||
1. Gọi API chụp ảnh tĩnh độ phân giải cao của camera vật lý.
|
||||
2. Nạp ảnh tĩnh vào một `Canvas` ẩn của Skia.
|
||||
3. Áp dụng chuỗi filter tương tự Viewfinder (WB, Exp, Highlight/Shadow, Grain, Denoise, Clarity).
|
||||
4. Vẽ đè file PNG Frame (Khung ảnh nghệ thuật) và vẽ text chứa tọa độ GPS (Sử dụng phông chữ TrueType `Courier Prime.ttf` nạp qua `Skia.Typeface`).
|
||||
5. Kết xuất ảnh sang định dạng JPEG: `image.encodeToBytes(ImageFormat.JPEG, 95)`.
|
||||
6. Lưu trực tiếp vào thư viện thiết bị bằng `expo-media-library`.
|
||||
|
||||
---
|
||||
|
||||
## 5. ĐÁNH GIÁ VÀ GIẢI PHÁP CHO CÁC YÊU CẦU TỪ NGƯỜI DÙNG
|
||||
|
||||
| Yêu cầu từ người dùng | Giải pháp tối ưu trên Mobile bằng thư viện có sẵn |
|
||||
| --- | --- |
|
||||
| **Bỏ nút áp dụng, chạm thẻ kích hoạt ngay** | Sử dụng `<TouchableOpacity>` trong danh sách `FlatList` 2 cột. Kích hoạt hàm `applyRecipe()` ngay khi chạm đồng thời kích hoạt rung nhẹ (`expo-haptics`). |
|
||||
| **Nhiệt độ màu WB: 2500K (Lạnh) -> 10000K (Ấm)** | Sử dụng thuật toán quy đổi Kelvin sang ma trận màu sắc (RGB), nạp trực tiếp vào component `<ColorMatrix>` tích hợp sẵn của Skia. |
|
||||
| **Khử nhiễu (Denoise) giá trị âm không làm mờ tịt** | Sử dụng `Skia.ImageFilter.MakeBlur` với mức giới hạn `sigma` cực nhỏ ($0.3 - 0.5\text{px}$) kết hợp bù sáng nhẹ để triệt tiêu nhiễu hạt mịn mà không làm nhòe chi tiết ảnh. |
|
||||
| **Chỉnh Đậm màu (Color) không bị lệch Tint** | Sử dụng thuộc tính bão hòa (saturation) thuần túy của bộ lọc `<ColorMatrix values={...} />` cấu hình độc lập cho từng hệ màu (Provia/Velvia) để tăng/giảm sắc độ mà không ảnh hưởng tới cân bằng trắng (Tint). |
|
||||
| **Thêm thuộc tính Clarity (-10 đến +10)** | Sử dụng `Skia.ImageFilter.MakeMatrixConvolution` (tăng độ sắc nét biên khi dương) và kết hợp `MakeBlur` + `Skia.BlendMode.Screen` (tạo hiệu ứng sương mờ mượt mà khi âm). |
|
||||
| **Highlight & Shadow Contrast độc lập** | Sử dụng component điều chế `<ComponentTransfer>` của Skia, nạp các dải bảng màu `tableValues` để tách biệt tương phản vùng tối và vùng sáng. |
|
||||
| **Hỗ trợ tên có ký tự đặc biệt, Tiếng Việt, Emoji** | Quản lý danh sách Recipes dưới dạng mảng JSON lưu trong `AsyncStorage`. Định danh mỗi công thức bằng UUID (`uuid`) để tối ưu hóa quá trình render của `FlatList` không bị lỗi font hoặc trùng lặp định danh. |
|
||||
| **Hạt đơn sắc (Monochrome Grain)** | Kết hợp bộ sinh nhiễu `<Turbulence>` của Skia cùng bộ lọc xám của `<ColorMatrix>` để sinh cấu trúc hạt xám hoài cổ trực tiếp trên GPU. |
|
||||
|
||||
---
|
||||
|
||||
## 6. KẾ HOẠCH PHÁT TRIỂN & KIỂM THỬ (MILESTONES & TESTING)
|
||||
|
||||
### A. Kế hoạch phát triển (4 Tuần)
|
||||
|
||||
* **Tuần 1: Thiết lập nền móng (Shell)**
|
||||
* Khởi tạo dự án Expo SDK mới nhất. Cấu hình cấp quyền truy cập Camera, Thư viện ảnh, GPS (`Location`).
|
||||
* Xây dựng giao diện Khung (UI Shell) bằng `NativeWind` và `lucide-react-native`.
|
||||
|
||||
|
||||
* **Tuần 2: Core Image Engine (Viewfinder)**
|
||||
* Tích hợp `React Native Skia` và `react-native-vision-camera`.
|
||||
* Thiết lập luồng xử lý ảnh Real-time trên GPU (Color Matrix, WB, Highlight/Shadow, Denoise, Grain) hiển thị trực tiếp lên Viewfinder.
|
||||
|
||||
|
||||
* **Tuần 3: Quản lý Recipe & Bộ nhớ tạm**
|
||||
* Tích hợp `AsyncStorage` quản lý lưu/trích xuất công thức màu dưới dạng JSON.
|
||||
* Xây dựng màn hình danh sách Recipes 2 cột, hiển thị Thumbnail nạp động đã áp bộ lọc tương ứng.
|
||||
|
||||
|
||||
* **Tuần 4: Chụp ảnh độ phân giải cao & Đóng gói**
|
||||
* Xây dựng mô-đun chụp ảnh tĩnh, áp bộ lọc chất lượng cao, đè PNG Frame và ghi đè Watermark GPS bằng font `Courier Prime`.
|
||||
* Xuất bản thử nghiệm qua TestFlight (iOS) và Google Play Console Internal Testing (Android).
|
||||
|
||||
|
||||
|
||||
### B. Phương án Kiểm thử (Testing Plan)
|
||||
|
||||
* **Trên Trình giả lập (Emulators):**
|
||||
* *iOS Simulator:* Kích hoạt "Simulated Camera" (vòng lặp video mẫu) để kiểm tra Skia Shader. Giả lập GPS qua `Features -> Location`.
|
||||
* *Android Emulator:* Liên kết webcam máy tính làm camera đầu vào, tùy chỉnh tọa độ GPS trong Settings nâng cao của máy ảo.
|
||||
|
||||
|
||||
* **Trên Thiết bị thật (Khuyến nghị cao nhất):**
|
||||
* *Giai đoạn Alpha (Expo Go):* Quét mã QR nội bộ qua mạng WiFi để kiểm tra tốc độ phản hồi kéo thanh trượt, độ nhạy của bộ chọn công thức 2 cột, và cảm nhận phản hồi rung (`Haptics`).
|
||||
* *Giai đoạn Beta (Development Builds):* Tạo build native bằng lệnh `eas build --profile development` để kiểm tra toàn diện tốc độ ghi đè tệp tin của `Media Library` và hiệu năng kết xuất ảnh gốc độ phân giải cao ($12\text{MP} - 48\text{MP}$) trực tiếp trên thiết bị thực tế.
|
||||
|
||||
|
||||
|
||||
```
|
||||
@@ -20,6 +20,33 @@
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-asset",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow CamRecipe Pro to access your camera to take photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow CamRecipe Pro to geotag your photos with location watermark."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
plugins: [
|
||||
"react-native-reanimated/plugin",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,6 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: "./global.css" });
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
Generated
+1696
-39
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -3,10 +3,24 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "5.2.0",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"expo": "~57.0.6",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.2",
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-haptics": "~57.0.1",
|
||||
"expo-image-picker": "~57.0.4",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"lucide-react-native": "^1.24.0",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.86.0"
|
||||
"react-native": "0.86.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./App.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
carbon: "#08080a",
|
||||
titan: "#1f1f23",
|
||||
amber: {
|
||||
500: "#f59e0b",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user