# 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 ``; 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 `` 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 (``, 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__.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 (``, 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