diff --git a/.expo/dev/logs/start.log b/.expo/dev/logs/start.log index 634003d..26a5a09 100644 --- a/.expo/dev/logs/start.log +++ b/.expo/dev/logs/start.log @@ -175,3 +175,77 @@ {"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} {"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} {"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:bundling:started","_t":1784256200326,"id":"7","platform":"android","environment":null,"entry":"index.ts"} +{"_e":"metro:bundling:progress","_t":1784256200326,"id":"7","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784256200332,"id":"7","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784256200434,"id":"7","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:done","_t":1784256200538,"id":"7","total":1,"ms":211.987279} +{"_e":"metro:client_log","_t":1784256223781,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]} +{"_e":"metro:client_log","_t":1784256310429,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:client_log","_t":1784256369062,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:client_log","_t":1784256387012,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:client_log","_t":1784256420271,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:bundling:started","_t":1784258596497,"id":"9","platform":"android","environment":null,"entry":"index.ts"} +{"_e":"metro:bundling:progress","_t":1784258596520,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258596532,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258596843,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597094,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597194,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597294,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597394,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597494,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597594,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597694,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597809,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258597925,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784258598025,"id":"9","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:done","_t":1784258598321,"id":"9","total":1,"ms":1802.095472} +{"_e":"metro:client_log","_t":1784258647493,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:bundling:started","_t":1784261637702,"id":"b","platform":"android","environment":null,"entry":"index.ts"} +{"_e":"metro:bundling:progress","_t":1784261637702,"id":"b","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784261637708,"id":"b","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784261637808,"id":"b","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784261638016,"id":"b","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784261638197,"id":"b","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:done","_t":1784261638447,"id":"b","total":1,"ms":744.986372} +{"_e":"metro:client_log","_t":1784261670156,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:client_log","_t":1784261697357,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]} +{"_e":"metro:bundling:started","_t":1784263415011,"id":"d","platform":"android","environment":null,"entry":"index.ts"} +{"_e":"metro:bundling:progress","_t":1784263415013,"id":"d","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784263415016,"id":"d","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784263415116,"id":"d","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784263415858,"id":"d","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:done","_t":1784263416167,"id":"d","total":1,"ms":1153.731646} +{"_e":"metro:bundling:started","_t":1784266840468,"id":"f","platform":"android","environment":null,"entry":"index.ts"} +{"_e":"metro:bundling:progress","_t":1784266840469,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266840484,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266840743,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266840992,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841361,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841461,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841561,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841661,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841761,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841861,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266841961,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266842061,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266842173,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266842275,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266842846,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266843171,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266843304,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266843413,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266844439,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266844538,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266844818,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266844918,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266844939,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845040,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845141,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845241,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845341,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845443,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845546,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:progress","_t":1784266845580,"id":"f","progress":0,"total":1,"current":0} +{"_e":"metro:bundling:done","_t":1784266846113,"id":"f","total":1,"ms":5644.099564} +{"_e":"metro:client_log","_t":1784266851912,"level":"error","data":["[TypeError: undefined is not a function]"]} diff --git a/.expo/devices.json b/.expo/devices.json index e5fbb08..2f654d4 100644 --- a/.expo/devices.json +++ b/.expo/devices.json @@ -2,7 +2,7 @@ "devices": [ { "installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449", - "lastUsed": 1784254612714 + "lastUsed": 1784266840311 } ] } diff --git a/2_CAMERA.md b/2_CAMERA.md new file mode 100644 index 0000000..4e1ce7e --- /dev/null +++ b/2_CAMERA.md @@ -0,0 +1,254 @@ + +# Kế Hoạch Khắc Phục Lỗi Giao Diện & Bộ Lọc Đồ Họa CamRecipe Pro + +Tài liệu này đề xuất phương án kỹ thuật chi tiết nhằm khắc phục triệt để các lỗi giao diện và bộ lọc màu trên phiên bản di động hiện tại, dựa theo giao diện thực tế trong tệp tin `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`. + +--- + +## 1. Chuyển đổi chức năng nút biểu tượng Con Mắt (Eye Button) khi ở chế độ Thư viện + +### Vấn đề kỹ thuật + +Như mô tả trong `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`, khi ứng dụng đang ở chế độ chỉnh sửa ảnh từ thư viện (Library/Edit Mode - nút lưu màu cam nằm ở trung tâm), nút góc dưới bên phải có biểu tượng con mắt vẫn hiển thị. Theo logic tối ưu hóa trải nghiệm người dùng, nút này cần trở thành phím tắt nhanh để quay ngược lại chế độ chụp ảnh trực tiếp (Camera Mode). + +### Giải pháp khắc phục + +Chúng ta sẽ thay đổi động thuộc tính `onPress` và biểu tượng (Icon) của nút điều khiển góc dưới bên phải dựa vào trạng thái nguồn ảnh hiện tại `currentInputSource`: + +* **Nếu `currentInputSource === 'camera'`:** Nút đóng vai trò là nút bật/tắt lưới Grid hoặc Xem trước nhanh (Preview/Overlay). + + +* **Nếu `currentInputSource === 'library'`:** Nút chuyển đổi biểu tượng thành Camera (`aperture` hoặc `camera` từ `lucide-react-native`) và khi chạm vào sẽ kích hoạt hàm chuyển chế độ `switchInputSource('camera')`. + + + +```javascript +// Logic render nút chức năng góc dưới bên phải + switchInputSource('camera') : handleToggleGrid} + className="w-12 h-12 rounded-full bg-zinc-900/80 items-center justify-center" +> + {currentInputSource === 'library' ? ( + // Icon Camera thay thế cho icon con mắt khi đang ở chế độ ảnh thư viện + + ) : ( + // Giữ nguyên icon con mắt khi đang ở chế độ camera native + + )} + + +``` + +--- + +## 2. Khắc phục lỗi Topbar bị chèn dưới thanh trạng thái (Status Bar) hệ thống + +### Vấn đề kỹ thuật + +Phần đầu của ứng dụng chứa tiêu đề "CAMRECIPE PRO" và các phím chế độ "CAMERA", "LIBRARY" đang bị đẩy lên kịch trần màn hình và bị chèn lấp bởi các biểu tượng thông báo hệ thống (sóng wifi, cột sóng nhà mạng, phần trăm pin) như trong ảnh `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`. + +### Giải pháp khắc phục + +Hiện tượng này xảy ra do lớp bọc ngoài cùng (Main Container) chưa được xử lý vùng an toàn chuẩn xác trên cả hai hệ điều hành. Chúng ta sẽ sử dụng thư viện chuẩn `react-native-safe-area-context` để lấy chính xác chiều cao vùng khuyết (Notch/Dynamic Island) của từng thiết bị di động: + +* **Cài đặt thư viện an toàn:** Sử dụng gói `react-native-safe-area-context`. + + +* **Áp dụng hook `useSafeAreaInsets`:** Lấy giá trị viền đệm top thời gian thực của thiết bị. + + +* **Cấu hình đệm động cho phần Header:** + +```typescript +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function MainLayout() { + const insets = useSafeAreaInsets(); + + return ( + + {/* Header Container được đệm an toàn tự động tránh Status Bar */} + + + + CAMRECIPE PRO + + {/* Toggle buttons... */} + + + {/* Phần Viewfinder và thân dưới ứng dụng */} + + ); +} + +``` + +--- + +## 3. Khắc phục lỗi Live Viewfinder bị mờ nhòe (Blur Overlay) khi áp dụng Recipe + +### Vấn đề kỹ thuật + +Khi người dùng kích hoạt live preview của recipe, kính ngắm camera hiển thị một lớp mờ sương, khiến độ sắc nét bị giảm mạnh so với camera native của thiết bị. Lỗi này xuất phát từ việc chuỗi lọc hình ảnh `Skia.ImageFilter` hoặc Gaussian Blur bị cấu hình sai trị số mặc định (luôn áp dụng một mức mờ tối thiểu lớn hơn $0$ dù người dùng không giảm Clarity hay bật Denoise). + +### Giải pháp khắc phục + +Chúng ta cần cô lập hoàn toàn bộ lọc mờ và chỉ cho phép kích hoạt khi và chỉ khi các giá trị tương ứng (Clarity âm hoặc Denoise) được thiết lập một cách chủ động từ người dùng: + +* **Kiểm tra điều kiện biên (Boundary Check):** Trước khi đưa ảnh qua bộ lọc Blur, thực hiện kiểm tra điều kiện. Nếu `clarity >= 0` và `grain_strength >= 0` (không ở trạng thái denoise), chúng ta sẽ bỏ qua hoàn toàn (bypass) bộ lọc nhòe và trả lại luồng ảnh gốc sắc nét nguyên bản từ phần cứng. + + +* **Đồng bộ hóa luồng lọc trên GPU:** + +```typescript +// Logic tối ưu hóa bộ lọc Skia tránh mờ nhòe viewfinder +const getActiveFilters = (clarity: number, grainStrength: number) => { + const filters = []; + + // Chỉ áp dụng Blur khi người dùng chủ động giảm Clarity xuống mức âm (để giả lập Black Mist) + if (clarity < 0) { + const blurSigma = Math.abs(clarity) * 0.45; // khống chế độ lệch chuẩn siêu mịn + filters.push(Skia.ImageFilter.MakeBlur(blurSigma, blurSigma, TileMode.Clamp, null)); + } + + // Chỉ áp dụng Denoise mượt mà khi mật độ hạt (grain_strength) được kéo về phía âm + if (grainStrength < 0) { + const denoiseSigma = Math.abs(grainStrength) * 0.35; // Làm mịn hạt nhưng giữ chi tiết rìa + filters.push(Skia.ImageFilter.MakeBlur(denoiseSigma, denoiseSigma, TileMode.Clamp, null)); + } + + return filters; +}; + +``` + +--- + +## 4. Khắc phục lỗi chế độ Monochrome (ACROS MONO) vẫn hiển thị màu sắc trên Camera + +### Vấn đề kỹ thuật + +Mặc dù người dùng đã bấm chọn bộ màu trắng đen ACROS MONO (như đang hiển thị viền vàng hổ phách xung quanh nút chọn ở góc dưới), luồng hình ảnh truyền từ Camera trực tiếp lên kính ngắm vẫn có xu hướng bị rò rỉ màu sắc (không chuyển đổi xám hoàn toàn). Nguyên nhân là do ma trận chuyển đổi màu sắc (`ColorMatrix`) của ACROS sử dụng các hệ số kênh chưa chuẩn, hoặc chưa khóa (clamp) tuyệt đối dải bão hòa màu sắc (Saturation) về $0$. + +### Giải pháp khắc phục + +Để tạo ra chất ảnh trắng đen ACROS nghệ thuật chuẩn xác nhất, chúng phải triệt tiêu hoàn toàn thông tin màu bằng cách áp dụng chính xác các trọng số độ chói (Luma coefficients) theo tiêu chuẩn truyền hình quốc tế ITU-R BT.601 hoặc BT.709: + +$$Y = 0.299 \cdot R + 0.587 \cdot G + 0.114 \cdot B$$ + +Chúng ta sẽ cấu hình ma trận màu Skia cho bộ lọc ACROS MONO như sau: + +```typescript +// Ma trận màu xám đơn sắc tuyệt đối (Monochrome ColorMatrix) +const acrosMatrix = [ + 0.299, 0.587, 0.114, 0, 0, // Kênh Đỏ chỉ lấy thông tin độ chói luma + 0.299, 0.587, 0.114, 0, 0, // Kênh Xanh lá tương tự + 0.299, 0.587, 0.114, 0, 0, // Kênh Xanh dương tương tự + 0, 0, 0, 1, 0 // Kênh Alpha giữ nguyên +]; + +// Áp dụng bộ lọc phần cứng ma trận màu sắc triệt tiêu hoàn toàn màu gốc +const colorFilter = Skia.ColorFilter.Matrix( + currentFilmSim === 'acros' ? acrosMatrix : standardMatrix +); + +``` + +Bằng việc ép tất cả các hệ số đóng góp kênh màu về đúng tỷ lệ độ chói hồng ngoại của dải xám, camera native khi chuyển sang chế độ ACROS MONO sẽ không còn bất kỳ một vệt màu hay sắc độ ấm/lạnh nào bị rò rỉ lên Viewfinder. + +--- + +## 5. Khắc phục lỗi GPS Geotag Watermark không đọc vị trí gốc của ảnh thư viện + +### Vấn đề kỹ thuật + +Khi người dùng tải lên một bức ảnh cũ từ thư viện (ví dụ: ảnh chụp tại TAM KỲ trong tệp `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`), hệ thống Watermark lại tự động lấy vị trí định vị GPS thời gian thực của thiết bị điện thoại hiện tại. Điều này làm mất đi tính chính xác lịch sử của bức ảnh và phá hỏng cơ chế đóng dấu lưu niệm tự động (EXIF Geotagging). + +### Giải pháp khắc phục + +Chúng ta sẽ xây dựng một hàm phân tích đa tầng để ưu tiên trích xuất tọa độ GPS gốc lưu trong dữ liệu EXIF của chính tệp tin ảnh, và chỉ sử dụng GPS của điện thoại làm phương án dự phòng cuối cùng (fallback): + +#### Tầng 1: Sử dụng API Native truy vấn siêu dữ liệu (Expo Media Library) + +Khi lấy ảnh từ Thư viện qua `expo-image-picker` hoặc `expo-media-library`, chúng ta truy vấn thông tin chi tiết bằng mã định danh tài sản `assetId`: + +```typescript +import * as MediaLibrary from 'expo-media-library'; + +const getPhotoMetadata = async (assetId: string) => { + try { + const assetInfo = await MediaLibrary.getAssetInfoAsync(assetId); + if (assetInfo.location) { + // Trích xuất tọa độ địa lý gốc từ EXIF ảnh hệ thống + return { + latitude: assetInfo.location.latitude, + longitude: assetInfo.location.longitude, + source: 'EXIF' + }; + } + } catch (err) { + console.warn("Không thể đọc EXIF bằng MediaLibrary API:", err); + } + return null; +}; + +``` + +#### Tầng 2: Bộ phân tích nhị phân EXIF dự phòng (Cho ảnh từ Cloud/Nguồn ngoài) + +Nếu ảnh được truyền dưới dạng URI tạm thời không có `assetId`, chúng ta sử dụng gói phân tích nhị phân siêu nhẹ `exif-reader` để đọc trực tiếp byte đầu vào: + +```typescript +import ExifReader from 'exif-reader'; +import * as FileSystem from 'expo-file-stream'; // Hoặc expo-file-system + +const parseRawExifGPS = async (fileUri: string) => { + try { + const base64Data = await FileSystem.readAsStringAsync(fileUri, { encoding: 'base64' }); + const arrayBuffer = Buffer.from(base64Data, 'base64'); + const tags = ExifReader(arrayBuffer); + + if (tags.gps && tags.gps.GPSLatitude && tags.gps.GPSLongitude) { + return { + latitude: tags.gps.GPSLatitude, + longitude: tags.gps.GPSLongitude, + source: 'RawEXIF' + }; + } + } catch (err) { + console.warn("Không tìm thấy dữ liệu GPS EXIF trong tệp tin:", err); + } + return null; +}; + +``` + +#### Tầng 3: Hàm hợp nhất Geotag thông minh + +Kết hợp logic đa tầng tuần tự để ra quyết định định vị chính xác: + +```typescript +const resolvePhotoLocation = async (imageAsset: any) => { + // Bước 1: Thử đọc GPS gốc từ album hệ thống (EXIF) + let gpsData = await getPhotoMetadata(imageAsset.id); + + // Bước 2: Thử phân tích nhị phân nếu bước 1 không ra kết quả + if (!gpsData) { + gpsData = await parseRawExifGPS(imageAsset.uri); + } + + // Bước 3: Nếu ảnh không chứa tọa độ gốc, dùng GPS của điện thoại làm dự phòng + if (!gpsData) { + const currentPhoneLocation = await getCurrentPhoneGPS(); + gpsData = { ...currentPhoneLocation, source: 'DeviceGPS' }; + } + + // Bước 4: Gọi Reverse-Geocoding để in dấu địa danh nghệ thuật lên Canvas + const locationName = await reverseGeocode(gpsData.latitude, gpsData.longitude); + updateWatermarkUI(locationName, gpsData.latitude, gpsData.longitude); +}; + +``` \ No newline at end of file diff --git a/3_FIX_RECIPE.md b/3_FIX_RECIPE.md new file mode 100644 index 0000000..b56a6dd --- /dev/null +++ b/3_FIX_RECIPE.md @@ -0,0 +1,94 @@ +Kế Hoạch Sửa Lỗi Bộ Lọc Màu & Đồng Bộ Định Vị CamRecipe ProTài liệu này tập trung giải quyết triệt để các lỗi hiển thị hình ảnh trên kính ngắm (Viewfinder) di động thực tế dựa theo các tệp tin hình ảnh báo lỗi đính kèm từ người dùng.1. Khắc Phục Lỗi Nhiệt Độ Màu (White Balance Kelvin) Bị NgượcVấn đề kỹ thuật (Ảnh 3 & Ảnh 4)Hiện trạng thực tế:Như trong Screenshot_2026-07-17-10-25-59-810_com.locphamtran.recipescamera.jpg, khi kéo thanh trượt WB lên kịch khung 10000K, kính ngắm camera lại bị ám xanh dương lạnh ngắt.Ngược lại, trong Screenshot_2026-07-17-10-25-55-474_com.locphamtran.recipescamera.jpg, khi hạ WB xuống kịch khung 2500K, kính ngắm lại bị ám vàng/cam ấm rực rỡ.Nguyên nhân: Logic ánh xạ giữa giá trị Kelvin và ma trận bù trừ màu sắc (Color compensation matrix) bị nghịch đảo chéo góc. Trong nhiếp ảnh, khi cài đặt WB lên mức cao (ví dụ $10000\text{K}$), máy ảnh hiểu nguồn sáng môi trường đang rất lạnh nên phải bù trừ bằng sắc tông ấm (Amber/Orange). Khi cài đặt WB thấp ($2500\text{K}$), máy ảnh hiểu nguồn sáng rất ấm (Tungsten) nên phải bù trừ bằng dải tông lạnh (Blue) để đưa sắc trắng về trung tính.Giải pháp khắc phụcChúng ta sẽ viết lại thuật toán quy đổi Kelvin sang RGB bổ sung hệ số bù trừ ảnh nghịch đảo chính xác như sau:$$\text{Temp} = \frac{\text{Kelvin}}{100}$$Hệ số RGB bù trừ màu sắc thực tế sẽ được ánh xạ chính xác trên GPU:Nếu $\text{Kelvin} < 5500\text{K}$ (compensate for warm light): Giảm mạnh hệ số $R$, tăng hệ số $B$ để tạo tông màu xanh lạnh.Nếu $\text{Kelvin} \ge 5500\text{K}$ (compensate for cold light): Tăng mạnh hệ số $R$, giảm hệ số $B$ để tạo tông màu vàng ấm.// Sửa đổi hàm ánh xạ Kelvin trong file src/utils/kelvinToRGB.ts +export function kelvinToRGBCompensation(kelvin: number) { + const k = Math.min(10000, Math.max(2500, kelvin)); + let r = 1.0, g = 1.0, b = 1.0; + + if (k < 5500) { + // 2500K -> 5500K: Cần ám xanh dương để bù trừ ánh sáng ấm + const factor = (5500 - k) / 3000; // dải 0 -> 1 + r = 1.0 - factor * 0.45; + g = 1.0 - factor * 0.15; + b = 1.0 + factor * 0.50; // Tăng cường sắc xanh dương + } else { + // 5500K -> 10000K: Cần ám vàng/đỏ để bù trừ ánh sáng lạnh + const factor = (k - 5500) / 4500; // dải 0 -> 1 + r = 1.0 + factor * 0.55; // Tăng cường sắc đỏ ấm + g = 1.0 + factor * 0.20; + b = 1.0 - factor * 0.40; + } + + return { r, g, b }; +} +2. Khắc Phục Lỗi Rò Rỉ Màu Trên Chế Độ Monochrome (ACROS MONO)Vấn đề kỹ thuật (Ảnh 1)Hiện trạng thực tế: Như hiển thị trong Screenshot_2026-07-17-10-23-53-813_com.locphamtran.recipescamera.jpg, khi nhấn chọn bộ lọc đơn sắc ACROS MONO, dải đèn LED RGB trên bàn phím máy tính vẫn rực rỡ sắc đỏ, xanh, hồng tím thay vì hiển thị dải xám đen mộc mạc.Nguyên nhân: Logic áp màu phim bỏ qua bộ lọc trắng đen của Skia do điều kiện rẽ nhánh ma trận màu bị lỗi hoặc hệ số ma trận màu trắng đen chưa triệt tiêu hoàn toàn dải bão hòa màu sắc (Saturation) trên luồng camera thời gian thực.Giải pháp khắc phụcÉp các hệ số ma trận màu sắc của ACROS MONO về đúng tiêu chuẩn độ chói quốc tế ITU-R BT.709 nhằm triệt tiêu hoàn toàn thông tin màu sắc:$$Y = 0.2126 \cdot R + 0.7152 \cdot G + 0.0722 \cdot B$$// Khóa chặt ma trận lọc màu trắng đen tuyệt đối của Skia +const acrosMatrix = [ + 0.2126, 0.7152, 0.0722, 0, 0, // Kênh Đỏ chỉ lấy thông tin độ chói xám + 0.2126, 0.7152, 0.0722, 0, 0, // Kênh Xanh lá tương tự + 0.2126, 0.7152, 0.0722, 0, 0, // Kênh Xanh dương tương tự + 0, 0, 0, 1, 0 // Giữ nguyên kênh Alpha +]; + +// Áp dụng bộ lọc ma trận sắc độ xám đơn sắc chuẩn nhiếp ảnh +const activeColorFilter = currentRecipe.film_sim === 'acros' + ? Skia.ColorFilter.Matrix(acrosMatrix) + : Skia.ColorFilter.Matrix(standardColorMatrix); +3. Loại Bỏ Lớp Mờ Sương (Fog Overlay) Trên Viewfinder Khi Thay Đổi WBVấn đề kỹ thuật (Ảnh 2)Hiện trạng thực tế: Như trong Screenshot_2026-07-17-10-25-47-625_com.locphamtran.recipescamera.jpg, khi chỉnh nhiệt độ màu về đúng trị số 6600K, lớp mờ sương (blur) bỗng nhiên biến mất kỳ lạ giúp ảnh sắc nét hẳn lên.Nguyên nhân: Lỗi phân chia dải biến số (division/overflow error) trong hàm shader tự động làm mờ ảnh khi xử lý nhiệt độ màu. Bộ lọc Gaussian Blur (feGaussianBlur) bị liên kết sai lệch với biến nhiệt độ màu wb, khiến cho ở các dải nhiệt độ khác ngoài 6600K, bán kính mờ (stdDeviation) bị tính toán sai vượt ngưỡng $0$ mặc dù độ sắc nét hoặc độ mịn màng không thay đổi.Giải pháp khắc phụcCô lập hoàn toàn giá trị mờ sương khỏi bộ điều phối WB. Chỉ kích hoạt hiệu ứng mờ sương khi và chỉ khi người dùng chủ động tinh chỉnh dải thanh trượt Clarity < 0 hoặc Denoise < 0:// Tách biệt hoàn toàn tính toán bán kính mờ sương khỏi White Balance +const getBlurSigma = (clarity: number, grainStrength: number) => { + 'worklet'; + let blurSigma = 0; + + // Chỉ áp dụng hiệu ứng Bloom/Mist mềm mại khi Clarity ở mức âm + if (clarity < 0) { + blurSigma = Math.abs(clarity) * 0.45; // Khống chế bán kính mờ mịn màng + } + + // Chỉ áp dụng khử nhiễu (Denoise) làm mượt hạt khi Grain ở mức âm + if (grainStrength < 0) { + const denoiseSigma = Math.abs(grainStrength) * 0.35; + blurSigma = Math.max(blurSigma, denoiseSigma); + } + + return blurSigma; // Trả về 0 nếu các thông số này không ở mức âm +}; +4. Đồng Bộ Hóa Recipes Thời Gian Thực Trên Native Camera PreviewVấn đề kỹ thuậtHiện trạng thực tế: Các thông số phơi sáng trong tab EXPOSURE hay dải lọc màu trong tab WB & CHROME thay đổi trên thanh trượt nhưng kính ngắm camera trực tiếp vẫn trơ trơ không thay đổi màu sắc.Nguyên nhân: Biến trạng thái tùy chỉnh của React Native (adjustments) khi thay đổi không kích hoạt việc vẽ lại (re-render) trên luồng xử lý frame của camera (Vision Camera Frame Processor hoặc Skia Canvas), do thiếu các biến liên kết phụ thuộc (dependency array) hoặc chưa đưa giá trị thông số màu về dạng Shared Values của thư viện react-native-reanimated.Giải pháp khắc phụcSử dụng hook useSharedValue để truyền giá trị tức thời của thanh sliders sang luồng xử lý đồ họa GPU của Skia nhằm cập nhật kính ngắm ở tốc độ 60 FPS:import { useSharedValue, useDerivedValue } from 'react-native-reanimated'; + +export function ViewfinderCamera() { + // Đồng bộ hóa các dải thông số màu dưới dạng Shared Values của GPU + const exposureVal = useSharedValue(0.7); + const kelvinVal = useSharedValue(5800); + const colorChromeVal = useSharedValue('strong'); + + // Đăng ký lắng nghe sự thay đổi của sliders để cập nhật tức thì ma trận màu Skia + const skiaColorFilter = useDerivedValue(() => { + const matrix = calculateDynamicMatrix(exposureVal.value, kelvinVal.value, colorChromeVal.value); + return Skia.ColorFilter.Matrix(matrix); + }, [exposureVal, kelvinVal, colorChromeVal]); + + return ( + + + + + ); +} +5. Khắc Phục Lỗi Đọc GPS Định Vị Gốc Của Ảnh Thư ViệnVấn đề kỹ thuậtHiện trạng thực tế: Khi tải ảnh lên từ thư viện, Geotag watermark ở góc dưới bên trái vẫn hiển thị tọa độ GPS của vị trí điện thoại hiện tại, bỏ qua lịch sử vị trí chụp gốc lưu trong EXIF của tệp tin.Nguyên nhân: API chọn ảnh (expo-image-picker) theo mặc định chỉ trả về một đường dẫn URI tạm thời bị lược bỏ hoàn toàn các siêu dữ liệu EXIF nhạy cảm vì lý do bảo mật quyền riêng tư của hệ điều hành.Giải pháp khắc phụcSử dụng gói truy vấn tài sản chuyên sâu expo-media-library để yêu cầu hệ thống cung cấp siêu dữ liệu chi tiết của bức ảnh gốc thông qua mã định danh tài sản (assetId):import * as MediaLibrary from 'expo-media-library'; + +export const getOriginalPhotoLocation = async (assetId: string) => { + try { + // Yêu cầu quyền truy cập thư viện đầy đủ từ hệ điều hành + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status === 'granted') { + // Truy vấn thông tin chi tiết của ảnh gốc bao gồm tọa độ GPS chụp lịch sử + const assetInfo = await MediaLibrary.getAssetInfoAsync(assetId); + if (assetInfo.location) { + return { + latitude: assetInfo.location.latitude, + longitude: assetInfo.location.longitude, + source: 'EXIF_ORIGINAL' + }; + } + } + } catch (error) { + console.warn("Không thể trích xuất định vị EXIF gốc từ ảnh thư viện:", error); + } + return null; // Trả về null để chuyển sang định vị hiện tại làm dự phòng (fallback) +}; diff --git a/App.tsx b/App.tsx index 52109a0..b0da9d8 100644 --- a/App.tsx +++ b/App.tsx @@ -1,8 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { SafeAreaView, View, StatusBar, Alert, ActivityIndicator, Text } from 'react-native'; +import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useCameraPermissions } from 'expo-camera'; import * as ImagePicker from 'expo-image-picker'; +import * as MediaLibrary from 'expo-media-library'; import * as Haptics from 'expo-haptics'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import Header from './src/components/Header'; import Viewfinder from './src/components/Viewfinder'; @@ -13,12 +16,14 @@ import PreviewModal from './src/components/PreviewModal'; import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; -import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; +import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils'; import { processAndExportPhoto } from './src/utils/exportEngine'; // @ts-ignore import './global.css'; +const SESSION_KEY = 'saved_session'; + export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); const [recipes, setRecipes] = useState([]); @@ -49,14 +54,65 @@ export default function App() { const [previewVisible, setPreviewVisible] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - // Hardware Permissions + // Hardware Permissions (expo-camera) const [cameraPermission, requestCameraPermission] = useCameraPermissions(); useEffect(() => { loadRecipes(); - loadGPS(); + restoreSession(); + // Do NOT auto-load device GPS here — it would override photo GPS on library mode }, []); + // Save/restore session on app lifecycle changes + useEffect(() => { + const subscription = AppState.addEventListener('change', async (nextAppState) => { + if (nextAppState === 'background' || nextAppState === 'inactive') { + const session = { + mode, + libraryImageUri, + adjustments, + selectedFrame, + useGeotag, + selectedRecipeId: selectedRecipe?.id || null, + gpsInfo, + }; + try { + await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)); + } catch (e) { + console.warn('Failed to save session:', e); + } + } + }); + return () => subscription.remove(); + }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]); + + const restoreSession = async () => { + try { + const saved = await AsyncStorage.getItem(SESSION_KEY); + if (!saved) return; + const session = JSON.parse(saved); + if (session.libraryImageUri) { + setLibraryImageUri(session.libraryImageUri); + setMode('library'); + } else { + setMode(session.mode || 'camera'); + } + if (session.adjustments) setAdjustments(session.adjustments); + if (session.selectedFrame) setSelectedFrame(session.selectedFrame); + if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); + if (session.gpsInfo) setGpsInfo(session.gpsInfo); + if (session.selectedRecipeId) { + const allRecipes = await getAllRecipes(); + const found = allRecipes.find((r: Recipe) => r.id === session.selectedRecipeId); + if (found) { + setSelectedRecipe(found); + } + } + } catch (e) { + console.warn('Failed to restore session:', e); + } + }; + const loadRecipes = async () => { const list = await getAllRecipes(); setRecipes(list); @@ -150,15 +206,94 @@ export default function App() { mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 1, + exif: true, }); if (!result.canceled && result.assets && result.assets.length > 0) { - setLibraryImageUri(result.assets[0].uri); + const asset = result.assets[0]; + setLibraryImageUri(asset.uri); setMode('library'); + // Clear old GPS — will be replaced if photo has location data + setGpsInfo(null); + + // Multi-tier GPS resolution: EXIF → MediaLibrary.getAssetInfoAsync + const resolvedGps = await resolvePhotoLocation(asset); + if (resolvedGps) { + setGpsInfo(resolvedGps); + } else if (useGeotag) { + // Tier 3: photo has no GPS — fall back to device location + const deviceGps = await getCurrentGPS(); + if (deviceGps) { + setGpsInfo(deviceGps); + } + } + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }; + // Multi-tier GPS resolver: EXIF → MediaLibrary → Device GPS (fallback inside handlePickImage) + const resolvePhotoLocation = async (asset: any): Promise => { + // Tier 1: Extract GPS from ImagePicker EXIF + if (asset.exif && (asset.exif.GPSLatitude || asset.exif.GPSLongitude)) { + const lat = typeof asset.exif.GPSLatitude === 'number' + ? asset.exif.GPSLatitude + : parseGPS(asset.exif.GPSLatitudeRef || '', asset.exif.GPSLatitude); + const lon = typeof asset.exif.GPSLongitude === 'number' + ? asset.exif.GPSLongitude + : parseGPS(asset.exif.GPSLongitudeRef || '', asset.exif.GPSLongitude); + if (lat !== 0 || lon !== 0) { + return { + latitude: lat, + longitude: lon, + locality: undefined, + country: undefined, + timestamp: Date.now(), + }; + } + } + + // Tier 2: Use MediaLibrary.getAssetInfoAsync for richer metadata + // On Android, assetId is a content URI like "content://media/external/images/media/12345" + // but MediaLibrary expects just the numeric part "12345" + const rawId = asset.assetId || ''; + const numericId = rawId.replace(/^.*\/media\//, '').replace(/\/.*$/, ''); + if (numericId && /^\d+$/.test(numericId)) { + try { + const assetInfo = await MediaLibrary.getAssetInfoAsync(numericId); + if (assetInfo.location) { + const locality = await reverseGeocode(assetInfo.location.latitude, assetInfo.location.longitude) || undefined; + return { + latitude: assetInfo.location.latitude, + longitude: assetInfo.location.longitude, + locality, + country: undefined, + timestamp: Date.now(), + }; + } + } catch (e) { + console.warn('MediaLibrary getAssetInfoAsync failed:', e); + } + } + + return null; + }; + + // Helper to parse GPS coordinates from EXIF string format + const parseGPS = (ref: string, val: any): number => { + try { + if (Array.isArray(val)) { + const [degrees, minutes, seconds] = val.map(Number); + const decimal = degrees + minutes / 60 + seconds / 3600; + return (ref === 'S' || ref === 'W') ? -decimal : decimal; + } + if (typeof val === 'number') return val; + return 0; + } catch { + return 0; + } + }; + const handleCapture = async () => { if (mode === 'camera') { // For simulator & simplicity, if camera view isn't fully mocked, we use a placeholder or trigger camera picture. @@ -230,7 +365,7 @@ export default function App() { gpsInfo={gpsInfo} libraryImageUri={libraryImageUri} cameraPermissionGranted={!!cameraPermission?.granted} - onRequestCameraPermission={requestCameraPermission} + onRequestCameraPermission={requestCameraPermission as any} /> @@ -261,7 +396,8 @@ export default function App() { onCapture={handleCapture} onPickImage={handlePickImage} onOpenPreview={() => setPreviewVisible(true)} - isLibraryMode={mode === 'library' && !libraryImageUri} + isLibraryMode={mode === 'library'} + onSwitchToCamera={() => setMode('camera')} /> {/* Preview Modal */} diff --git a/package-lock.json b/package-lock.json index 8f8b70c..3c45332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "react-dom": "19.2.3", "react-native": "0.86.0", "react-native-reanimated": "4.5.0", + "react-native-safe-area-context": "^5.8.0", "react-native-web": "^0.21.2", "tailwindcss": "^3.4.19" }, @@ -6622,6 +6623,16 @@ "react-native-worklets": "0.10.x" } }, + "node_modules/react-native-safe-area-context": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", + "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-skia-android": { "version": "147.1.0", "resolved": "https://registry.npmjs.org/react-native-skia-android/-/react-native-skia-android-147.1.0.tgz", diff --git a/package.json b/package.json index cbd6d05..f903d11 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-dom": "19.2.3", "react-native": "0.86.0", "react-native-reanimated": "4.5.0", + "react-native-safe-area-context": "^5.8.0", "react-native-web": "^0.21.2", "tailwindcss": "^3.4.19" }, diff --git a/src/components/CameraControls.tsx b/src/components/CameraControls.tsx index b5eb021..684a0c5 100644 --- a/src/components/CameraControls.tsx +++ b/src/components/CameraControls.tsx @@ -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 ( @@ -28,28 +30,36 @@ export default function CameraControls({ - {/* Shutter Button */} + {/* Dynamic Center Button: Shutter or Save */} - - - + {isLibraryMode ? ( + + + + ) : ( + + + + + + )} - {/* Last Captured Image Preview Button */} + {/* Right Action Button: Eye (preview) or Camera (switch back) */} - {lastPhotoUri ? ( + {isLibraryMode ? ( + + ) : lastPhotoUri ? ( ) : ( diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index e8d4774..1d33c82 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -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({ - { + // Shared overlay components for both modes + const renderColorMatrixOverlay = (opacity: number = VIEWFINDER_OVERLAY_OPACITY) => ( + + + + + + + ); + + const renderGrainOverlay = () => { + if (grainOpacity <= 0 || !noiseEffect) return null; return ( - - {renderFrameOverlay()} - {renderGPSWatermark()} + + + + + + ); }; + const renderBorderOverlay = () => ( + + {renderFrameOverlay()} + {renderGPSWatermark()} + + ); + 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" > - - + + + {/* Grid */} @@ -206,38 +229,36 @@ export default function Viewfinder({ - {renderLiveOverlay()} + {/* ColorMatrix overlay shows the recipe effect on camera */} + {renderColorMatrixOverlay()} + {renderGrainOverlay()} + {renderBorderOverlay()} ); } + // Library mode return ( {libraryImageUri && skiaImage ? ( - - - - - - - {grainOpacity > 0 && noiseEffect && ( - - - - + <> + + + + - )} - - {renderFrameOverlay()} - {renderGPSWatermark()} - + {renderGrainOverlay()} + + {/* Border + GPS as separate canvas on top */} + {renderBorderOverlay()} + ) : ( diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index cc41a22..f22d1ef 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -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); diff --git a/src/utils/locationUtils.ts b/src/utils/locationUtils.ts index 928e7f6..ae5d1e6 100644 --- a/src/utils/locationUtils.ts +++ b/src/utils/locationUtils.ts @@ -50,6 +50,18 @@ export async function getCurrentGPS(): Promise { } } +export async function reverseGeocode(latitude: number, longitude: number): Promise { + 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);