fix: lỗi áp dụng recipe lên live view của camera

This commit is contained in:
2026-07-17 15:02:05 +07:00
parent 2775662b62
commit 4c419f266c
11 changed files with 715 additions and 98 deletions
+24 -14
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, TouchableOpacity, Image } from 'react-native';
import { Image as ImageIcon, Eye } from 'lucide-react-native';
import { Image as ImageIcon, Eye, Download, Camera } from 'lucide-react-native';
interface CameraControlsProps {
lastPhotoUri: string | null;
@@ -8,6 +8,7 @@ interface CameraControlsProps {
onPickImage: () => void;
onOpenPreview: () => void;
isLibraryMode: boolean;
onSwitchToCamera?: () => void;
}
export default function CameraControls({
@@ -16,6 +17,7 @@ export default function CameraControls({
onPickImage,
onOpenPreview,
isLibraryMode,
onSwitchToCamera,
}: CameraControlsProps) {
return (
<View className="flex-row items-center justify-between px-8 py-6 bg-carbon border-t border-zinc-800/50">
@@ -28,28 +30,36 @@ export default function CameraControls({
<ImageIcon size={20} color="#f59e0b" />
</TouchableOpacity>
{/* Shutter Button */}
{/* Dynamic Center Button: Shutter or Save */}
<TouchableOpacity
onPress={onCapture}
disabled={isLibraryMode}
className={`w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center ${
isLibraryMode ? 'opacity-40' : 'opacity-100'
}`}
className="relative items-center justify-center"
activeOpacity={0.8}
>
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center active:scale-95">
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
</View>
{isLibraryMode ? (
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center border-4 border-zinc-800">
<Download size={28} color="#000000" />
</View>
) : (
<View className="w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center">
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center">
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
</View>
</View>
)}
</TouchableOpacity>
{/* Last Captured Image Preview Button */}
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
<TouchableOpacity
onPress={onOpenPreview}
disabled={!lastPhotoUri}
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden"
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
className={`w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden ${
isLibraryMode ? '' : ''
}`}
activeOpacity={0.7}
>
{lastPhotoUri ? (
{isLibraryMode ? (
<Camera size={20} color="#f59e0b" />
) : lastPhotoUri ? (
<Image source={{ uri: lastPhotoUri }} className="w-full h-full object-cover" />
) : (
<Eye size={20} color="#a1a1aa" />
+58 -37
View File
@@ -1,10 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet, Dimensions, ActivityIndicator, TouchableOpacity } from 'react-native';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
import { CameraView } from 'expo-camera';
import {
Canvas,
Image as SkiaImage,
ColorMatrix,
useImage,
useFont,
Text as SkiaText,
@@ -13,6 +11,8 @@ import {
Skia,
Group,
Rect,
ColorMatrix,
Image as SkiaImage,
} from '@shopify/react-native-skia';
import { Recipe, GPSInfo, FrameId } from '../types';
import { getSkiaColorMatrix } from '../utils/colorUtils';
@@ -30,8 +30,8 @@ interface ViewfinderProps {
}
const { width: screenWidth } = Dimensions.get('window');
const viewfinderWidth = screenWidth - 32; // padding sides
const viewfinderHeight = (viewfinderWidth * 4) / 3; // 3:4 aspect ratio
const viewfinderWidth = screenWidth - 32;
const viewfinderHeight = (viewfinderWidth * 4) / 3;
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
@@ -40,6 +40,8 @@ const noiseEffect = Skia.RuntimeEffect.Make(`
}
`);
const VIEWFINDER_OVERLAY_OPACITY = 0.35;
export default function Viewfinder({
mode,
recipe,
@@ -50,13 +52,13 @@ export default function Viewfinder({
cameraPermissionGranted,
onRequestCameraPermission,
}: ViewfinderProps) {
// Load library image if available
const cameraRef = useRef(null);
const skiaImage = useImage(libraryImageUri || '');
// Load Courier Prime font for the GPS watermark
const customFont = useFont(
require('../../assets/CourierPrime-Regular.ttf'),
Math.round(viewfinderWidth * 0.032) // ~12-14px scaled dynamically
Math.round(viewfinderWidth * 0.032)
);
const adjustments = recipe.adjustments;
@@ -96,7 +98,6 @@ export default function Viewfinder({
<Rect x={0} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
<Rect x={viewfinderWidth - sideBorder} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
<Rect x={0} y={viewfinderHeight - bottomBorder} width={viewfinderWidth} height={bottomBorder} color="#faf9f6" />
<Rect
x={sideBorder - 1}
y={sideBorder - 1}
@@ -160,15 +161,36 @@ export default function Viewfinder({
);
};
const renderLiveOverlay = () => {
// Shared overlay components for both modes
const renderColorMatrixOverlay = (opacity: number = VIEWFINDER_OVERLAY_OPACITY) => (
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
<Group opacity={opacity}>
<ColorMatrix matrix={colorMatrix} />
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} color="white" />
</Group>
</Canvas>
);
const renderGrainOverlay = () => {
if (grainOpacity <= 0 || !noiseEffect) return null;
return (
<Canvas style={StyleSheet.absoluteFill}>
{renderFrameOverlay()}
{renderGPSWatermark()}
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
<Group blendMode="overlay">
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
</Canvas>
);
};
const renderBorderOverlay = () => (
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{renderFrameOverlay()}
{renderGPSWatermark()}
</Canvas>
);
if (mode === 'camera') {
if (!cameraPermissionGranted) {
return (
@@ -195,8 +217,9 @@ export default function Viewfinder({
style={{ width: viewfinderWidth, height: viewfinderHeight }}
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
>
<CameraView style={StyleSheet.absoluteFill} facing="back" />
<CameraView ref={cameraRef} style={StyleSheet.absoluteFill} facing="back" />
{/* 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%]" />
@@ -206,38 +229,36 @@ export default function Viewfinder({
<View className="h-[1px] w-full bg-white mb-[33%]" />
</View>
{renderLiveOverlay()}
{/* ColorMatrix overlay shows the recipe effect on camera */}
{renderColorMatrixOverlay()}
{renderGrainOverlay()}
{renderBorderOverlay()}
</View>
);
}
// Library mode
return (
<View
style={{ width: viewfinderWidth, height: viewfinderHeight }}
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
>
{libraryImageUri && skiaImage ? (
<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>
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group>
<ColorMatrix matrix={colorMatrix} />
<SkiaImage
image={skiaImage}
fit="cover"
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
/>
</Group>
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
</Canvas>
{renderGrainOverlay()}
</Canvas>
{/* Border + GPS as separate canvas on top */}
{renderBorderOverlay()}
</>
) : (
<View className="items-center justify-center p-6">
<Text className="text-zinc-500 font-mono text-center text-xs">
+43 -39
View File
@@ -1,45 +1,26 @@
// Tanner Helland's Kelvin to RGB approximation
// Kelvin range: 1000K to 40000K (we use 2500K to 10000K)
// Kelvin to RGB compensation for White Balance.
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
// the scene light is COLD and compensates with WARM tones (red/orange).
// When WB is set LOW (e.g. 2500K), the camera compensates with COOL tones (blue).
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;
const k = Math.min(10000, Math.max(2500, kelvin));
let r = 1.0, g = 1.0, b = 1.0;
// Red
if (temp <= 66) {
r = 255;
if (k < 5500) {
// 2500K → 5500K: Warm light → need blue/cyan compensation
const factor = (5500 - k) / 3000;
r = 1.0 - factor * 0.45;
g = 1.0 - factor * 0.15;
b = 1.0 + factor * 0.50;
} else {
r = temp - 60;
r = 329.698727446 * Math.pow(r, -0.1332047592);
r = Math.max(0, Math.min(255, r));
// 5500K → 10000K: Cool light → need warm/amber compensation
const factor = (k - 5500) / 4500;
r = 1.0 + factor * 0.55;
g = 1.0 + factor * 0.20;
b = 1.0 - factor * 0.40;
}
// 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 };
return { r, g, b };
}
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and adjustments
@@ -85,7 +66,7 @@ export function getSkiaColorMatrix(
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Classic black and white filter (luminance weights)
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
const r = 0.2126;
const g = 0.7152;
const b = 0.0722;
@@ -128,7 +109,10 @@ export function getSkiaColorMatrix(
matrix = multiplyMatrices(chromeMat, matrix);
}
// 5. White Balance (Kelvin Temperature and Tint)
// 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;
@@ -159,6 +143,26 @@ export function getSkiaColorMatrix(
return matrix;
}
// Compute a tint color + opacity from the matrix for camera overlay preview
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
const apply = (r: number, g: number, b: number, a: number) => {
const row = (offset: number) =>
matrix[offset + 0] * r + matrix[offset + 1] * g + matrix[offset + 2] * b + matrix[offset + 3] * a + matrix[offset + 4];
return { r: row(0), g: row(5), b: row(10), a: row(15) };
};
// Apply matrix to neutral white to get the filter's color cast
const white = apply(1, 1, 1, 1);
// Clamp and compute perceived intensity to determine opacity
const clamp = (v: number) => Math.max(0, Math.min(1, v));
const intensity = (white.r + white.g + white.b) / 3;
// Opacity is higher when the filter deviates from neutral
const deviation = Math.abs(white.r - intensity) + Math.abs(white.g - intensity) + Math.abs(white.b - intensity);
const opacity = Math.min(0.35, deviation * 2.5);
return { r: clamp(white.r), g: clamp(white.g), b: clamp(white.b), a: opacity };
}
// 4x5 Matrix multiplication utility: A * B
function multiplyMatrices(a: number[], b: number[]): number[] {
const result = new Array(20).fill(0);
+12
View File
@@ -50,6 +50,18 @@ export async function getCurrentGPS(): Promise<GPSInfo | null> {
}
}
export async function reverseGeocode(latitude: number, longitude: number): Promise<string | null> {
try {
const geo = await Location.reverseGeocodeAsync({ latitude, longitude });
if (geo && geo.length > 0) {
return (geo[0].subregion || geo[0].city || geo[0].region || '').toUpperCase() || null;
}
} catch (e) {
console.warn('Reverse geocode failed:', e);
}
return null;
}
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
const absolute = Math.abs(val);
const degrees = Math.floor(absolute);