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