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

24 KiB
Raw Blame History

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

  • Tạo branch recipes-web
  • Chốt yêu cầu (port, auth, batch, responsive, EXIF GPS)
  • Bổ sung theme (light/dark + accent) + i18n (vi/en) vào kế hoạch
  • 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
  • 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