diff --git a/WEB_PLAN.md b/WEB_PLAN.md index 255b358..11bca15 100644 --- a/WEB_PLAN.md +++ b/WEB_PLAN.md @@ -102,10 +102,22 @@ Viết mới: `web/src/engine/skiaShim.ts` (adapter API Skia trên CanvasKit) + - **FX (filters)**: NOISE REDUCTION · CLARITY · SHARPENING · MONOCHROME GRAIN · HDF EFFECT · VIGNETTING. - **FRAME**: NO FRAME / CLASSIC BORDER / RETRO INSTANT (polaroid) / WALL FRAME (+ WALL LANDSCAPE) · CROP (none/free/1:1/2:3/3:2/3:4/4:3/16:9 + APPLY) · ROTATE (0/90/180/270 + STRAIGHTEN + AUTO STRAIGHTEN) · WATERMARK (GPS WATERMARK ON/OFF, PLACE NAME, TIME, TEXT COLOR/SIZE/FONT/ROTATE, CUSTOM WATERMARK) · PHOTO RATIO (FULL/4:3/3:2). +### Metadata khi export — phải khớp app (300 ppi + EXIF) +Đường export mặc định của app (`src/utils/exportEngine.ts`, tức `EXPO_PUBLIC_NATIVE_EXPORT != 1`) ghi JPEG đúng 3 bước, **theo thứ tự này**: + +1. `encodeToBytes(JPEG, 95)` — Skia re-encode **làm mất toàn bộ EXIF nguồn**. +2. `patchJpegDpi(bytes, 300)` — JFIF density `units=1 X=Y=300` (kèm `XResolution`/`YResolution` nếu JPEG có EXIF). +3. `writeJpegExif(bytes, stamp, sourceBytes)` — dựng lại APP1/EXIF: **copy từ ảnh nguồn mọi tag app không sở hữu** (hãng máy, model, lens, phơi sáng, ISO…), **bỏ IFD1 thumbnail + MakerNote**, rồi đóng dấu `Software = "RecipesCam 1.2"`, `DateTimeOriginal`, GPS, tên địa điểm (0x889d), blob thiết bị (0x889e), kích thước pixel, dpi. + +Web phải làm y hệt, cùng thứ tự, **giữ nguyên kích thước pixel và 300 ppi**: +- `src/utils/exifWrite.ts` và `src/utils/jpegDpi.ts` **không có import nào** → tái dùng/port **nguyên văn**, chạy trên `Uint8Array` trong worker; web đã có sẵn bytes ảnh nguồn nên không cần `readPhotoBytes`. +- Chỉ `src/utils/photoMeta.ts` mới dính Expo (`getDeviceIdentity`) → web truyền `device` từ nguồn khác hoặc để `null` (mọi field của `ExifStamp` đều optional). +- Trạng thái hiện tại: `web/src/engine/exportEngine.ts` mới có bước 1+2 (đã kiểm chứng: JFIF `x=300 y=300`) — **bước 3 chưa port** → làm ở W4. Nhớ `patchJpegDpi` phải chạy **trước** `writeJpegExif`, và truyền `dpi` vào stamp để `XResolution`/`YResolution` khớp JFIF. + ### Khác biệt web (bắt buộc xử lý) -- **GPS watermark**: đọc GPS từ EXIF ảnh nạp vào (`exifr`); ảnh không có GPS → cho nhập tay place name + toạ độ. Reverse-geocode tên địa điểm là tuỳ chọn (gọi Nominatim), mặc định tắt. +- **GPS watermark**: đọc GPS từ EXIF ảnh nạp vào (`exifr`); ảnh không có GPS → cho nhập tay place name + toạ độ. Reverse-geocode tên địa điểm là tuỳ chọn (gọi Nominatim), mặc định tắt. Cùng GPS đó cũng đi vào EXIF xuất ra (bước 3 ở trên), không chỉ vào watermark. - **Crop/rotate/straighten**: thao tác chuột/chạm trực tiếp trên canvas. -- **Export**: CanvasKit `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → tải `recipescam__.jpg`; batch → ZIP (`fflate`, ~8KB) hoặc tải lần lượt. +- **Export**: CanvasKit `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` → tải `recipescam__.jpg`; batch → ZIP (`fflate`, ~8KB) hoặc tải lần lượt. Không re-encode lần hai, không đổi kích thước pixel. - **Trạng thái**: `localStorage` thay `AsyncStorage`; recipe đã lưu đồng bộ server khi đã đăng nhập. ## Cấu trúc thư mục đề xuất @@ -180,8 +192,8 @@ Exit: mọi tham số đổi tức thì trên preview; reload giữ trạng thá Exit: preview đúng layout từng frame; ảnh có GPS tự điền, ảnh không GPS cho nhập tay. ### W4 — EXPORT / RENDER full-res -Worker render full-res + tiến trình + hủy; JPEG 95 + DPI 300; download; thumbnail strip. -Exit: file tải về đúng 300 DPI, kích thước khớp app với cùng input. +Worker render full-res + tiến trình + hủy; `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` (port `src/utils/exifWrite.ts`, giữ tag ảnh nguồn, bỏ IFD1/MakerNote, stamp software/date/GPS/dims/dpi); download; thumbnail strip. +Exit: file tải về đúng **300 ppi** (`JFIF units=1 X=Y=300`) và **giữ EXIF như app** (camera/lens/ISO của ảnh nguồn + tag app tự ghi); kích thước pixel khớp app với cùng input; cùng ảnh + cùng recipe → EXIF web và app giống nhau từng tag app sở hữu. ### W5 — API + auth Fastify + SQLite: `POST /api/auth/signup|login|logout`, `GET /api/auth/me`, CRUD `/api/recipes`; session cookie; trang Login/Signup; đồng bộ recipe + favorite; chế độ **demo** (không cần tài khoản, giới hạn theo `tiers.ts`). @@ -213,12 +225,15 @@ Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export - Không xoá/đổi code app RN: web chỉ **đọc** `src/utils/*` (nếu phải sửa util để dùng chung → giữ tương thích app, typecheck lại). - Import TS ngoài root `web/`: cấu hình `server.fs.allow` + alias; Docker context phải là repo root. - **Parity chưa chốt**: thiếu cặp ảnh gốc + ảnh app export cùng nguồn (xem cuối W0) → rủi ro phát hiện lệch màu muộn ở W8. +- **Metadata**: engine web **phải** giữ đúng 300 ppi và dựng lại EXIF như app; quên bước `writeJpegExif` thì ảnh vẫn ra nhưng mất camera/lens/ISO/ngày chụp. +- **Hai đường export của app không đồng nhất**: đường Kotlin native (`RecipescamExportModule.kt`, `Bitmap.compress(JPEG)`) **chỉ** gọi `patchJpegDpi`, **không** dựng lại EXIF → nếu P6 flip default sang native thì app cũng sẽ mất metadata. Cần chốt: port `writeJpegExif` sang Kotlin, hoặc giữ đường Skia làm mặc định. Không ảnh hưởng web, nhưng ảnh hưởng mốc "khớp app". - **Giá trị cần xác nhận khi làm W6**: URL Google Play / App Store thật (đang để placeholder trong `storeLinks.ts`), tên miền cuối cùng gắn qua Nginx Proxy Manager. ## Checklist - [x] Tạo branch `recipes-web` - [x] Chốt yêu cầu (port, auth, batch, responsive, EXIF GPS) - [x] Bổ sung theme (light/dark + accent) + i18n (vi/en) vào kế hoạch +- [x] Chốt metadata export: 300 ppi (`patchJpegDpi`) + dựng lại EXIF (`writeJpegExif`), giữ nguyên kích thước pixel - [ ] W0 spike CanvasKit — pipeline ✓, font ✓, build `full` ✓, WebGL ✓; **còn**: cặp ảnh parity từ người dùng + số end-to-end trên WebGL - [ ] W1 scaffold + drag&drop + responsive - [ ] W2 tool phi cấu trúc ảnh