Bắt đầu sửa lỗi

This commit is contained in:
2026-07-17 09:33:24 +07:00
commit 2775662b62
134 changed files with 28571 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
// Tanner Helland's Kelvin to RGB approximation
// Kelvin range: 1000K to 40000K (we use 2500K to 10000K)
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
const temp = Math.max(1000, Math.min(40000, kelvin)) / 100;
let r = 0;
let g = 0;
let b = 0;
// Red
if (temp <= 66) {
r = 255;
} else {
r = temp - 60;
r = 329.698727446 * Math.pow(r, -0.1332047592);
r = Math.max(0, Math.min(255, r));
}
// Green
if (temp <= 66) {
g = temp;
g = 99.4708025861 * Math.log(g) - 161.1195681661;
g = Math.max(0, Math.min(255, g));
} else {
g = temp - 60;
g = 288.1221695283 * Math.pow(g, -0.0755148492);
g = Math.max(0, Math.min(255, g));
}
// Blue
if (temp >= 66) {
b = 255;
} else {
if (temp <= 19) {
b = 0;
} else {
b = temp - 10;
b = 138.5177312231 * Math.log(b) - 305.0447927307;
b = Math.max(0, Math.min(255, b));
}
}
return { r: r / 255, g: g / 255, b: b / 255 };
}
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and adjustments
export function getSkiaColorMatrix(
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none',
exposure: number, // -10 to +10
saturation: number, // -10 to +10
temperature: number, // 2500 to 10000
tint: number, // -10 to +10
colorChrome: 'none' | 'weak' | 'strong'
): number[] {
// 1. Start with Identity Matrix
let matrix = [
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 1, 0,
];
// 2. Base Filters
if (baseFilter === 'classic-neg') {
// Classic Neg: high contrast, desaturated reds/greens, warm/amber cast
matrix = [
1.15, -0.05, -0.05, 0, 0.05,
-0.05, 1.05, -0.05, 0, 0.02,
-0.08, -0.08, 0.95, 0, -0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'provia') {
// Provia: standard natural colors, balanced tones
matrix = [
1.02, 0, 0, 0, 0,
0, 1.02, 0, 0, 0,
0, 0, 1.02, 0, 0,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'velvia') {
// Velvia: vivid colors, high saturation, deep blacks
matrix = [
1.25, -0.05, -0.05, 0, -0.02,
-0.05, 1.25, -0.05, 0, -0.02,
-0.05, -0.05, 1.25, 0, -0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Classic black and white filter (luminance weights)
const r = 0.2126;
const g = 0.7152;
const b = 0.0722;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
}
// 3. Apply Saturation adjustment (standard color matrix transformation)
if (baseFilter !== 'monochrome' && saturation !== 0) {
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
const invS = 1 - s;
const r = 0.213 * invS;
const g = 0.715 * invS;
const b = 0.072 * invS;
const satMat = [
r + s, g, b, 0, 0,
r, g + s, b, 0, 0,
r, g, b + s, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(satMat, matrix);
}
// 4. Color Chrome effect (enhances deep colors without shifting white point)
if (baseFilter !== 'monochrome' && colorChrome !== 'none') {
const factor = colorChrome === 'strong' ? 0.25 : 0.12;
// Boost reds and blues slightly, deep shadows get more saturated
const chromeMat = [
1 + factor, -factor/2, -factor/2, 0, 0,
-factor/2, 1, -factor/2, 0, 0,
-factor/2, -factor/2, 1 + factor, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(chromeMat, matrix);
}
// 5. White Balance (Kelvin Temperature and Tint)
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,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(expMat, matrix);
}
return matrix;
}
// 4x5 Matrix multiplication utility: A * B
function multiplyMatrices(a: number[], b: number[]): number[] {
const result = new Array(20).fill(0);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
result[r * 5 + c] =
a[r * 5 + 0] * b[0 * 5 + c] +
a[r * 5 + 1] * b[1 * 5 + c] +
a[r * 5 + 2] * b[2 * 5 + c] +
a[r * 5 + 3] * b[3 * 5 + c];
}
// Handle the 5th column translation translation
result[r * 5 + 4] =
a[r * 5 + 0] * b[0 * 5 + 4] +
a[r * 5 + 1] * b[1 * 5 + 4] +
a[r * 5 + 2] * b[2 * 5 + 4] +
a[r * 5 + 3] * b[3 * 5 + 4] +
a[r * 5 + 4];
}
return result;
}
+104
View File
@@ -0,0 +1,104 @@
import { Recipe } from '../types';
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
name: 'CLASSIC NEG.',
baseFilter: 'classic-neg',
adjustments: {
exposure: 2,
contrast: 1,
saturation: -1,
temperature: 6300,
tint: 2,
highlight: -2,
shadow: 1,
denoise: 1,
clarity: 2,
grain: 4,
colorChrome: 'weak',
},
frameId: 'none',
useGeotag: true,
},
{
id: 'velvia-default',
name: 'VELVIA VIVID',
baseFilter: 'velvia',
adjustments: {
exposure: 0,
contrast: 2,
saturation: 3,
temperature: 5500,
tint: 0,
highlight: 1,
shadow: -1,
denoise: 2,
clarity: 4,
grain: 1,
colorChrome: 'strong',
},
frameId: 'none',
useGeotag: true,
},
{
id: 'provia-default',
name: 'PROVIA STD',
baseFilter: 'provia',
adjustments: {
exposure: 1,
contrast: 0,
saturation: 0,
temperature: 5000,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 2,
clarity: 1,
grain: 2,
colorChrome: 'none',
},
frameId: 'none',
useGeotag: true,
},
{
id: 'acros-default',
name: 'ACROS MONO',
baseFilter: 'monochrome',
adjustments: {
exposure: 0,
contrast: 4,
saturation: 0,
temperature: 5000,
tint: 0,
highlight: 3,
shadow: -2,
denoise: 0,
clarity: 3,
grain: 6,
colorChrome: 'none',
},
frameId: 'none',
useGeotag: true,
},
{
id: 'retro-amber-default',
name: 'RETRO AMBER 🏮',
baseFilter: 'classic-neg',
adjustments: {
exposure: 3,
contrast: 2,
saturation: 1,
temperature: 7500,
tint: -2,
highlight: -3,
shadow: 2,
denoise: 3,
clarity: -2,
grain: 5,
colorChrome: 'strong',
},
frameId: 'classic-white',
useGeotag: true,
},
];
+193
View File
@@ -0,0 +1,193 @@
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 { Asset } from 'expo-asset';
import { Recipe, GPSInfo, FrameId } from '../types';
import { getSkiaColorMatrix } from './colorUtils';
import { drawFrameOnCanvas } from './frameUtils';
import { formatCoordinate } from './locationUtils';
export async function processAndExportPhoto(
sourceUri: string,
recipe: Recipe,
frameId: FrameId,
useGeotag: boolean,
gpsInfo: GPSInfo | null
): Promise<string | null> {
try {
// 1. Read source image file into Skia
const skiaData = await Skia.Data.fromURI(sourceUri);
if (!skiaData) {
console.error('Failed to load image data from URI');
return null;
}
const skImage = Skia.Image.MakeImageFromEncoded(skiaData);
if (!skImage) {
console.error('Failed to parse image from URI');
return null;
}
const width = skImage.width();
const height = skImage.height();
// 2. Create offscreen canvas
const surface = Skia.Surface.Make(width, height);
if (!surface) {
console.error('Failed to create Skia surface');
return null;
}
const canvas = surface.getCanvas();
const paint = Skia.Paint();
const adjustments = recipe.adjustments;
// 3. Build Color Matrix Filter
const matrix = getSkiaColorMatrix(
recipe.baseFilter,
adjustments.exposure,
adjustments.saturation,
adjustments.temperature,
adjustments.tint,
adjustments.colorChrome
);
const colorFilter = Skia.ColorFilter.MakeMatrix(matrix);
paint.setColorFilter(colorFilter);
// 4. Highlight & Shadow / Denoise / Clarity Image Filters
let imageFilter = null;
// Denoise (Subtle blur to smooth noise)
if (adjustments.denoise > 0) {
const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px
imageFilter = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
}
// Clarity (Convolution Sharpening if positive)
if (adjustments.clarity > 0) {
const sharpAmount = (adjustments.clarity / 10) * 0.8;
const kernel = [
0, -sharpAmount, 0,
-sharpAmount, 1 + 4 * sharpAmount, -sharpAmount,
0, -sharpAmount, 0,
];
const sharpenFilter = Skia.ImageFilter.MakeMatrixConvolution(
3,
3,
kernel,
1,
0,
0,
0,
TileMode.Clamp,
true,
imageFilter
);
if (sharpenFilter) {
imageFilter = sharpenFilter;
}
} else if (adjustments.clarity < 0) {
// Bloom/mist effect if negative
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
const mistFilter = Skia.ImageFilter.MakeBlur(mistSigma, mistSigma, TileMode.Clamp, imageFilter);
if (mistFilter) {
imageFilter = mistFilter;
}
}
if (imageFilter) {
paint.setImageFilter(imageFilter);
}
// 5. Draw primary image with color filters & enhancements
canvas.drawImage(skImage, 0, 0, paint);
// 6. Add Monochrome Grain Overlay
if (adjustments.grain > 0) {
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
const grainPaint = Skia.Paint();
grainPaint.setBlendMode(BlendMode.Overlay);
grainPaint.setAlphaf(grainOpacity);
// Procedural noise shader
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
return vec4(vec3(r), 1.0);
}
`);
const noiseShader = noiseEffect ? noiseEffect.makeShader([]) : null;
if (noiseShader) {
grainPaint.setShader(noiseShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
}
}
// 7. Render Frame Border (Polaroid / Classic White / Cinematic)
drawFrameOnCanvas(canvas, width, height, frameId);
// 8. Render GPS Geotag Watermark
if (useGeotag && gpsInfo) {
const fontAsset = Asset.fromModule(require('../../assets/CourierPrime-Regular.ttf'));
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);
const textPaint = Skia.Paint();
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
const latStr = formatCoordinate(gpsInfo.latitude, 'lat');
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
const locationName = gpsInfo.locality || 'STREET VIEW';
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
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);
}
}
// 9. Snapshot & Encode to JPEG
const resultImage = surface.makeImageSnapshot();
const jpegBytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
if (!jpegBytes) {
console.error('Failed to encode image to JPEG bytes');
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, {
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);
} else {
console.warn('Media Library permission denied. Image saved to temporary cache only.');
}
return tempFileUri;
} catch (error) {
console.error('Error during photo processing and export:', error);
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia';
import { FrameId } from '../types';
export interface FrameDefinition {
id: FrameId;
name: string;
}
export const FRAMES: FrameDefinition[] = [
{ id: 'none', name: 'NO FRAME' },
{ id: 'classic-white', name: 'CLASSIC BORDER' },
{ id: 'polaroid', name: 'RETRO POLAROID' },
{ id: 'cinematic', name: 'CINEMATIC BARS' },
];
export function drawFrameOnCanvas(
canvas: SkCanvas,
width: number,
height: number,
frameId: FrameId
) {
if (frameId === 'none') return;
const paint = Skia.Paint();
if (frameId === 'classic-white') {
// A clean white border around the picture
paint.setColor(Skia.Color('#ffffff'));
paint.setStyle(0); // Fill
const borderWidth = Math.min(width, height) * 0.05; // 5% border
// Top border
canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint);
// Bottom border
canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint);
// Left border
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
// Right border
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
} else if (frameId === 'polaroid') {
// Polaroid style: thick white borders, thicker at the bottom
paint.setColor(Skia.Color('#faf9f6')); // Off-white
paint.setStyle(0); // Fill
const sideBorder = Math.min(width, height) * 0.06; // 6% borders on sides/top
const bottomBorder = height * 0.18; // 18% border at bottom
// Top border
canvas.drawRect(Skia.XYWHRect(0, 0, width, sideBorder), paint);
// Left border
canvas.drawRect(Skia.XYWHRect(0, 0, sideBorder, height), paint);
// Right border
canvas.drawRect(Skia.XYWHRect(width - sideBorder, 0, sideBorder, height), paint);
// Bottom border
canvas.drawRect(Skia.XYWHRect(0, height - bottomBorder, width, bottomBorder), paint);
// Inner thin border (grey line separating image and card)
const innerPaint = Skia.Paint();
innerPaint.setColor(Skia.Color('#e5e5e5'));
innerPaint.setStrokeWidth(1);
innerPaint.setStyle(1); // Stroke
canvas.drawRect(
Skia.XYWHRect(
sideBorder - 1,
sideBorder - 1,
width - 2 * sideBorder + 2,
height - sideBorder - bottomBorder + 2
),
innerPaint
);
} else if (frameId === 'cinematic') {
// Cinematic black bars on top and bottom
paint.setColor(Skia.Color('#000000'));
paint.setStyle(0); // Fill
const barHeight = height * 0.12; // 12% cinematic letterbox height
// Top letterbox
canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint);
// Bottom letterbox
canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint);
}
}
+61
View File
@@ -0,0 +1,61 @@
import * as Location from 'expo-location';
import { GPSInfo } from '../types';
export async function requestLocationPermissions(): Promise<boolean> {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
return status === 'granted';
} catch (error) {
console.error('Error requesting location permissions:', error);
return false;
}
}
export async function getCurrentGPS(): Promise<GPSInfo | null> {
try {
const hasPermission = await requestLocationPermissions();
if (!hasPermission) return null;
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const { latitude, longitude } = location.coords;
let locality = 'UNKNOWN LOCATION';
let country = '';
try {
const geocode = await Location.reverseGeocodeAsync({ latitude, longitude });
if (geocode && geocode.length > 0) {
const address = geocode[0];
// Combine city, region, street or subregion to form a retro looking place string
locality = address.subregion || address.city || address.district || address.region || 'LOCAL REGION';
locality = locality.toUpperCase();
country = (address.country || '').toUpperCase();
}
} catch (e) {
console.warn('Reverse geocoding failed, using coordinates only:', e);
}
return {
latitude,
longitude,
locality,
country,
timestamp: location.timestamp,
};
} catch (error) {
console.error('Error fetching GPS info:', error);
return null;
}
}
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
const absolute = Math.abs(val);
const degrees = Math.floor(absolute);
const minutes = Math.floor((absolute - degrees) * 60);
const seconds = ((absolute - degrees - minutes / 60) * 3600).toFixed(2);
const direction = type === 'lat' ? (val >= 0 ? 'N' : 'S') : (val >= 0 ? 'E' : 'W');
return `${degrees}° ${minutes}' ${seconds}" ${direction}`;
}
+40
View File
@@ -0,0 +1,40 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Recipe } from '../types';
import { DEFAULT_RECIPES } from './defaultRecipes';
const CUSTOM_RECIPES_KEY = '@camrecipe_pro:custom_recipes';
export async function getCustomRecipes(): Promise<Recipe[]> {
try {
const data = await AsyncStorage.getItem(CUSTOM_RECIPES_KEY);
if (!data) return [];
return JSON.parse(data);
} catch (error) {
console.error('Failed to load custom recipes:', error);
return [];
}
}
export async function getAllRecipes(): Promise<Recipe[]> {
const custom = await getCustomRecipes();
return [...DEFAULT_RECIPES, ...custom];
}
export async function saveCustomRecipe(recipe: Omit<Recipe, 'id' | 'isCustom'>): Promise<Recipe> {
const custom = await getCustomRecipes();
const newRecipe: Recipe = {
...recipe,
id: `custom_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
isCustom: true,
};
const updated = [...custom, newRecipe];
await AsyncStorage.setItem(CUSTOM_RECIPES_KEY, JSON.stringify(updated));
return newRecipe;
}
export async function deleteCustomRecipe(id: string): Promise<void> {
const custom = await getCustomRecipes();
const updated = custom.filter(r => r.id !== id);
await AsyncStorage.setItem(CUSTOM_RECIPES_KEY, JSON.stringify(updated));
}