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:
2026-09-07 06:54:13 +07:00
parent 0d276861e0
commit 96f412e269
13 changed files with 892 additions and 421 deletions
+5 -8
View File
@@ -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) =>
+92 -26
View File
@@ -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
View File
@@ -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 ? (
+1
View File
@@ -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
View File
@@ -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;
+98
View File
@@ -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
View File
@@ -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;
+106
View File
@@ -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 };
}