Files
RecipesCam/WEB_PLAN.md
T
3dtours 8c6e7930db Add self-contained docker/ stack for the web UI
`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.
2026-09-17 17:43:03 +07:00

250 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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