Require 300 ppi + EXIF parity on web export

This commit is contained in:
2026-09-17 17:21:51 +07:00
parent 73c063a6ac
commit 608901bd0f
+19 -4
View File
@@ -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_<tên>_<timestamp>.jpg`; batch → ZIP (`fflate`, ~8KB) hoặc tải lần lượt.
- **Export**: CanvasKit `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` → tải `recipescam_<tên>_<timestamp>.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