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
+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;