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 ? (