230 lines
20 KiB
Markdown
230 lines
20 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).
|
||
|
||
### 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.
|
||
- **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.
|
||
- **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.
|
||
|
||
→ 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; 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.
|
||
|
||
### 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-*`); `web/README.md` (RUNBOOK: port, domain, backup `./data`, đổi `TIER` config); cập nhật `README.md` gốc; 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.
|
||
|
||
## 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 → engine bắt buộc dùng `MakeWebGLCanvasSurface`; máy không có WebGL (hoặc WebGL bị block) phải báo lỗi rõ thay vì âm thầm chạy CPU.
|
||
- **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.
|
||
- **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
|
||
- [ ] 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
|
||
- [ ] W9 Docker + deploy + docs
|