8c6e7930db
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
250 lines
24 KiB
Markdown
250 lines
24 KiB
Markdown
# WEB_PLAN — RecipesCam Web (Docker, branch `recipes-web`)
|
||
|
||
Trạng thái: kế hoạch đã chốt yêu cầu, chưa code. Ngày: 2026-09-06.
|
||
Nguồn đối chiếu: app RN hiện tại (`App.tsx`, `src/components/*`, `src/utils/*`).
|
||
|
||
## Mục tiêu
|
||
Một web app chạy bằng Docker, gồm 2 phần:
|
||
|
||
1. **Landing page** (`/`, responsive cả desktop lẫn mobile) — giới thiệu app RecipesCam: hero, tính năng, ảnh before/after, review người dùng, badge Google Play / App Store, CTA **DÙNG THỬ DEMO** và **ĐĂNG KÝ**.
|
||
2. **WebUI chỉnh ảnh** (`/app`) — tái hiện giao diện app điện thoại:
|
||
- **Cột trái**: rail 6 tab (PRESETS / FAVORITED / LIGHT / WB / FX / FRAME); chip công cụ của tab đang chọn nằm trong cột đó.
|
||
- **Giữa**: khung ảnh lớn, **kéo-thả ảnh từ máy tính** vào để nạp (kèm chọn file + paste clipboard). Trên mobile: nút chọn ảnh / camera roll.
|
||
- Đầy đủ tool như app → **EXPORT / RENDER** ra JPEG (DPI 300) tải về.
|
||
- **Add-on BATCH EXPORT**: nạp nhiều ảnh, áp cùng 1 recipe (của người dùng hoặc bundle), export ra cùng một tông màu.
|
||
3. **Tài khoản**: bắt buộc đăng nhập để dùng bản đầy đủ; vẫn cho **demo không cần đăng nhập**.
|
||
4. Toàn bộ xử lý ảnh chạy **client-side** (không upload ảnh lên server) — server chỉ phục vụ file tĩnh + API tài khoản/recipe.
|
||
|
||
## Quyết định đã chốt (2026-09-06)
|
||
1. **Cổng/domain**: chạy `IP:port` (mặc định `8090`), đổi được qua `docker-compose.yml`; domain thật gắn sau bằng **Nginx Proxy Manager** bên ngoài → không cần TLS trong container, chỉ cần `X-Forwarded-*` đúng.
|
||
2. **Auth/multi-user**: có. Đăng ký/đăng nhập email + mật khẩu, session cookie; **vẫn cho dùng demo** không cần tài khoản.
|
||
3. **Batch export**: có, là công cụ add-on cho người dùng đã đăng nhập — nhiều ảnh dùng chung 1 recipe, render tuần tự có tiến trình.
|
||
4. **Responsive**: hỗ trợ cả desktop và mobile browser.
|
||
5. **EXIF GPS**: đọc GPS từ ảnh nạp vào để tự điền watermark; nếu ảnh không có GPS thì cho nhập tay (place name + toạ độ).
|
||
6. **Review trên landing**: nội dung tĩnh trong repo (`web/src/content/reviews.json`) — sửa file + rebuild; không cần CRUD/admin ở v1.
|
||
`ponytail:` seed tĩnh là đủ vì review đổi rất chậm; nâng lên bảng DB + admin API khi cần tự đăng review.
|
||
7. **Theme**: nhiều bộ theme — `light` / `dark` + các nhóm màu accent (xem mục Theme).
|
||
8. **Đa ngôn ngữ**: có — tiếng Việt + tiếng Anh ở v1 (xem mục i18n).
|
||
|
||
### Phân hạng tính năng
|
||
| Hạng | Điều kiện | Quyền |
|
||
|---|---|---|
|
||
| Guest | không đăng nhập | Landing, xem review, **DEMO**: nạp 1 ảnh, dùng tool (cấu hình ở `web/src/config/tiers.ts`), export **tối đa 2048px + watermark "RecipesCam demo"**, không lưu recipe |
|
||
| User | đã đăng nhập | Toàn bộ tool như app, export **full-res không watermark**, lưu/favorite/share recipe (đồng bộ server), **BATCH EXPORT** |
|
||
|
||
Bảng hạng để trong 1 file config duy nhất để đổi chính sách mà không sửa UI.
|
||
|
||
## Theme (light/dark + nhóm màu)
|
||
- **Cơ chế**: CSS custom properties trên `:root`, chuyển bằng thuộc tính `data-theme` + `data-accent` trên `<html>`; mọi màu trong UI đi qua token, không hardcode. Đổi theme = set 2 attribute, không re-render React.
|
||
- **Theme nền**: `light`, `dark` (mặc định theo `prefers-color-scheme`, người dùng chọn được thì ghi đè). Dark dùng nền đen thuần theo `web-design`.
|
||
- **Nhóm màu accent**: `amber` (mặc định — đúng chất RecipesCam), `blue`, `emerald`, `rose`, `violet`. Accent chỉ đổi token `--accent*`, không đổi layout.
|
||
- **Lưu**: `localStorage` (`theme`, `accent`); đồng bộ sang `<meta name="theme-color">` cho mobile browser.
|
||
- **UI chọn**: popover trong header (icon bảng màu) trên cả landing lẫn workspace; landing cũng đổi theme được.
|
||
- `ponytail:` 2 theme nền + 5 accent là cấu hình dữ liệu trong `web/src/theme/themes.ts`; thêm theme mới = thêm 1 object, không sửa component.
|
||
|
||
## Đa ngôn ngữ (i18n)
|
||
- **Ngôn ngữ v1**: `vi` (mặc định theo `navigator.language` nếu là tiếng Việt) + `en`. Thêm ngôn ngữ = thêm 1 file từ điển.
|
||
- **Cách làm**: tự viết, **không thêm thư viện** — từ điển `web/src/i18n/vi.ts` / `en.ts` (object lồng nhau, có kiểu từ `vi` để `en` thiếu key là lỗi typecheck), `t(key, params)` + `I18nProvider` giữ `lang` trong `localStorage`. Đổi ngôn ngữ = đổi state, không reload.
|
||
- **Phạm vi dịch**: toàn bộ nhãn UI (rail tab, chip, slider, tooltip, nút, thông báo lỗi, landing/review/auth form), tiêu đề trang + SEO (`<html lang>`, OG locale).
|
||
- **Không dịch**: tên film sim (`PROVIPES`, `LC STREETLIFE CLASSIC`…), tên recipe người dùng đặt, nội dung watermark người dùng nhập — là danh từ riêng/thương hiệu, giữ nguyên ở mọi ngôn ngữ.
|
||
- `ponytail:` từ điển là file TS được bundle; chỉ tách lazy-load theo ngôn ngữ khi bundle vượt ~50KB text.
|
||
|
||
## Kiến trúc
|
||
```
|
||
docker-compose.yml
|
||
├── web nginx:alpine 8090:80 static SPA (Vite dist) + proxy /api → api
|
||
└── api node:20-alpine 3000 Fastify + better-sqlite3, volume ./data
|
||
```
|
||
- **Frontend**: Vite + React + TS + react-router (`/`, `/app`, `/login`, `/signup`).
|
||
- **Engine ảnh**: **CanvasKit (canvaskit-wasm)** trong Web Worker — nhưng **bắt buộc trên surface WebGL** (`MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))`), không phải raster CPU: xem W0. Preview bản thu nhỏ (~2048px), render full-res khi export.
|
||
- **API**: Fastify + better-sqlite3 (1 file DB, không cần container DB riêng). Session cookie `httpOnly` + `SameSite=Lax`, mật khẩu hash `argon2`. Bảng: `users`, `sessions`, `recipes`, `favorites`.
|
||
- **Recipe bundle** lấy thẳng từ `src/utils/defaultRecipes.ts` (client-side) — server chỉ lưu recipe người dùng tạo.
|
||
- Nginx Proxy Manager bên ngoài lo TLS/domain; container chỉ nghe HTTP.
|
||
|
||
### Vì sao CanvasKit
|
||
Tái dùng gần như nguyên vẹn `src/utils/exportEngine.ts`: CanvasKit có `RuntimeEffect` (SkSL y nguyên `TONE_SKSL`, `CINEMA_SKSL`), `ColorFilter.MakeMatrix`, `ImageFilter.MakeBlur`/`MakeCompose`, `BlendMode.Overlay`, `drawImageRect`, `MakeFreeTypeFaceFromData`, `encodeToBytes(JPEG, 95)` → parity cao nhất với app, code ít nhất.
|
||
Hai chỗ CanvasKit khác Skia JSI (đã xử lý ở W0): encode JPEG cần build `canvaskit-wasm/full`; không có `ImageFilter.MakeMatrixConvolution` → CLARITY/sharpening đi qua `CLARITY_SKSL`.
|
||
**Không** dùng `react-native-web`/Expo web: app phụ thuộc `react-native-vision-camera` + Skia JSI, web sẽ vỡ.
|
||
|
||
## Tái sử dụng (không viết lại)
|
||
| Nguồn trong repo | Dùng cho web |
|
||
|---|---|
|
||
| `src/utils/colorUtils.ts` | `getSkiaColorMatrix`, `applyExposureGain`, `kelvinToRGB` — nguyên văn |
|
||
| `src/utils/toneShader.ts` | `TONE_SKSL` + `getToneUniforms` |
|
||
| `src/utils/cinemaShader.ts` | `CINEMA_SKSL` + `getCinemaUniforms` |
|
||
| `src/utils/frameUtils.ts` | `FRAMES`, `polaroidLayout`, `wallframeLayout`, `drawFrameOnCanvas`, hằng số POLAROID/WALLFRAME |
|
||
| `src/utils/paramDefs.ts` | `PARAM_DEFS` (slider bounds/label/format) → render chip + slider |
|
||
| `src/utils/defaultRecipes.ts` | `FILM_SIMS`, `DEFAULT_ADJUSTMENTS`, recipe bundle |
|
||
| `src/utils/jpegDpi.ts` | `patchJpegDpi(bytes, 300)` — chạy trong worker |
|
||
| `src/utils/recipeShare.ts` | IMPORT/EXPORT recipe chia sẻ giữa app ↔ web (cùng định dạng) |
|
||
| `src/types/index.ts` | `ColorAdjustments`, `Recipe`, `FrameId`, `AspectRatio`, `CropRatio`, `CropRect`, `ASPECT_RATIO_W_H`, `CROP_W_H` |
|
||
| `assets/Cousine-Regular.ttf`, `assets/NotoEmoji-GPS.ttf`, `wallframe.png` | font + artwork frame |
|
||
|
||
Viết mới: `web/src/engine/skiaShim.ts` (adapter API Skia trên CanvasKit) + `web/src/engine/exportEngine.ts` (port từ `src/utils/exportEngine.ts`, bỏ `expo-file-system`/MediaLibrary → Blob/download).
|
||
|
||
## Bản đồ UI app → web
|
||
| Khu vực app | Web |
|
||
|---|---|
|
||
| `ToolRail` (rail ngang dưới màn hình) | Cột dọc bên trái, 6 tab; mobile: rail ngang dưới đáy |
|
||
| `AdjustmentPanel` chip row + strip | Cột thứ 2 cạnh rail (~280–320px): chip của tab + strip/slider mở tại chỗ |
|
||
| `Viewfinder` | Canvas giữa, nhận drag&drop file ảnh |
|
||
| Drag-on-image để đổi slider | Giữ: kéo dọc trên ảnh = chỉnh param đang mở |
|
||
| PEEK (giữ để so sánh) | Giữ chuột phải hoặc phím `\` |
|
||
| `TopBar` | Header: tên recipe, UNDO/RESET, SAVE AS, EXPORT, IMPORT/EXPORT recipe, tài khoản, **đổi theme**, **đổi ngôn ngữ**; ẩn mục camera-only |
|
||
| `PhotoViewerModal` | Strip thumbnail kết quả export ở đáy |
|
||
| `SettingsModal` | Popover settings; bỏ mục camera-only; giữ PHOTO RATIO |
|
||
|
||
### Danh sách tool đầy đủ theo tab (lấy từ code hiện tại)
|
||
- **PRESETS**: PHOTO STYLE (`provia`, `velvia`, `classic-chrome`, `classic-neg`, `astia`, `eterna`, `monochrome`, `leica`, `leica-vivid`) · RECIPES (bundle + user, có star) · CREATE · IMPORT.
|
||
- **FAVORITED**: recipe đã star.
|
||
- **LIGHT (iq)**: EXPOSURE · EV · CONTRAST · COLOR · VIBRANCE · HIGHLIGHT · SHADOW · D.RANGE (AUTO/DR100/200/400).
|
||
- **WB**: TEMP (AUTO / DAYLIGHT / DAYLIGHT -3R / CLOUDY / SHADE / TUNGSTEN / FLUOR + slider COLOR TEMP) · TINT · COLOR CHROME · CHROME BLUE.
|
||
- **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. 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)` → `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
|
||
```
|
||
web/
|
||
Dockerfile nginx.conf docker-compose.yml
|
||
package.json vite.config.ts tsconfig.json index.html
|
||
api/ # Fastify + better-sqlite3 (build riêng, chạy trong container api)
|
||
server.ts auth.ts recipes.ts db.ts Dockerfile
|
||
src/
|
||
main.tsx App.tsx router.tsx config/tiers.ts config/storeLinks.ts
|
||
content/reviews.json content/features.json
|
||
theme/ (tokens.css, themes.ts, ThemeProvider.tsx, ThemePopover.tsx)
|
||
i18n/ (vi.ts, en.ts, I18nProvider.tsx, t.ts)
|
||
pages/ (Landing, Login, Signup, Workspace)
|
||
ui/ (ToolRail, ChipColumn, ImageStage, SliderRow, StripRow, ThumbStrip, SettingsPopover, BatchPanel, AccountMenu)
|
||
engine/ (skiaShim.ts, exportEngine.ts, worker.ts, previewEngine.ts, exifGps.ts)
|
||
state/ (recipeStore.ts, authStore.ts)
|
||
assets/ (fonts, wallframe.png — import từ ../assets)
|
||
```
|
||
Docker build context = repo root (cần `src/utils`, `assets/`), `-f web/Dockerfile`.
|
||
|
||
## Phases
|
||
|
||
### W0 — Spike CanvasKit (parity gate) ← đã chạy, còn cổng parity
|
||
Chuỗi 1 ảnh: decode → crop → matrix → tone → cinema → denoise/clarity → HDF → grain → vignette → frame → watermark → JPEG 95 + DPI 300.
|
||
Exit: chạy trong Node trên ảnh mẫu; so sánh với ảnh export từ app (meanAbs/gt12 như probe P6); đo thời gian 12MP; xác nhận FreeType font `Cousine` + `NotoEmoji-GPS` render (emoji không ra → fallback Canvas2D overlay).
|
||
Rủi ro lớn nhất của cả kế hoạch → làm trước.
|
||
|
||
#### Kết quả đo được (2026-09-06, spike trong `web/spike/`)
|
||
Bản port đã chạy hết chuỗi trên ảnh 12MP và ra JPEG; 4 phát hiện làm đổi kế hoạch:
|
||
|
||
1. **JPEG encode**: build `canvaskit-wasm` mặc định **không** encode được JPEG (`encodeToBytes(JPEG)` → null; PNG vẫn được). → dùng build `canvaskit-wasm/full` (8.2MB wasm so với 7.3MB). Shim import `canvaskit-wasm/full`.
|
||
2. **`ImageFilter.MakeMatrixConvolution` không tồn tại** trong CanvasKit (chuỗi wasm có nhưng không có binding JS). → thay bằng `CLARITY_SKSL` (lấy từ `src/utils/toneShader.ts`, vốn đã dùng cho preview RN) chạy như shader pass, `px=(1,1)` ở tỉ lệ 1:1. Không phải viết thuật toán mới.
|
||
3. **Font ✓**: `Typeface.MakeFreeTypeFaceFromData` + `Canvas.drawText` chạy. `Cousine-Regular.ttf` **thiếu** 📍/📷 (glyphID 0) nhưng `NotoEmoji-GPS.ttf` có (glyphID 1,2) và vẽ ra 1731 pixel → **không cần fallback Canvas2D emoji**.
|
||
4. **Raster CPU không dùng được, buộc phải render trên surface WebGL**. Đo trên 4000×3000 (`spike/bench.ts`, SkVM interpreter):
|
||
|
||
| Bước | 4000×3000 | 2048 |
|
||
|---|---|---|
|
||
| draw | 5ms | — |
|
||
| tone SkSL pass | **3205ms** | 836ms |
|
||
| clarity SkSL 5-tap | **13745ms** | 3581ms |
|
||
| blur σ=40 | **3024ms** | — |
|
||
| JPEG q95 | 246ms | 54ms |
|
||
| decode | 233ms | — |
|
||
|
||
Cùng pipeline trên surface WebGL (`spike/gpu.html`, headless Chromium, ANGLE/SwiftShader — **software GL**, GPU thật sẽ nhanh hơn hoặc bằng):
|
||
- `MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))` OK, tạo surface 15–22ms.
|
||
- tone / clarity / blur pass: **0.1–1ms** (submit bất đồng bộ).
|
||
- **JPEG q95: 205ms @12MP, 54ms @2048, 14ms @1024**; decode 12MP 280ms.
|
||
|
||
Đầu ra kiểm chứng trên ảnh 12MP (raster CPU, `spike/w0.ts`): 3 file JPEG 4.1–5.1MB, JFIF `units=1 x=300 y=300` (DPI 300 đúng), byte-mean ~125 → **ảnh không trắng, pipeline chạy hết chuỗi**.
|
||
|
||
→ Chốt: engine **bắt buộc** chạy trên WebGL surface (`MakeWebGLCanvasSurface`), raster chỉ là fallback cho test Node. Preview ~2048px, full-res khi export.
|
||
|
||
Các API khác đã xác nhận có trong CanvasKit: `MakeCompose`, `MakeBlur`, `MakeRadialGradient`, `RuntimeEffect.Make`, `makeShaderWithChildren`, `MakeSurface`, `Font`, `TypefaceFontProvider`, `MakeOffscreen`, `MakeWebGLCanvasSurface`.
|
||
Gotcha khi viết shim: `new CK.Paint()` (không phải `CK.Paint()`); `img.readPixels(srcX, srcY, imageInfo)`; `CK.ImageFormat` là enum object; phải `flush(surface)` trước mỗi snapshot.
|
||
|
||
**Còn lại của W0 (chặn parity)**: cần **1 cặp ảnh mẫu từ người dùng** — ảnh gốc + đúng ảnh app đã export từ ảnh đó — để chạy diff meanAbs/gt12 như P6. Cặp `app.jpg`/`native.jpg` đang có trong scratchpad không rõ nguồn nên không dùng làm chuẩn được. Sau đó chạy lại 1 lần end-to-end trên WebGL để lấy số 12MP thật trong browser.
|
||
|
||
### W1 — Scaffold + layout + theme + i18n + nạp ảnh + responsive
|
||
Vite/React/TS/router; 3 vùng desktop / 1 cột mobile; rail 6 tab; chip column rỗng; canvas giữa; nạp ảnh bằng drop/chọn file/paste; preview downscale 2048px (worker); zoom/pan.
|
||
Theme: `tokens.css` (light/dark + 5 accent), `ThemeProvider`, popover chọn theme; i18n: `I18nProvider` + từ điển vi/en + nút đổi ngôn ngữ, mọi nhãn đi qua `t()`.
|
||
Exit: thả ảnh từ desktop → hiển thị; đổi theme/ngôn ngữ tức thì và giữ sau reload; `npm run build` + typecheck sạch; mở đúng trên khổ mobile.
|
||
|
||
### W2 — Tool phi cấu trúc ảnh
|
||
PRESETS, FAVORITED, LIGHT, WB, FX; slider/chip/strip (một row mở tại thời điểm); undo/reset; PEEK; lưu localStorage. Từ điển vi/en phủ hết nhãn của các tab này.
|
||
Exit: mọi tham số đổi tức thì trên preview; reload giữ trạng thái; không còn chuỗi tiếng Anh hardcode ngoài tên film sim.
|
||
|
||
### W3 — FRAME / CROP / ROTATE / WATERMARK / RATIO + EXIF GPS
|
||
4 frame + wall landscape; free crop kéo rect; rotate/straighten/auto; watermark GPS (EXIF + nhập tay) + custom text; photo ratio.
|
||
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; `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`).
|
||
Exit: đăng ký → đăng nhập → lưu recipe → đăng nhập lại ở máy khác thấy recipe; demo chạy không cần tài khoản và bị giới hạn đúng cấu hình.
|
||
|
||
### W6 — Landing page
|
||
Hero, tính năng, before/after, review (`reviews.json`), badge Google Play/App Store (`storeLinks.ts`), CTA demo + đăng ký; responsive; SEO cơ bản (`<title>`, OG tags, `<html lang>` theo ngôn ngữ đang chọn); landing cũng đổi được theme + ngôn ngữ.
|
||
Exit: trang mở đẹp ở cả 360px và 1440px ở cả 2 theme; link store + CTA đúng đích; nội dung landing có đủ vi/en.
|
||
|
||
### W7 — BATCH EXPORT (add-on)
|
||
Panel riêng: nạp nhiều ảnh, chọn recipe (bundle hoặc của người dùng), hàng đợi render tuần tự có tiến trình, tải từng file hoặc ZIP. Yêu cầu đăng nhập.
|
||
Exit: 10 ảnh + 1 recipe → 10 file cùng tông màu, không tràn RAM.
|
||
|
||
### W8 — Parity & perf
|
||
Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối ưu (worker pool, preview cache, dùng lại bitmap khi export).
|
||
Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s.
|
||
|
||
### W9 — Docker + deploy + docs
|
||
`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `docker/README.md` (RUNBOOK: port, domain, backup `./data`, đổi `TIER` config); commit + push.
|
||
Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export chạy; container tự lên lại sau reboot.
|
||
|
||
**Trạng thái**: `docker/` đã tự chứa và chạy được — `docker/docker-compose.yml` (service `frontend` + `api`), `docker/backend/` (Fastify + SQLite), `docker/frontend/` (Vite + CanvasKit + nginx). Chạy `docker compose up -d --build` trong `docker/`, UI ở `:8090`; đã kiểm chứng health/signup/login/recipes và export ảnh thật end-to-end trong Chromium headless. Còn lại: gắn domain/TLS qua Nginx Proxy Manager, cập nhật `README.md` gốc.
|
||
|
||
## Rủi ro
|
||
- ~~**CanvasKit không có emoji màu**~~ → **đã loại ở W0**: `NotoEmoji-GPS.ttf` cung cấp đủ 📍/📷, không cần fallback Canvas2D.
|
||
- ~~**Skia API shim** (`MakeMatrixConvolution` khác chữ ký)~~ → **đã loại ở W0**: không tồn tại trong CanvasKit, thay bằng `CLARITY_SKSL`.
|
||
- ~~**Hiệu năng**: raster CPU chậm gấp ~1000 lần WebGL~~ → **đã chốt**: engine dùng GPU surface, nhưng **một `GrDirectContext` dùng chung** + `MakeRenderTarget` cho mọi surface. `MakeWebGLCanvasSurface` gọi nhiều lần tạo nhiều WebGL context riêng → snapshot của surface này vẽ sang surface kia ra **đen thui** (đo được: 12MP JPEG + output sharpen, GL mean 0.0 vs raster 107.0). Không có WebGL thì fallback raster `MakeSurface` — đúng pipeline, chỉ chậm hơn.
|
||
- **wasm ~8MB** (build `full`, gzip ~3MB): lazy-load, nginx cache dài hạn.
|
||
- **RAM 12MP** trong browser (bitmap ~48MB + surface) — giới hạn worker pool 1–2, giải phóng bitmap sau export; batch render tuần tự, không song song.
|
||
- **HDF/vignette** dùng gradient + blend — kiểm tra tương đương CanvasKit.
|
||
- 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
|
||
- [ ] W3 frame/crop/rotate/watermark/ratio + EXIF GPS
|
||
- [ ] W4 export/render full-res
|
||
- [ ] W5 API + auth + demo
|
||
- [ ] W6 landing page
|
||
- [ ] W7 batch export
|
||
- [ ] W8 parity & perf
|
||
- [x] `docker/` tự chứa + chạy được (code FE/BE nằm trong `docker/`, compose + Dockerfile + `.env.example` + README) — đã verify end-to-end
|
||
- [ ] W9 Docker + deploy + docs
|