Files
RecipesCam/1_FIX_CAMERA.md
2026-07-17 09:33:24 +07:00

134 lines
7.7 KiB
Markdown

Dưới đây là toàn bộ nội dung kế hoạch đã được chuyển đổi sang định dạng Markdown chuẩn, tối ưu hóa cấu trúc scannable với tiêu đề phân cấp, bảng biểu, danh sách và các khối code block được định dạng rõ ràng.
---
# Kế Hoạch Sửa Lỗi & Tối Ưu Hóa Ứng Dụng Recipes Camera
Tài liệu này tập trung giải quyết triệt để **4 vấn đề kỹ thuật lớn** được phát hiện trên phiên bản di động hiện tại của ứng dụng **Recipes Camera** (dựa trên giao diện thực tế trong tệp tin `Screenshot_2026-07-17-09-22-29-895_com.locphamtran.recipescamera.jpg`). Phương án sửa lỗi ưu tiên sử dụng các bộ thiết lập có sẵn, tối ưu hóa hiệu năng GPU và cải thiện trải nghiệm người dùng (UX).
---
## 1. Khắc phục lỗi: Kính ngắm (Viewfinder) Camera Native không áp dụng hiệu ứng Recipes
### Nguyên nhân lỗi
Trên thiết bị di động, luồng preview từ phần cứng camera (`Camera` của Expo hoặc `VisionCamera`) được render trực tiếp lên một bề mặt native riêng (**Native Surface**). Do đó, các bộ lọc CSS hoặc Canvas thông thường trong JavaScript không thể tác động lên Live Viewfinder này, dẫn đến việc kính ngắm vẫn hiển thị màu gốc dù người dùng đã chọn recipe khác.
### Giải pháp kỹ thuật (Sử dụng Skia Frame Processor có sẵn)
Thay vì dùng luồng preview thô, chúng ta sẽ định tuyến luồng frame của camera đi qua GPU của thiết bị bằng cách kết hợp **React Native Vision Camera (v3/v4)****React Native Skia**:
* **Sử dụng Frame Processor:** Kích hoạt Frame Processor từ `react-native-vision-camera`.
* **Vẽ trực tiếp lên Skia Canvas:** Truyền các Frame từ camera vào làm texture cho Skia Canvas thời gian thực.
* **Áp bộ lọc phần cứng của Skia:** Sử dụng cấu trúc lọc màu ma trận có sẵn của Skia (`Skia.ColorFilter.Matrix`) đè trực tiếp lên Canvas ngắm.
```typescript
// Trích đoạn giải pháp xử lý luồng Frame thời gian thực trên GPU di động
import { Camera, useFrameProcessor } from 'react-native-vision-camera';
import { Canvas, Image, ColorMatrix } from '@shopify/react-native-skia';
// Trong Component hiển thị Viewfinder
const frameProcessor = useFrameProcessor((frame) => {
'worklet';
// Đưa frame camera trực tiếp vào luồng xử lý của Skia GPU
const skiaImage = frame.toSkiaImage();
// Render và áp dụng ColorMatrix tức thì ở tốc độ 60 FPS
}, [currentMatrix]);
```
---
## 2. Nâng cấp UX: Thay đổi nút Shutter thành nút Lưu ảnh khi ở chế độ Thư viện
### Vấn đề UX
Khi người dùng tải ảnh từ thư viện lên ứng dụng để chỉnh sửa (**Library Mode**), nút bấm chính ở giữa dưới cùng vẫn là nút chụp ảnh (nút Shutter trắng tròn lớn), gây bối rối vì người dùng không biết nhấn vào đâu để lưu bức ảnh đã áp công thức màu mới.
### Giải pháp kỹ thuật (Giao diện động)
Chúng ta sẽ chuyển đổi trạng thái của nút điều khiển chính dựa vào giá trị của biến trạng thái nguồn ảnh `currentInputSource`:
| Trạng thái `currentInputSource` | Giao diện nút bấm hiển thị | Hành vi khi kích hoạt (`onPress`) |
| --- | --- | --- |
| **`'camera'`** | Nút Shutter trắng tròn cổ điển để chụp ảnh native. | Chạy hàm chụp ảnh `handleTakePhoto` |
| **`'library'`** | Nút Lưu ảnh (Save/Export Button) với icon tải xuống nổi bật (`download`/`save`). | Chạy trực tiếp hàm kết xuất và lưu ảnh `captureAndSave()` |
```javascript
// Logic kết xuất giao diện động tại thanh điều khiển camera
<TouchableOpacity
onPress={currentInputSource === 'camera' ? handleTakePhoto : captureAndSave}
className="relative items-center justify-center"
>
{currentInputSource === 'camera' ? (
// Nút Shutter chụp ảnh
<View className="w-16 h-16 rounded-full bg-white border-4 border-black" />
) : (
// Nút Lưu ảnh khi chỉnh sửa ảnh từ thư viện
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center">
<DownloadIcon stroke="#000" size={28} />
</View>
)}
</TouchableOpacity>
```
---
## 3. Khắc phục lỗi: GPS Geotag Watermark không đọc được tọa độ từ ảnh thư viện
### Nguyên nhân lỗi
Mã nguồn hiện tại chỉ mô phỏng việc sinh GPS giả lập dựa trên tên tệp tin ảnh hoặc lấy GPS thời gian thực từ cảm biến điện thoại. Khi người dùng nạp một ảnh cũ có chứa dữ liệu GPS từ thư viện, ứng dụng không trích xuất được tọa độ thực tế này.
### Giải pháp kỹ thuật (Sử dụng API Native có sẵn)
Khi chọn ảnh từ thư viện bằng gói Media Library, chúng ta sẽ không dùng trực tiếp URI tạm thời, mà dùng mã định danh tài sản (`assetId`) để yêu cầu hệ thống cung cấp siêu dữ liệu đầy đủ bao gồm dữ liệu vị trí:
* **Sử dụng `expo-media-library`:** Gọi hàm `MediaLibrary.getAssetInfoAsync(assetId)` có sẵn của Expo.
* **Đọc thuộc tính Location:** API native này tự động trả về đối tượng chứa vị trí chính xác:
```json
"location": {
"latitude": 15.9000,
"longitude": 108.1500
}
```
* **Mã hóa địa danh ngược (Reverse Geocoding):** Gửi tọa độ này lên OpenStreetMap API để hiển thị tên địa danh hoài cổ trên ảnh (Ví dụ: `📍 PHỐ CỔ HỘI AN, VIETNAM`).
---
## 4. Khắc phục lỗi: Ứng dụng tự reset về chế độ Camera khi thu nhỏ (App Lifecycle State)
### Nguyên nhân lỗi
Khi ứng dụng bị đẩy vào trạng thái chạy ngầm (**Background**) và bật lại (**Active**), hệ điều hành có thể giải phóng bộ nhớ tạm thời hoặc cơ chế khởi tạo camera bị kích hoạt lại từ đầu, khiến ứng dụng tự động reset về chế độ Native Camera và xóa sạch bức ảnh người dùng đang chỉnh sửa dở.
### Giải pháp kỹ thuật (Lưu giữ trạng thái bằng AppState & AsyncStorage)
Sử dụng API `AppState` có sẵn của React Native kết hợp với cơ chế lưu trữ để đóng băng trạng thái làm việc hiện tại:
1. **Lắng nghe Lifecycle:** Theo dõi trạng thái ứng dụng bằng `AppState.addEventListener`.
2. **Đóng băng khi thu nhỏ (Pause / Background):**
Lưu trữ các biến `currentInputSource`, đường dẫn ảnh tạm `currentImageUri`, Recipe đang chọn và các thông số tùy chỉnh IQ hiện tại vào bộ nhớ lưu trữ `AsyncStorage`.
3. **Khôi phục khi khởi động lại (Active):**
Trong hàm `useEffect` khởi tạo ứng dụng, tiến hành đọc lại các dữ liệu đã đóng băng từ `AsyncStorage`. Nếu người dùng đang chỉnh sửa dở ảnh thư viện, ứng dụng sẽ khôi phục chính xác bức ảnh cùng các thanh slider ở nguyên vị trí cũ.
```javascript
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (nextAppState === 'background' || nextAppState === 'inactive') {
// Đóng băng và lưu trữ trạng thái hiện tại xuống bộ nhớ tạm
await AsyncStorage.setItem('saved_session', JSON.stringify({
source: currentInputSource,
imageUri: currentImageUri,
adjustments: adjustments
}));
}
});
return () => subscription.remove();
}, [currentInputSource, currentImageUri, adjustments]);
```