fix: sửa export/gps/UI theo FIX_PLAN + chụp ảnh camera thật (takePhoto)
- export: encodeToBase64 thay SkData.toString, dùng expo-media-library/legacy - GPS ảnh gốc: resolver EXIF -> MediaLibrary -> device (ACCESS_MEDIA_LOCATION), tag nguồn watermark - contrast vào color matrix (có hiệu lực mono), ẩn highlight/shadow, denoise/clarity ghi chú export-only - init session tuần tự, tách setState lồng nhau, share file bằng expo-sharing, DONE lưu gallery - Phase 4A: chụp camera thật qua vision-camera takePhoto rồi export filter
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useCameraPermissions } from 'expo-camera';
|
||||
import { useCameraPermission, type Camera } from 'react-native-vision-camera';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
@@ -16,7 +16,8 @@ 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, reverseGeocode } from './src/utils/locationUtils';
|
||||
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
|
||||
import { resolvePhotoGPS } from './src/utils/photoGps';
|
||||
import { processAndExportPhoto } from './src/utils/exportEngine';
|
||||
|
||||
// @ts-ignore
|
||||
@@ -51,15 +52,21 @@ export default function App() {
|
||||
|
||||
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
|
||||
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
|
||||
const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Hardware Permissions (expo-camera)
|
||||
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||||
// Hardware Permissions (vision-camera)
|
||||
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
|
||||
const cameraRef = useRef<Camera>(null);
|
||||
|
||||
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
|
||||
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
restoreSession();
|
||||
// B5: run a single sequenced init — restore the session first, then fall
|
||||
// back to the first recipe only when no recipe was restored.
|
||||
initSession();
|
||||
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
|
||||
}, []);
|
||||
|
||||
@@ -86,13 +93,32 @@ export default function App() {
|
||||
return () => subscription.remove();
|
||||
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]);
|
||||
|
||||
const restoreSession = async () => {
|
||||
const initSession = async () => {
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
|
||||
let session: any = null;
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(SESSION_KEY);
|
||||
if (!saved) return;
|
||||
const session = JSON.parse(saved);
|
||||
if (session.libraryImageUri) {
|
||||
setLibraryImageUri(session.libraryImageUri);
|
||||
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
||||
if (raw) session = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.warn('Failed to read session:', e);
|
||||
}
|
||||
|
||||
let restoredRecipe: Recipe | null = null;
|
||||
if (session) {
|
||||
// B12: the restored library image may no longer exist on disk
|
||||
let imageUri: string | null = session.libraryImageUri || null;
|
||||
if (imageUri) {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(imageUri);
|
||||
if (!info.exists) imageUri = null;
|
||||
} catch {
|
||||
imageUri = null;
|
||||
}
|
||||
}
|
||||
if (imageUri) {
|
||||
setLibraryImageUri(imageUri);
|
||||
setMode('library');
|
||||
} else {
|
||||
setMode(session.mode || 'camera');
|
||||
@@ -102,21 +128,13 @@ export default function App() {
|
||||
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
|
||||
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
|
||||
if (session.selectedRecipeId) {
|
||||
const allRecipes = await getAllRecipes();
|
||||
const found = allRecipes.find((r: Recipe) => r.id === session.selectedRecipeId);
|
||||
if (found) {
|
||||
setSelectedRecipe(found);
|
||||
}
|
||||
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore session:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecipes = async () => {
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
if (list.length > 0) {
|
||||
if (restoredRecipe) {
|
||||
applyRecipe(restoredRecipe);
|
||||
} else if (list.length > 0) {
|
||||
applyRecipe(list[0]);
|
||||
}
|
||||
};
|
||||
@@ -124,7 +142,7 @@ export default function App() {
|
||||
const loadGPS = async () => {
|
||||
const info = await getCurrentGPS();
|
||||
if (info) {
|
||||
setGpsInfo(info);
|
||||
setGpsInfo({ ...info, source: 'device' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,16 +154,15 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
||||
setAdjustments((prev) => {
|
||||
const next = { ...prev, ...updates };
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// B8: never call a setState inside another setState's updater.
|
||||
const next = { ...adjustments, ...updates };
|
||||
setAdjustments(next);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateFrame = (frameId: FrameId) => {
|
||||
@@ -202,6 +219,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
if (isProcessing) return;
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: false,
|
||||
@@ -213,130 +231,129 @@ export default function App() {
|
||||
const asset = result.assets[0];
|
||||
setLibraryImageUri(asset.uri);
|
||||
setMode('library');
|
||||
// Clear old GPS — will be replaced if photo has location data
|
||||
// Clear old GPS — will be replaced if the photo (or device) has location data
|
||||
setGpsInfo(null);
|
||||
|
||||
// Multi-tier GPS resolution: EXIF → MediaLibrary.getAssetInfoAsync
|
||||
const resolvedGps = await resolvePhotoLocation(asset);
|
||||
if (resolvedGps) {
|
||||
setGpsInfo(resolvedGps);
|
||||
} else if (useGeotag) {
|
||||
// Tier 3: photo has no GPS — fall back to device location
|
||||
const deviceGps = await getCurrentGPS();
|
||||
if (deviceGps) {
|
||||
setGpsInfo(deviceGps);
|
||||
}
|
||||
// Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback
|
||||
const { gps, permissionDenied } = await resolvePhotoGPS(asset, {
|
||||
useGeotag,
|
||||
allowDeviceFallback: useGeotag,
|
||||
});
|
||||
setGpsInfo(gps);
|
||||
if (permissionDenied) {
|
||||
console.warn('Media library read permission denied — photo GPS unavailable.');
|
||||
}
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
};
|
||||
|
||||
// Multi-tier GPS resolver: EXIF → MediaLibrary → Device GPS (fallback inside handlePickImage)
|
||||
const resolvePhotoLocation = async (asset: any): Promise<GPSInfo | null> => {
|
||||
// Tier 1: Extract GPS from ImagePicker EXIF
|
||||
if (asset.exif && (asset.exif.GPSLatitude || asset.exif.GPSLongitude)) {
|
||||
const lat = typeof asset.exif.GPSLatitude === 'number'
|
||||
? asset.exif.GPSLatitude
|
||||
: parseGPS(asset.exif.GPSLatitudeRef || '', asset.exif.GPSLatitude);
|
||||
const lon = typeof asset.exif.GPSLongitude === 'number'
|
||||
? asset.exif.GPSLongitude
|
||||
: parseGPS(asset.exif.GPSLongitudeRef || '', asset.exif.GPSLongitude);
|
||||
if (lat !== 0 || lon !== 0) {
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
locality: undefined,
|
||||
country: undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Use MediaLibrary.getAssetInfoAsync for richer metadata
|
||||
// On Android, assetId is a content URI like "content://media/external/images/media/12345"
|
||||
// but MediaLibrary expects just the numeric part "12345"
|
||||
const rawId = asset.assetId || '';
|
||||
const numericId = rawId.replace(/^.*\/media\//, '').replace(/\/.*$/, '');
|
||||
if (numericId && /^\d+$/.test(numericId)) {
|
||||
try {
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(numericId);
|
||||
if (assetInfo.location) {
|
||||
const locality = await reverseGeocode(assetInfo.location.latitude, assetInfo.location.longitude) || undefined;
|
||||
return {
|
||||
latitude: assetInfo.location.latitude,
|
||||
longitude: assetInfo.location.longitude,
|
||||
locality,
|
||||
country: undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('MediaLibrary getAssetInfoAsync failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper to parse GPS coordinates from EXIF string format
|
||||
const parseGPS = (ref: string, val: any): number => {
|
||||
try {
|
||||
if (Array.isArray(val)) {
|
||||
const [degrees, minutes, seconds] = val.map(Number);
|
||||
const decimal = degrees + minutes / 60 + seconds / 3600;
|
||||
return (ref === 'S' || ref === 'W') ? -decimal : decimal;
|
||||
}
|
||||
if (typeof val === 'number') return val;
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const activeRecipe: Recipe =
|
||||
selectedRecipe || {
|
||||
id: 'temp',
|
||||
name: 'Custom',
|
||||
baseFilter: 'none',
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
};
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (isProcessing) return;
|
||||
|
||||
// Camera mode (Phase 4, option A): real capture through vision-camera.
|
||||
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.');
|
||||
if (!cameraPermissionGranted) {
|
||||
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
|
||||
return;
|
||||
}
|
||||
if (!cameraRef.current) {
|
||||
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
try {
|
||||
// A photo taken now is at the device's current spot — resolve device GPS
|
||||
// fresh instead of reusing GPS left over from a previously loaded photo.
|
||||
let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null;
|
||||
if (useGeotag && !captureGps) {
|
||||
const deviceGps = await getCurrentGPS();
|
||||
if (deviceGps) {
|
||||
captureGps = { ...deviceGps, source: 'device' as const };
|
||||
setGpsInfo(captureGps);
|
||||
}
|
||||
}
|
||||
|
||||
const exportedUri = await processAndExportPhoto(
|
||||
libraryImageUri,
|
||||
{
|
||||
...selectedRecipe,
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
const photo = await cameraRef.current.takePhoto({});
|
||||
const sourceUri = toUri(photo.path);
|
||||
|
||||
const result = await processAndExportPhoto(
|
||||
sourceUri,
|
||||
{ ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag },
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
},
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
gpsInfo
|
||||
);
|
||||
captureGps
|
||||
);
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
if (exportedUri) {
|
||||
setLastPhotoUri(exportedUri);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing image.');
|
||||
if (result) {
|
||||
setLastPhotoUri(result.uri);
|
||||
setLastSavedToLibrary(result.savedToLibrary);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Camera capture failed:', error);
|
||||
Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 result = await processAndExportPhoto(
|
||||
libraryImageUri,
|
||||
{
|
||||
...selectedRecipe,
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
},
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
gpsInfo
|
||||
);
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
if (result) {
|
||||
setLastPhotoUri(result.uri);
|
||||
setLastSavedToLibrary(result.savedToLibrary);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing image.');
|
||||
}
|
||||
};
|
||||
|
||||
// Switching back to camera: drop GPS that belongs to a library photo, so the
|
||||
// live view (and any capture) never stamps a stale location.
|
||||
const handleSetMode = (next: 'camera' | 'library') => {
|
||||
if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
|
||||
setGpsInfo(null);
|
||||
}
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -344,7 +361,7 @@ export default function App() {
|
||||
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
|
||||
|
||||
{/* Top Header */}
|
||||
<Header mode={mode} setMode={setMode} title="CamRecipe Pro" />
|
||||
<Header mode={mode} setMode={handleSetMode} title="CamRecipe Pro" />
|
||||
|
||||
{/* Main Viewfinder Frame */}
|
||||
<View className="flex-1 justify-center items-center px-4 py-2 bg-carbon">
|
||||
@@ -364,8 +381,9 @@ export default function App() {
|
||||
useGeotag={useGeotag}
|
||||
gpsInfo={gpsInfo}
|
||||
libraryImageUri={libraryImageUri}
|
||||
cameraPermissionGranted={!!cameraPermission?.granted}
|
||||
onRequestCameraPermission={requestCameraPermission as any}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
cameraRef={cameraRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -397,7 +415,7 @@ export default function App() {
|
||||
onPickImage={handlePickImage}
|
||||
onOpenPreview={() => setPreviewVisible(true)}
|
||||
isLibraryMode={mode === 'library'}
|
||||
onSwitchToCamera={() => setMode('camera')}
|
||||
onSwitchToCamera={() => handleSetMode('camera')}
|
||||
/>
|
||||
|
||||
{/* Preview Modal */}
|
||||
@@ -406,6 +424,7 @@ export default function App() {
|
||||
onClose={() => setPreviewVisible(false)}
|
||||
photoUri={lastPhotoUri}
|
||||
recipeName={selectedRecipe?.name || 'Custom'}
|
||||
savedToLibrary={lastSavedToLibrary}
|
||||
/>
|
||||
|
||||
{/* Global Processing Loader Overlay */}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# KẾ HOẠCH SỬA TOÀN BỘ + TÍNH NĂNG GPS GỐC ẢNH CAMERA OS
|
||||
|
||||
App: `RecipesCam` (Expo SDK 57 / RN 0.86 / Skia / vision-camera).
|
||||
Nguyên tắc: sửa nhỏ nhất, từng Phase có tiêu chí verify trên thiết bị thật, không thay đổi stack.
|
||||
|
||||
---
|
||||
|
||||
## 0. TÓM TẮT
|
||||
|
||||
- **Trục trặc chính**: luồng "Save ảnh đã lọc" và "đọc GPS ảnh cũ" đều chết — gồm 1 lỗi API Skia (`toString` không tồn tại) và **1 lỗi tương thích SDK 57 chưa từng được xử lý**: từ `expo-media-library@57`, toàn bộ hàm legacy (`getAssetInfoAsync`, `saveToLibraryAsync`, ...) import từ `expo-media-library` **chỉ là stub ném lỗi** (xem `node_modules/expo-media-library/src/legacyWarnings.ts`); phải import từ `expo-media-library/legacy` hoặc dùng class API mới (`Asset`, `Query`, `Album`).
|
||||
- **Tính năng mới**: đọc & dùng GPS gốc ghi trong EXIF của ảnh do camera hệ điều hành chụp. Chìa khóa Android: cần quyền `ACCESS_MEDIA_LOCATION` (bật qua plugin option `isAccessMediaLocationEnabled`) — nếu không, Android 10+ **giấu** GPS trong metadata.
|
||||
|
||||
---
|
||||
|
||||
## 1. PHÁT HIỆN BỔ SUNG (ngoài báo cáo trước)
|
||||
|
||||
| Lỗi | Bằng chứng |
|
||||
|---|---|
|
||||
| `App.tsx:263 MediaLibrary.getAssetInfoAsync(...)` → **throw ngay** (stub SDK 57) → Tier-2 GPS chưa bao giờ chạy | `legacyWarnings.ts`: `errorOnLegacyMethodUse('getAssetInfoAsync')` |
|
||||
| `exportEngine.ts:183 MediaLibrary.saveToLibraryAsync(...)` → **throw ngay** → kể cả sửa xong B1 (encodeToBase64), save vẫn chết | cùng nguồn: stub `saveToLibraryAsync` |
|
||||
| `exportEngine.ts:181 requestPermissionsAsync` vẫn chạy (hàm mới của module mới) nhưng không đủ | `index.ts` chỉ export mới: `requestPermissionsAsync`, `getPermissionsAsync`, `presentPermissionsPicker`, class `Asset/Query/Album` |
|
||||
| Android 10+: đọc GPS từ MediaStore cần `android.permission.ACCESS_MEDIA_LOCATION` trong manifest, runtime được auto-request khi có trong manifest | plugin `withMediaLibrary.js` option `isAccessMediaLocationEnabled`; `MediaLibraryModule.kt:298-323` |
|
||||
|
||||
→ Kết luận: lỗi B1 + SDK57 phải sửa **cùng nhau**, nếu không ảnh không bao giờ lưu được.
|
||||
|
||||
---
|
||||
|
||||
## 2. PHASE 1 — SỬA LUỒNG XUẤT & LƯU ẢNH (blocker)
|
||||
|
||||
File: `src/utils/exportEngine.ts`
|
||||
|
||||
1. **B1 — base64 sai** (dòng 173-178):
|
||||
- Bỏ `Skia.Data.fromBytes(jpegBytes).toString()` (SkData chỉ có `dispose`, `.toString()` rơi về `[object Object]`).
|
||||
- Dùng API có sẵn: `const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);` (có trong `Image.d.ts:115` + native `JsiSkImage.h`).
|
||||
- Ghi file: `FileSystem.writeAsStringAsync(tempFileUri, base64, { encoding: EncodingType.Base64 })`.
|
||||
2. **SDK57 — save**: đổi import `* as MediaLibrary from 'expo-media-library'` → 2 nguồn:
|
||||
- `import { requestPermissionsAsync } from 'expo-media-library';` (hàm mới, request đủ read + auto `ACCESS_MEDIA_LOCATION` nếu manifest có).
|
||||
- `import { saveToLibraryAsync } from 'expo-media-library/legacy';` (hoặc migrate sang `Asset.create(uri)` — chọn legacy để diff nhỏ, đánh dấu `ponytail:` chuyển class API khi bỏ legacy).
|
||||
3. **Giữ nguyên phần còn lại**: pipeline Skia surface → filter → grain → frame → GPS text đúng hướng, không đụng.
|
||||
|
||||
Verify: build dev, chọn ảnh → SAVE → mở Gallery thấy file JPEG xem được, watermark đúng. (Không cần thiết bị thật đặc biệt; máy Android bất kỳ.)
|
||||
|
||||
---
|
||||
|
||||
## 3. PHASE 2 — TÍNH NĂNG: ĐỌC & DÙNG NATIVE GPS ẢNH CHỤP BỞI CAMERA OS
|
||||
|
||||
### 3.1 Yêu cầu chức năng
|
||||
- Chọn ảnh (bất kể nguồn: thư viện/DCIM do Camera OS chụp, ứng dụng file...) trong **library mode** → geotag watermark phải ưu tiên **tọa độ GPS gốc nhúng trong ảnh**, kèm địa danh (reverse geocode), thời gian chụp ảnh — KHÔNG lấy GPS thiết bị hiện tại.
|
||||
- GPS thiết bị chỉ còn là **phương án cuối** khi: ảnh không có GPS VÀ user đang bật geotag.
|
||||
- Hiển thị nguồn dữ liệu để QA: watermark line 3 thêm tag `[EXIF]`/`[MEDIA]`/`[DEVICE]` (giữ mặc định tắt nếu muốn sạch UI).
|
||||
|
||||
### 3.2 Chuỗi phân giải GPS (thay `resolvePhotoLocation` hiện tại)
|
||||
```
|
||||
Tier EXIF (image-picker asset.exif)
|
||||
→ có GPSLatitude/Longitude? parse chuẩn rational → dùng luôn (source=exif, timestamp=ảnh)
|
||||
Tier MEDIA (asset.assetId != null)
|
||||
→ request read permission (main requestPermissionsAsync)
|
||||
→ MediaLibrary.getAssetInfoAsync(assetId) [import legacy]
|
||||
→ assetInfo.location → dùng (source=mediaLibrary)
|
||||
Tier DEVICE (chỉ khi useGeotag=true)
|
||||
→ getCurrentGPS() → dùng (source=device)
|
||||
Mọi tier: reverse geocode ra locality (uppercase) nếu chưa có; formatCoordinate như cũ
|
||||
```
|
||||
Tách hẳn sang util mới `src/utils/photoGps.ts` (không nhét tiếp vào App.tsx), giữ `App.tsx` chỉ gọi.
|
||||
|
||||
### 3.3 Thay đổi cụ thể
|
||||
1. `app.json` plugin `expo-media-library` thêm: `"isAccessMediaLocationEnabled": true` → sinh `android.permission.ACCESS_MEDIA_LOCATION`.
|
||||
2. `App.tsx:236-280`: xóa logic cũ → `const gps = await resolvePhotoGPS(asset, useGeotag)`.
|
||||
3. Hardening `parseGPS` (bug B7): nhận dạng 3 dạng EXIF:
|
||||
- số `21.5`,
|
||||
- array số `[21, 30, 0]`,
|
||||
- array rational string `["21/1","30/1","0/1"]` → bóc `num/den`.
|
||||
- Trả `null` thay vì `NaN`; chỉ chấp nhận khi `isFinite`; bỏ qua `0,0` (thiếu dữ liệu).
|
||||
4. GPSInfo mở rộng (`src/types/index.ts`): `source?: 'exif' | 'mediaLibrary' | 'device'; photoTakenAt?: number;` — timestamp mặc định = thời điểm chụp ảnh nếu đọc được EXIF.
|
||||
5. Watermark (`Viewfinder.tsx:152-170` + `exportEngine.ts:149-159`): hiển thị giờ từ `gpsInfo.timestamp`; thêm tag nguồn (tham số debug).
|
||||
6. Permission UX: nếu đọc ảnh mà user chưa grant read/location → Alert giải thích + `requestPermissionsAsync()` đúng thời điểm (không hỏi lúc khởi động).
|
||||
|
||||
### 3.4 Ma trận kiểm thử trên máy thật
|
||||
| Nền tảng | Ảnh camera OS có GPS | Kỳ vọng |
|
||||
|---|---|---|
|
||||
| Android 10+ | bật Location khi chụp | cho phép `ACCESS_MEDIA_LOCATION` → watermark = đúng nơi chụp `[MEDIA/EXIF]` |
|
||||
| Android 10+ | không bật Location | không watermark hoặc `[DEVICE]` nếu fallback bật |
|
||||
| Android (Photo Picker mới) | có GPS | Picker thường giữ EXIF khi app có location-access; nếu `assetId=null` → nhảy Tier EXIF; thiếu cả 2 → ghi log + fallback |
|
||||
| iOS full access | có GPS | `assetId` có → Tier MEDIA chạy |
|
||||
| iOS limited access | — | `assetId=null`, exif không kèm GPS → fallback device; ghi chú UX "cấp full quyền để đọc vị trí ảnh" |
|
||||
| File/cloud (không qua thư viện OS) | có GPS trong file | tùy chọn Tier EXIF binary (xem 3.5) |
|
||||
|
||||
### 3.5 (Tùy chọn, sau khi Phase 2 core chạy) EXIF binary fallback
|
||||
Ảnh không qua MediaStore (URI tạm/cloud) mà picker không trả exif: parse GPS IFD trực tiếp từ file bằng parser nhỏ (`exif-reader` — đã ghi trong PROJECT_OVERVIEW, chưa cài) hoặc tự đọc 2 IFD. Chỉ thêm khi có nhu cầu thật (YAGNI).
|
||||
|
||||
---
|
||||
|
||||
## 4. PHASE 3 — BUGS STATE/UI CÒN LẠI
|
||||
|
||||
| # | Sửa | File |
|
||||
|---|---|---|
|
||||
| B3 | **Quyết định rõ số phận 3 slider**: (a) map `contrast` vào ma trận (scale offset, nhân sau base) + highlight/shadow giữ nguyên hoặc (b) ẩn slider khỏi UI để không bán tính năng ảo. Đề xuất (a) contrast; highlight/shadow triển khai bằng `ComponentTransfer` hoặc ẩn tạm — chốt với người dùng | `src/utils/colorUtils.ts`, `src/components/AdjustmentPanel.tsx` |
|
||||
| B3b | Monochrome: đưa bước Exposure **ra trước** early-return mono (WB/tint/chrome vẫn skip), để Exposure slider có tác dụng trên ACROS | `colorUtils.ts:113-115` |
|
||||
| B4 | (Quyết định) Denoise/Clarity hiện chỉ áp lúc export. Tối thiểu: đổi nhãn slider thành "chỉ áp khi SAVE"; tối đa: preview song song bằng ImageFilter Skia. Đề xuất Phase 3 làm nhãn + ghi chú, preview để Phase 4 | `AdjustmentPanel.tsx` |
|
||||
| B5 | Tuần tự hoá init: `restoreSession()` chạy trước, chỉ `applyRecipe(list[0])` khi **không** có session hợp lệ; bỏ `loadRecipes` tự apply mù | `App.tsx:60-122` |
|
||||
| B8 | Tách side-effect khỏi updater: tính `next` trước, rồi `setAdjustments(next)` + `setSelectedRecipe({...})` độc lập | `App.tsx:138-149` |
|
||||
| B6/B9 | (đã gộp vào Phase 1/2) | |
|
||||
| B10 | Nút DONE giữ ý nghĩa đóng; nếu media permission bị từ chối → hiện trạng thái "chưa lưu vào thư viện" + nút LƯU LẠI | `PreviewModal.tsx` |
|
||||
| B11 | Xoá file tạm cũ trước khi ghi mới (cache dir 1 file `camrecipe_pro_export_*.jpg` thôi) | `exportEngine.ts` |
|
||||
| B12 | Restore URI không tồn tại → tự chuyển về `mode='camera'` + reset `libraryImageUri` | `App.tsx:89-114` |
|
||||
| B13 | Dọn quyền thừa: `expo-image-picker` plugin thêm `microphonePermission: false`; bỏ `RECORD_AUDIO/READ_MEDIA_AUDIO` khỏi app.json | `app.json` |
|
||||
| B14 | Kelvin 10000K: chốt có giữ `r=1.55` (kẹt kênh) hay giảm hệ số — quyết định thẩm mỹ, mặc định giữ nguyên | `colorUtils.ts` |
|
||||
| Share | Chuyển `Share.share` file → `expo-sharing` (`shareAsync(uri, {mimeType})`) | `PreviewModal.tsx` |
|
||||
| Typecheck | TS 6.0.3 trong package-lock làm `tsc` crash (`RangeError`); hạ `typescript` về `~5.9` để `npx tsc --noEmit` dùng được | `package.json` |
|
||||
|
||||
---
|
||||
|
||||
## 5. PHASE 4 — CAMERA MODE (cần chốt sản phẩm)
|
||||
|
||||
Hiện shutter camera mode chỉ Alert. Hai hướng:
|
||||
- **(A) Implement chụp thật** bằng vision-camera: `device.takePhoto()` (frame processor đang chạy → photo raw chưa filter) → chạy cùng `processAndExportPhoto()` trên file ảnh vừa chụp → lưu gallery. GPS khi chụp: dùng `getCurrentGPS()` (vision-camera không ghi EXIF GPS) — hoặc chấp nhận ảnh tự chụp không có GPS gốc.
|
||||
- **(B) Khóa UX hiện tại**: ẩn/đổi nút shutter thành hướng dẫn sang LIBRARY.
|
||||
Khuyến nghị (A) nhưng xếp sau Phase 1-3 vì cần thiết bị thật để verify ổn định.
|
||||
|
||||
---
|
||||
|
||||
## 6. QA TỔNG THỂ (sau mỗi Phase)
|
||||
|
||||
```bash
|
||||
cd /home/locpham/RecipesCam
|
||||
npx tsc --noEmit # sau khi hạ TS ~5.9 (Phase 3)
|
||||
npx expo run:android # máy thật USB (không có emulator image trong máy này)
|
||||
```
|
||||
|
||||
Checklist thủ công:
|
||||
1. Library → chọn ảnh bất kỳ → kéo Exposure/Sat/WB → SAVE → Gallery mở file đẹp, đúng filter + frame + watermark (Phase 1).
|
||||
2. Dùng **Camera OS chụp ảnh mới với Location ON** → chọn trong app → watermark hiện đúng nơi chụp + `[EXIF/MEDIA]` (Phase 2).
|
||||
3. Lặp lại khi ảnh chụp Location OFF → không watermark / `[DEVICE]` đúng chính sách.
|
||||
4. Background app giữa lúc chỉnh → mở lại → recipe + slider khôi phục đúng (B5).
|
||||
5. Chọn ACROS MONO → kéo Exposure → ảnh sáng/tối đổi (B3b).
|
||||
6. Toggle geotag tắt → không watermark; bật lại khi chưa grant location → Alert + fallback đúng.
|
||||
7. Chụp 2 ảnh liên tiếp → không đè file, không treo (B1/B11).
|
||||
8. SHARE ảnh → mở được qua app khác (expo-sharing).
|
||||
|
||||
Rủi ro: (i) GPS Photo Picker Android/iOS limited-access hành vi khác nhau → giữ log + QA ma trận 3.4; (ii) vision-camera trên máy thiếu GPU frame processor cũ vẫn preview được nhưng `takePhoto` cần test riêng; (iii) `expo-media-library/legacy` sẽ bị gỡ ở SDK tương lai → đã đánh dấu `ponytail:`.
|
||||
|
||||
## 7. THỨ TỰ THỰC HIỆN
|
||||
Phase 1 → verify → Phase 2 (+app.json/plugin) → verify → Phase 3 → verify → Phase 4 (cần chốt A/B).
|
||||
Mỗi Phase là 1 commit riêng, giữ diff nhỏ.
|
||||
@@ -19,15 +19,13 @@
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_AUDIO"
|
||||
"android.permission.READ_MEDIA_VIDEO"
|
||||
],
|
||||
"package": "com.locphamtran.recipescamera"
|
||||
},
|
||||
@@ -37,9 +35,10 @@
|
||||
"plugins": [
|
||||
"expo-asset",
|
||||
[
|
||||
"expo-camera",
|
||||
"react-native-vision-camera",
|
||||
{
|
||||
"cameraPermission": "Allow CamRecipe Pro to access your camera to take photos."
|
||||
"enableFrameProcessors": true,
|
||||
"cameraPermissionText": "Allow CamRecipe Pro to access your camera."
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -51,15 +50,18 @@
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library."
|
||||
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets."
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets.",
|
||||
"microphonePermission": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
Generated
+113
-75
@@ -12,7 +12,7 @@
|
||||
"@react-native-community/slider": "5.2.0",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.6",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.2",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
@@ -22,20 +22,23 @@
|
||||
"expo-image-picker": "~57.0.4",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
"expo-sharing": "~57.0.18",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"lucide-react-native": "^1.24.0",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"react-native-reanimated": "^4.5.0",
|
||||
"react-native-safe-area-context": "^5.8.0",
|
||||
"react-native-vision-camera": "^4.7.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets-core": "^1.6.3",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1219,15 +1222,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.5.tgz",
|
||||
"integrity": "sha512-xhUGgzpFWRghDUH98+Wl4RDakYhTsbyMg6aOYiBjRzPO/THH8tKMw3vlksgFYlU2PkiAdABJN3tNPf5qmvOQhA==",
|
||||
"version": "57.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz",
|
||||
"integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config-types": "^57.0.2",
|
||||
"@expo/json-file": "~11.0.1",
|
||||
"@expo/plist": "^0.8.1",
|
||||
"@expo/require-utils": "^57.0.3",
|
||||
"@expo/require-utils": "^57.0.5",
|
||||
"@expo/sdk-runtime-versions": "^1.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"debug": "^4.3.5",
|
||||
@@ -1422,9 +1425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.5.tgz",
|
||||
"integrity": "sha512-KyiYvQ9rwT3X3CFTRP1grSs/z2gizJuKbo7akPCgqACC4XtG/MWYsvbOB2fH6cfeRn8Zv3JNHgRGF4AmCOIzFw==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.6.tgz",
|
||||
"integrity": "sha512-liXA9axM3aykAdil4qdHOYKmQqTDdXYkAoT3Eny+SQEo3btERJCbOk8VH48/G0sVbXCj82AbSS8s3XNEQNqbDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -1512,9 +1515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/prebuild-config": {
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.7.tgz",
|
||||
"integrity": "sha512-VrsRKc+je3bAZp8ocM8fVjRbXAQqYnBLiy3gm10VY0WoNspQwxcE9gllTu2jtMcqf6pNfAxGaSSp9I74SNOEzg==",
|
||||
"version": "57.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.8.tgz",
|
||||
"integrity": "sha512-NQjRuTLvxUnggK57pKTHLtzS8YYtjrQSGYu+CjgWaO8lNlqdoBbvHrxGYtc9tBuOI3xVPaTfuIvjikxX1r+UhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config": "~57.0.5",
|
||||
@@ -1524,15 +1527,15 @@
|
||||
"@expo/json-file": "^11.0.1",
|
||||
"@react-native/normalize-colors": "0.86.0",
|
||||
"debug": "^4.3.1",
|
||||
"expo-modules-autolinking": "~57.0.7",
|
||||
"expo-modules-autolinking": "~57.0.8",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/require-utils": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.3.tgz",
|
||||
"integrity": "sha512-ns05X1K8tM+Qtzp6dNloUFOopSdh3J+HC61BtOR8WHhgtPFyX8TKuO2diqZUqVg9K8yfkWug7g8tBS0qRniSTA==",
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz",
|
||||
"integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -1540,7 +1543,7 @@
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0"
|
||||
"typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -2167,9 +2170,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/agent-cli-detector": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.2.tgz",
|
||||
"integrity": "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ==",
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.3.tgz",
|
||||
"integrity": "sha512-XjBe6lT5sK9xhAn0ToFvdwicsaGvzZkax1iviPnL8sFFYHoagnTTn2E4YTFiyM956iqkOkFcgcWCiER0kkShbQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"agent-cli-detector": "dist/cli.js"
|
||||
@@ -3312,13 +3315,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-57.0.6.tgz",
|
||||
"integrity": "sha512-4NKM1ArfRAmmY82Xcw/guGHlTSItD5mzNxbK4Qd3aOPuRAPAkCgJVLC/eqccZCEGS5aJn0tyjHNKE65k2GrcFw==",
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-57.0.7.tgz",
|
||||
"integrity": "sha512-PJdE0EjoX878OqClmsigVKdT0jCNOAQDRLcvFkeBakxaVyEDhjcQ8baKq2YysYH2g0YNB1rEIpHUiKtluO918A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "^57.0.8",
|
||||
"@expo/cli": "^57.0.9",
|
||||
"@expo/config": "~57.0.5",
|
||||
"@expo/config-plugins": "~57.0.5",
|
||||
"@expo/devtools": "~57.0.1",
|
||||
@@ -3327,16 +3330,16 @@
|
||||
"@expo/local-build-cache-provider": "^57.0.4",
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"@expo/metro": "~56.0.0",
|
||||
"@expo/metro-config": "~57.0.5",
|
||||
"@expo/metro-config": "~57.0.6",
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"babel-preset-expo": "~57.0.3",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-constants": "~57.0.5",
|
||||
"expo-asset": "~57.0.6",
|
||||
"expo-constants": "~57.0.6",
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-keep-awake": "~57.0.1",
|
||||
"expo-modules-autolinking": "~57.0.7",
|
||||
"expo-modules-core": "~57.0.5",
|
||||
"expo-modules-autolinking": "~57.0.8",
|
||||
"expo-modules-core": "~57.0.6",
|
||||
"pretty-format": "^29.7.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"whatwg-url-minimum": "^0.1.2"
|
||||
@@ -3374,13 +3377,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-asset": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.5.tgz",
|
||||
"integrity": "sha512-vRwG+QvoW1u1vd6yY1oQ4f5doNGGpuCQHvzSY25eCNlQjNJtHJyjWcllzn1KdQ8aQc9TMMYedxeLoYZshSI7MA==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.6.tgz",
|
||||
"integrity": "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.11.3",
|
||||
"expo-constants": "~57.0.5"
|
||||
"expo-constants": "~57.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -3409,9 +3412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.5.tgz",
|
||||
"integrity": "sha512-HVxPZc1uBdqrlcmNvdyO3L107vt/gsCRNGvXrYXWjZqmz1XOvGeUCR7S3MG4wUj4cQw4/WMCp+fcoDIhynJ80A==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
|
||||
"integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/env": "~2.4.2"
|
||||
@@ -3579,9 +3582,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.7.tgz",
|
||||
"integrity": "sha512-arYvWy3odY0JxFwjosVeu8OrS/CvYy+Voroe126X/fYlkdXryJZSYbOsmM1ruol3Eo4ji8HKcfO+mvEVKGxUbQ==",
|
||||
"version": "57.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.8.tgz",
|
||||
"integrity": "sha512-YBDgbJHlhhhr3JaKErW6znsIL8zQsh206LaDpWrLHV/WluRwhYfZuF/fhkVN7b/DsRxIu0UeCPExaEPMxlC2Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/require-utils": "^57.0.3",
|
||||
@@ -3594,9 +3597,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-core": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.5.tgz",
|
||||
"integrity": "sha512-jyx2yAKUO5wJRlRTj74GC8P6NFhXWw/wB42LaPgAco7tjvWujl4O5D8fM7jCE+VFRa3OV/4SZUyp7H0aeuqYdg==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.6.tgz",
|
||||
"integrity": "sha512-hePwOh2+i+EpWrVnv95sQeQ0OD5PYuEuIhmglVLzQVaVBV3zHcRvLAc1rV8ROqUL6YtAQUXjPkoSx1ly2ZdTuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/expo-modules-macros-plugin": "0.6.1",
|
||||
@@ -3632,6 +3635,22 @@
|
||||
"node": ">=20.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-sharing": {
|
||||
"version": "57.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.18.tgz",
|
||||
"integrity": "sha512-Q09Gab1yn+OLR7sqcHBNyjM/4MMQa290hDKCV2pf2THnTLkLLuUmC0AKgMgxygqU0uuzhOgVqfnE70dePCLq9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config-plugins": "^57.0.9",
|
||||
"@expo/config-types": "^57.0.2",
|
||||
"@expo/plist": "^0.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-status-bar": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz",
|
||||
@@ -3653,9 +3672,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "57.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.8.tgz",
|
||||
"integrity": "sha512-RzE6o39O6Ual4nw1LbUyrIMY5VzQlW4CbOyoww/RhKclMRVItnfkLi89cVKQoPFw+5EKW2KKC6LLtA4or7Trwg==",
|
||||
"version": "57.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz",
|
||||
"integrity": "sha512-41z9z68SynNXasZOjuT1si5Sq5OKL6SLf40ZjikbtZgDuvBO8HaUsaDzsJ0c1UZJ3N+vMzYCc1JUIDyRkVBjkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/code-signing-certificates": "^0.0.6",
|
||||
@@ -3668,12 +3687,12 @@
|
||||
"@expo/json-file": "^11.0.1",
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"@expo/metro": "~56.0.0",
|
||||
"@expo/metro-config": "~57.0.5",
|
||||
"@expo/metro-config": "~57.0.6",
|
||||
"@expo/metro-file-map": "^57.0.1",
|
||||
"@expo/osascript": "^2.7.1",
|
||||
"@expo/package-manager": "^1.13.1",
|
||||
"@expo/plist": "^0.8.1",
|
||||
"@expo/prebuild-config": "^57.0.7",
|
||||
"@expo/prebuild-config": "^57.0.8",
|
||||
"@expo/require-utils": "^57.0.3",
|
||||
"@expo/router-server": "^57.0.3",
|
||||
"@expo/schema-utils": "^57.0.2",
|
||||
@@ -4758,9 +4777,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4781,9 +4797,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4804,9 +4817,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4827,9 +4837,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6473,9 +6480,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6496,9 +6500,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6519,9 +6520,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6542,9 +6540,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6672,6 +6667,30 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vision-camera": {
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz",
|
||||
"integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@shopify/react-native-skia": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": "*",
|
||||
"react-native-worklets-core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@shopify/react-native-skia": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-reanimated": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-worklets-core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
@@ -6731,6 +6750,19 @@
|
||||
"react-native": "0.83 - 0.86"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets-core": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets-core/-/react-native-worklets-core-1.6.3.tgz",
|
||||
"integrity": "sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-hash-64": "^1.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
@@ -7262,6 +7294,12 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-hash-64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/string-hash-64/-/string-hash-64-1.0.3.tgz",
|
||||
"integrity": "sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -7555,9 +7593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+6
-3
@@ -7,7 +7,7 @@
|
||||
"@react-native-community/slider": "5.2.0",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.6",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.2",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
@@ -17,20 +17,23 @@
|
||||
"expo-image-picker": "~57.0.4",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
"expo-sharing": "~57.0.18",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"lucide-react-native": "^1.24.0",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"react-native-reanimated": "^4.5.0",
|
||||
"react-native-safe-area-context": "^5.8.0",
|
||||
"react-native-vision-camera": "^4.7.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets-core": "^1.6.3",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
|
||||
@@ -242,16 +242,13 @@ export default function AdjustmentPanel({
|
||||
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) =>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] px-4 mb-2">
|
||||
NOTE: DENOISE & CLARITY APPLY AT EXPORT ONLY — LIVE PREVIEW UNCHANGED
|
||||
</Text>
|
||||
{renderSlider('Denoise (Export)', adjustments.denoise, 0, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ denoise: val })
|
||||
)}
|
||||
{renderSlider('Clarity (Sharp/Soft)', adjustments.clarity, -10, 10, 1, (val) =>
|
||||
{renderSlider('Clarity (Export)', adjustments.clarity, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ clarity: val })
|
||||
)}
|
||||
{renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, Share } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, Share, Alert } from 'react-native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { X, Share2, Download } from 'lucide-react-native';
|
||||
|
||||
interface PreviewModalProps {
|
||||
@@ -7,6 +9,7 @@ interface PreviewModalProps {
|
||||
onClose: () => void;
|
||||
photoUri: string | null;
|
||||
recipeName: string;
|
||||
savedToLibrary: boolean; // whether the export was already saved to the gallery
|
||||
}
|
||||
|
||||
export default function PreviewModal({
|
||||
@@ -14,32 +17,86 @@ export default function PreviewModal({
|
||||
onClose,
|
||||
photoUri,
|
||||
recipeName,
|
||||
savedToLibrary: initialSaved,
|
||||
}: PreviewModalProps) {
|
||||
const [saved, setSaved] = useState(initialSaved);
|
||||
|
||||
// Modal stays mounted — refresh state whenever a new export is shown.
|
||||
useEffect(() => {
|
||||
if (visible) setSaved(initialSaved);
|
||||
}, [visible, initialSaved]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!photoUri) return;
|
||||
try {
|
||||
await Share.share({
|
||||
url: photoUri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
});
|
||||
// expo-sharing actually shares the file; React Native's Share.share cannot
|
||||
// attach local files. Fall back to Share on platforms where it's unavailable.
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(photoUri, {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: `Share with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
UTI: 'public.jpeg',
|
||||
});
|
||||
} else {
|
||||
await Share.share({
|
||||
url: photoUri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sharing failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
if (!photoUri || saved) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
'Not Saved',
|
||||
'Media library access was denied, so this photo was not saved to your gallery. You can still share it from here.'
|
||||
);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await MediaLibrary.saveToLibraryAsync(photoUri);
|
||||
setSaved(true);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save to gallery failed:', error);
|
||||
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (photoUri && !saved) {
|
||||
Alert.alert(
|
||||
'Not Saved to Gallery',
|
||||
'This photo lives in the app cache only. Tap DONE to save a copy to your gallery, or SHARE it now.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={onClose}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<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}
|
||||
onPress={handleClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -61,25 +118,34 @@ export default function PreviewModal({
|
||||
</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>
|
||||
<View>
|
||||
{photoUri && !saved && (
|
||||
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
|
||||
NOT SAVED TO GALLERY — TAP DONE TO SAVE
|
||||
</Text>
|
||||
)}
|
||||
<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>
|
||||
<TouchableOpacity
|
||||
onPress={handleDone}
|
||||
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">
|
||||
{saved ? 'DONE' : 'SAVE & DONE'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
+86
-113
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
|
||||
import { CameraView } from 'expo-camera';
|
||||
import { Camera, useCameraDevice, useSkiaFrameProcessor } from 'react-native-vision-camera';
|
||||
import { useSharedValue } from 'react-native-reanimated';
|
||||
import {
|
||||
Canvas,
|
||||
useImage,
|
||||
@@ -27,6 +28,7 @@ interface ViewfinderProps {
|
||||
libraryImageUri: string | null;
|
||||
cameraPermissionGranted: boolean;
|
||||
onRequestCameraPermission: () => void;
|
||||
cameraRef?: React.RefObject<Camera | null>;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
@@ -40,7 +42,7 @@ const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
}
|
||||
`);
|
||||
|
||||
const CAPTURE_INTERVAL_MS = 500; // 20fps capture
|
||||
const containerStyle = { width: viewfinderWidth, height: viewfinderHeight };
|
||||
|
||||
export default function Viewfinder({
|
||||
mode,
|
||||
@@ -51,13 +53,10 @@ export default function Viewfinder({
|
||||
libraryImageUri,
|
||||
cameraPermissionGranted,
|
||||
onRequestCameraPermission,
|
||||
cameraRef,
|
||||
}: ViewfinderProps) {
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [capturedFrameUri, setCapturedFrameUri] = useState<string | null>(null);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
const skiaImage = useImage(capturedFrameUri || libraryImageUri || '');
|
||||
|
||||
const device = useCameraDevice('back');
|
||||
const skiaImage = useImage(libraryImageUri || '');
|
||||
const customFont = useFont(
|
||||
require('../../assets/CourierPrime-Regular.ttf'),
|
||||
Math.round(viewfinderWidth * 0.032)
|
||||
@@ -67,6 +66,7 @@ export default function Viewfinder({
|
||||
const colorMatrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
@@ -74,59 +74,35 @@ export default function Viewfinder({
|
||||
);
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
|
||||
// Periodic frame capture for camera live view with full filter effects
|
||||
const colorMatrixSV = useSharedValue(colorMatrix);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'camera') {
|
||||
setCapturedFrameUri(null);
|
||||
setIsCapturing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const captureFrame = async () => {
|
||||
if (isCapturing || !cameraRef.current) return;
|
||||
setIsCapturing(true);
|
||||
try {
|
||||
const picture = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.4,
|
||||
base64: false,
|
||||
skipProcessing: true,
|
||||
});
|
||||
if (picture?.uri) {
|
||||
setCapturedFrameUri(picture.uri);
|
||||
}
|
||||
} catch {
|
||||
// Camera not ready yet
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Capture immediately on mount
|
||||
captureFrame();
|
||||
|
||||
// Then capture periodically
|
||||
const intervalId = setInterval(captureFrame, CAPTURE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
setCapturedFrameUri(null);
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
// Re-capture when recipe changes for immediate feedback
|
||||
useEffect(() => {
|
||||
if (mode !== 'camera' || !cameraRef.current || isCapturing) return;
|
||||
const timer = setTimeout(captureFrame, 100);
|
||||
return () => clearTimeout(timer);
|
||||
colorMatrixSV.value = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
);
|
||||
}, [
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome,
|
||||
]);
|
||||
|
||||
const frameProcessor = useSkiaFrameProcessor((frame) => {
|
||||
'worklet';
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value));
|
||||
frame.render(paint);
|
||||
}, [colorMatrixSV]);
|
||||
|
||||
const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05;
|
||||
const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06;
|
||||
const bottomBorder = viewfinderHeight * 0.18;
|
||||
@@ -185,6 +161,17 @@ export default function Viewfinder({
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
// Only tag the provenance when we know it (photos from the OS camera/library);
|
||||
// old saved sessions without a source keep the original retro caption.
|
||||
const sourceTag =
|
||||
gpsInfo.source === 'exif'
|
||||
? 'EXIF'
|
||||
: gpsInfo.source === 'mediaLibrary'
|
||||
? 'MEDIA LIB'
|
||||
: gpsInfo.source === 'device'
|
||||
? 'DEVICE GPS'
|
||||
: null;
|
||||
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
|
||||
|
||||
const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24);
|
||||
const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16;
|
||||
@@ -193,16 +180,45 @@ export default function Viewfinder({
|
||||
<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" />
|
||||
<SkiaText x={xOffset} y={yOffset} text={`${timestampStr}${captionSuffix}`} font={customFont} color="#f59e0b" />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCameraOverlays = () => (
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{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="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>
|
||||
|
||||
<View className="absolute bottom-2 right-2 bg-black/60 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[9px] text-amber-500 font-mono">LIVE</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
if (mode === 'camera') {
|
||||
if (!cameraPermissionGranted) {
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
style={containerStyle}
|
||||
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">
|
||||
@@ -219,75 +235,32 @@ export default function Viewfinder({
|
||||
);
|
||||
}
|
||||
|
||||
// Show captured and processed frame with Skia
|
||||
if (capturedFrameUri && skiaImage) {
|
||||
if (!device) {
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
style={containerStyle}
|
||||
className="bg-zinc-950 border border-zinc-800 rounded-2xl items-center justify-center p-6"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<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>
|
||||
|
||||
{/* Capture indicator */}
|
||||
<View className="absolute bottom-2 right-2 bg-black/60 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[9px] text-amber-500 font-mono">LIVE</Text>
|
||||
</View>
|
||||
<Text className="text-zinc-500 font-mono text-center text-xs">
|
||||
Camera not available on this device
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to CameraView while first frame is capturing
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
style={containerStyle}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
>
|
||||
<CameraView ref={cameraRef} style={StyleSheet.absoluteFill} facing="back" />
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
|
||||
{/* Grid */}
|
||||
<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>
|
||||
|
||||
{/* Loading indicator */}
|
||||
<View className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Text className="text-white font-mono text-xs">CAPTURING...</Text>
|
||||
</View>
|
||||
<Camera
|
||||
ref={cameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={true}
|
||||
frameProcessor={frameProcessor}
|
||||
/>
|
||||
{renderCameraOverlays()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -295,7 +268,7 @@ export default function Viewfinder({
|
||||
// Library mode
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
style={containerStyle}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
|
||||
>
|
||||
{libraryImageUri && skiaImage ? (
|
||||
|
||||
@@ -30,4 +30,5 @@ export interface GPSInfo {
|
||||
locality?: string;
|
||||
country?: string;
|
||||
timestamp: number;
|
||||
source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from
|
||||
}
|
||||
|
||||
+28
-25
@@ -27,6 +27,7 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number }
|
||||
export function getSkiaColorMatrix(
|
||||
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none',
|
||||
exposure: number, // -10 to +10
|
||||
contrast: number, // -10 to +10
|
||||
saturation: number, // -10 to +10
|
||||
temperature: number, // 2500 to 10000
|
||||
tint: number, // -10 to +10
|
||||
@@ -110,34 +111,36 @@ export function getSkiaColorMatrix(
|
||||
}
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||
if (baseFilter === 'monochrome') {
|
||||
return matrix;
|
||||
}
|
||||
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;
|
||||
if (baseFilter !== 'monochrome') {
|
||||
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,
|
||||
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(expMat, matrix);
|
||||
matrix = multiplyMatrices(wbMat, matrix);
|
||||
}
|
||||
|
||||
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
|
||||
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
|
||||
if (exposure !== 0 || contrast !== 0) {
|
||||
const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
|
||||
const expOffset = (exposure / 10) * 0.15;
|
||||
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
|
||||
const conOffset = 0.5 * (1 - conScale);
|
||||
const adjMat = [
|
||||
expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale,
|
||||
0, expScale * conScale, 0, 0, expOffset + conOffset * expScale,
|
||||
0, 0, expScale * conScale, 0, expOffset + conOffset * expScale,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(adjMat, matrix);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Pure EXIF GPS helpers — tolerant of the varied shapes EXIF parsers return:
|
||||
// numbers (37.7749), arrays ([d, m, s]), rational strings ("37/1", "46/1", "26964/1000")
|
||||
// and arrays of rational strings.
|
||||
|
||||
export function parseRational(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parts = value.trim().split('/');
|
||||
if (parts.length === 2) {
|
||||
const num = Number(parts[0]);
|
||||
const den = Number(parts[1]);
|
||||
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) return num / den;
|
||||
}
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toDecimal(value: unknown, ref: string): number | null {
|
||||
// value: single decimal (number|string) OR array [deg, min, sec]
|
||||
if (Array.isArray(value)) {
|
||||
const d = parseRational(value[0]);
|
||||
const m = parseRational(value[1]);
|
||||
const s = value.length > 2 ? parseRational(value[2]) : 0;
|
||||
if (d === null || m === null || s === null || d < 0 || m < 0 || s < 0) return null;
|
||||
return d + m / 60 + s / 3600;
|
||||
}
|
||||
const n = parseRational(value);
|
||||
return n === null || n < 0 ? null : n;
|
||||
}
|
||||
|
||||
export function parseExifCoordinate(
|
||||
raw: unknown,
|
||||
ref: unknown
|
||||
): number | null {
|
||||
const absolute = toDecimal(raw, String(ref));
|
||||
if (absolute === null || absolute === 0) return null; // ignore 0,0 placeholders
|
||||
const negative = ref === 'S' || ref === 'W';
|
||||
return negative ? -absolute : absolute;
|
||||
}
|
||||
|
||||
export interface ExifLike {
|
||||
GPSLatitude?: unknown;
|
||||
GPSLatitudeRef?: unknown;
|
||||
GPSLongitude?: unknown;
|
||||
GPSLongitudeRef?: unknown;
|
||||
GPSDateStamp?: unknown;
|
||||
GPSTimeStamp?: unknown;
|
||||
DateTimeOriginal?: unknown;
|
||||
}
|
||||
|
||||
export function parseExifGPS(exif: ExifLike | null | undefined): { latitude: number; longitude: number } | null {
|
||||
if (!exif) return null;
|
||||
const lat = parseExifCoordinate(exif.GPSLatitude, exif.GPSLatitudeRef);
|
||||
const lon = parseExifCoordinate(exif.GPSLongitude, exif.GPSLongitudeRef);
|
||||
if (lat === null || lon === null) return null;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; // invalid ranges
|
||||
if (lat === 0 && lon === 0) return null;
|
||||
return { latitude: lat, longitude: lon };
|
||||
}
|
||||
|
||||
const parseTimeStamp = (value: unknown): [number, number, number] | null => {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const h = parseRational(value[0]);
|
||||
const m = parseRational(value[1]);
|
||||
const s = value.length > 2 ? parseRational(value[2]) : 0;
|
||||
if (h === null || m === null || s === null) return null;
|
||||
if (h > 23 || m > 59 || s > 61) return null;
|
||||
return [h, m, s];
|
||||
};
|
||||
|
||||
// EXIF stores dates as "2023:01:02 15:04:05" — colons, not dashes.
|
||||
export function parseExifTimestamp(exif: ExifLike | null | undefined): number | null {
|
||||
if (!exif) return null;
|
||||
const raw = typeof exif.DateTimeOriginal === 'string' ? exif.DateTimeOriginal : '';
|
||||
let dateText = raw.trim();
|
||||
if (!dateText && typeof exif.GPSDateStamp === 'string') {
|
||||
const gpsDate = (exif.GPSDateStamp as string).trim();
|
||||
const gpsTime = parseTimeStamp(exif.GPSTimeStamp);
|
||||
if (gpsTime) {
|
||||
dateText = `${gpsDate} ${gpsTime[0]}:${gpsTime[1]}:${gpsTime[2]}`;
|
||||
} else if (gpsDate) {
|
||||
dateText = `${gpsDate} 00:00:00`;
|
||||
}
|
||||
}
|
||||
if (!dateText) return null;
|
||||
// Normalize "2023:1:2 15:4:5" (any of : / - separators, unpadded parts)
|
||||
// into an ISO string "2023-01-02T15:04:05" before parsing.
|
||||
const m = dateText.match(/^(\d{4})[:/-](\d{1,2})[:/-](\d{1,2})(?:[ T]+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||
if (!m) return null;
|
||||
const two = (v: string) => v.padStart(2, '0');
|
||||
const [, y, mo, d, h = '0', mi = '0', s = '0'] = m;
|
||||
const time = Date.parse(`${y}-${two(mo)}-${two(d)}T${two(h)}:${two(mi)}:${two(s)}`);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
+39
-18
@@ -1,19 +1,28 @@
|
||||
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 * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { Asset } from 'expo-asset';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from './colorUtils';
|
||||
import { drawFrameOnCanvas } from './frameUtils';
|
||||
import { formatCoordinate } from './locationUtils';
|
||||
|
||||
export interface ExportResult {
|
||||
uri: string;
|
||||
savedToLibrary: boolean;
|
||||
}
|
||||
|
||||
// Fixed filename: every export overwrites the previous one, so temp files
|
||||
// never accumulate in the cache directory.
|
||||
const EXPORT_FILE = `${FileSystem.cacheDirectory}camrecipe_pro_last_export.jpg`;
|
||||
|
||||
export async function processAndExportPhoto(
|
||||
sourceUri: string,
|
||||
recipe: Recipe,
|
||||
frameId: FrameId,
|
||||
useGeotag: boolean,
|
||||
gpsInfo: GPSInfo | null
|
||||
): Promise<string | null> {
|
||||
): Promise<ExportResult | null> {
|
||||
try {
|
||||
// 1. Read source image file into Skia
|
||||
const skiaData = await Skia.Data.fromURI(sourceUri);
|
||||
@@ -47,6 +56,7 @@ export async function processAndExportPhoto(
|
||||
const matrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
@@ -55,9 +65,9 @@ export async function processAndExportPhoto(
|
||||
const colorFilter = Skia.ColorFilter.MakeMatrix(matrix);
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 4. Highlight & Shadow / Denoise / Clarity Image Filters
|
||||
// 4. Denoise / Clarity Image Filters (export-only enhancements)
|
||||
let imageFilter = null;
|
||||
|
||||
|
||||
// Denoise (Subtle blur to smooth noise)
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px
|
||||
@@ -134,12 +144,12 @@ export async function processAndExportPhoto(
|
||||
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);
|
||||
@@ -150,42 +160,53 @@ export async function processAndExportPhoto(
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
const sourceTag =
|
||||
gpsInfo.source === 'exif'
|
||||
? 'EXIF'
|
||||
: gpsInfo.source === 'mediaLibrary'
|
||||
? 'MEDIA LIB'
|
||||
: gpsInfo.source === 'device'
|
||||
? 'DEVICE GPS'
|
||||
: null;
|
||||
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
|
||||
|
||||
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);
|
||||
canvas.drawText(`${timestampStr}${captionSuffix}`, xOffset, yOffset, textPaint, font);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
const resultImage = surface.makeImageSnapshot();
|
||||
const jpegBytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
|
||||
const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);
|
||||
|
||||
if (!jpegBytes) {
|
||||
console.error('Failed to encode image to JPEG bytes');
|
||||
if (!base64) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
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, {
|
||||
// 10. Write binary bytes to temporary local file (overwrites previous export)
|
||||
await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, {
|
||||
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);
|
||||
try {
|
||||
await MediaLibrary.saveToLibraryAsync(EXPORT_FILE);
|
||||
return { uri: EXPORT_FILE, savedToLibrary: true };
|
||||
} catch (e) {
|
||||
console.warn('Save to gallery failed:', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
||||
}
|
||||
|
||||
return tempFileUri;
|
||||
return { uri: EXPORT_FILE, savedToLibrary: false };
|
||||
} catch (error) {
|
||||
console.error('Error during photo processing and export:', error);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { getAssetInfoAsync } from 'expo-media-library/legacy';
|
||||
import { GPSInfo } from '../types';
|
||||
import { parseExifGPS, parseExifTimestamp, ExifLike } from './exifGps';
|
||||
import { getCurrentGPS, reverseGeocode } from './locationUtils';
|
||||
|
||||
export interface PickedPhotoAsset {
|
||||
uri?: string;
|
||||
assetId?: string | null;
|
||||
exif?: ExifLike | null;
|
||||
}
|
||||
|
||||
export interface PhotoGpsResult {
|
||||
gps: GPSInfo | null;
|
||||
permissionDenied?: boolean; // true when media-library read permission was refused
|
||||
}
|
||||
|
||||
const localityFor = async (latitude: number, longitude: number): Promise<string | undefined> => {
|
||||
try {
|
||||
return (await reverseGeocode(latitude, longitude)) || undefined;
|
||||
} catch (e) {
|
||||
console.warn('Reverse geocode failed for photo GPS:', e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Android media-library ids are numeric ("12345"); ImagePicker may hand us
|
||||
// "content://media/external/images/media/12345" — reduce to the trailing number.
|
||||
// iOS ids are opaque (e.g. "PH://...") and must be passed verbatim.
|
||||
function toMediaLibraryId(assetId: string): string {
|
||||
if (Platform.OS !== 'android') return assetId;
|
||||
const m = assetId.match(/(\d+)\s*$/);
|
||||
return m ? m[1] : assetId;
|
||||
}
|
||||
|
||||
async function resolveFromMediaLibrary(
|
||||
assetId: string,
|
||||
useGeotag: boolean
|
||||
): Promise<{ gps: GPSInfo | null; permissionDenied: boolean }> {
|
||||
let perm;
|
||||
try {
|
||||
perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
|
||||
} catch (e) {
|
||||
console.warn('MediaLibrary permission request failed:', e);
|
||||
return { gps: null, permissionDenied: true };
|
||||
}
|
||||
if (!perm.granted) {
|
||||
return { gps: null, permissionDenied: true };
|
||||
}
|
||||
try {
|
||||
const info = await getAssetInfoAsync(toMediaLibraryId(assetId));
|
||||
if (info.location && Number.isFinite(info.location.latitude) && Number.isFinite(info.location.longitude)) {
|
||||
const { latitude, longitude } = info.location;
|
||||
const locality = useGeotag ? await localityFor(latitude, longitude) : undefined;
|
||||
return {
|
||||
gps: {
|
||||
latitude,
|
||||
longitude,
|
||||
locality,
|
||||
country: undefined,
|
||||
timestamp: info.creationTime && info.creationTime > 0 ? info.creationTime : Date.now(),
|
||||
source: 'mediaLibrary',
|
||||
},
|
||||
permissionDenied: false,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('MediaLibrary getAssetInfoAsync failed:', e);
|
||||
}
|
||||
return { gps: null, permissionDenied: false };
|
||||
}
|
||||
|
||||
// Resolve GPS for a picked photo: EXIF → MediaLibrary (needs ACCESS_MEDIA_LOCATION
|
||||
// on Android 10+) → device GPS fallback.
|
||||
export async function resolvePhotoGPS(
|
||||
asset: PickedPhotoAsset,
|
||||
opts: { useGeotag: boolean; allowDeviceFallback?: boolean }
|
||||
): Promise<PhotoGpsResult> {
|
||||
const { useGeotag, allowDeviceFallback = true } = opts;
|
||||
|
||||
// Tier 1 — EXIF embedded in the file (needs no extra permission)
|
||||
const exifCoord = parseExifGPS(asset.exif);
|
||||
if (exifCoord) {
|
||||
const timestamp = parseExifTimestamp(asset.exif) ?? Date.now();
|
||||
const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined;
|
||||
return {
|
||||
gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' },
|
||||
};
|
||||
}
|
||||
|
||||
// Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION)
|
||||
if (asset.assetId) {
|
||||
const { gps, permissionDenied } = await resolveFromMediaLibrary(asset.assetId, useGeotag);
|
||||
if (gps) return { gps };
|
||||
if (permissionDenied) return { gps: null, permissionDenied: true };
|
||||
}
|
||||
|
||||
// Tier 3 — device fallback (photo has no location; stamp the device's current spot)
|
||||
if (allowDeviceFallback) {
|
||||
const deviceGps = await getCurrentGPS();
|
||||
if (deviceGps) return { gps: { ...deviceGps, source: 'device' } };
|
||||
}
|
||||
|
||||
return { gps: null };
|
||||
}
|
||||
Reference in New Issue
Block a user