`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.
24 KiB
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:
- 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Ý. - 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.
- 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.
- 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)
- Cổng/domain: chạy
IP:port(mặc định8090), đổi được quadocker-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ầnX-Forwarded-*đúng. - 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.
- 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.
- Responsive: hỗ trợ cả desktop và mobile browser.
- 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ạ độ).
- 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. - Theme: nhiều bộ theme —
light/dark+ các nhóm màu accent (xem mục Theme). - Đ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ínhdata-theme+data-accenttrê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 theoprefers-color-scheme, người dùng chọn được thì ghi đè). Dark dùng nền đen thuần theoweb-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 trongweb/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 theonavigator.languagenế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đểenthiếu key là lỗi typecheck),t(key, params)+I18nProvidergiữlangtronglocalStorage. Đổ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 hashargon2. 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:
encodeToBytes(JPEG, 95)— Skia re-encode làm mất toàn bộ EXIF nguồn.patchJpegDpi(bytes, 300)— JFIF densityunits=1 X=Y=300(kèmXResolution/YResolutionnếu JPEG có EXIF).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ấuSoftware = "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.tsvàsrc/utils/jpegDpi.tskhông có import nào → tái dùng/port nguyên văn, chạy trênUint8Arraytrong worker; web đã có sẵn bytes ảnh nguồn nên không cầnreadPhotoBytes.- Chỉ
src/utils/photoMeta.tsmới dính Expo (getDeviceIdentity) → web truyềndevicetừ nguồn khác hoặc đểnull(mọi field củaExifStampđều optional). - Trạng thái hiện tại:
web/src/engine/exportEngine.tsmới có bước 1+2 (đã kiểm chứng: JFIFx=300 y=300) — bước 3 chưa port → làm ở W4. NhớpatchJpegDpiphải chạy trướcwriteJpegExif, và truyềndpivào stamp đểXResolution/YResolutionkhớ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ảirecipescam_<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:
localStoragethayAsyncStorage; 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:
- JPEG encode: build
canvaskit-wasmmặc định không encode được JPEG (encodeToBytes(JPEG)→ null; PNG vẫn được). → dùng buildcanvaskit-wasm/full(8.2MB wasm so với 7.3MB). Shim importcanvaskit-wasm/full. ImageFilter.MakeMatrixConvolutionkhông tồn tại trong CanvasKit (chuỗi wasm có nhưng không có binding JS). → thay bằngCLARITY_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.- Font ✓:
Typeface.MakeFreeTypeFaceFromData+Canvas.drawTextchạy.Cousine-Regular.ttfthiếu 📍/📷 (glyphID 0) nhưngNotoEmoji-GPS.ttfcó (glyphID 1,2) và vẽ ra 1731 pixel → không cần fallback Canvas2D emoji. - 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.ttfcung cấp đủ 📍/📷, không cần fallback Canvas2D.Skia API shim (→ đã loại ở W0: không tồn tại trong CanvasKit, thay bằngMakeMatrixConvolutionkhác chữ ký)CLARITY_SKSL.- ~
Hiệu năng: raster CPU chậm gấp→ đã chốt: engine dùng GPU surface, nhưng một1000 lần WebGLGrDirectContextdùng chung +MakeRenderTargetcho mọi surface.MakeWebGLCanvasSurfacegọ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 rasterMakeSurface— đú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ìnhserver.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
writeJpegExifthì ả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ọipatchJpegDpi, 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: portwriteJpegExifsang 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 trongdocker/, compose + Dockerfile +.env.example+ README) — đã verify end-to-end- W9 Docker + deploy + docs