Files
RecipesCam/WEB_PLAN.md
T

11 KiB
Raw Blame History

WEB_PLAN — RecipesCam WebUI trên Docker (branch recipes-web)

Trạng thái: kế hoạch, 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

WebUI chạy bằng Docker trên server, mô phỏng 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 ngay dưới/bên trong cột đó.
  • Giữa: khung ảnh lớn, kéo-thả ảnh từ máy tính vào để nạp (kèm nút chọn file + paste clipboard).
  • Đầy đủ tool như app; sau khi chỉnh → EXPORT / RENDER ra JPEG (DPI 300) tải về máy.
  • 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.

Quyết định kiến trúc (chốt để khỏi lưỡng lự khi code)

  1. Tách khỏi app RN: không dùng react-native-web/Expo web — app phụ thuộc react-native-vision-camera + Skia JSI, web sẽ vỡ. Thêm app mới web/ (Vite + React + TS) trong cùng repo.
  2. Engine = CanvasKit (canvaskit-wasm) tái dùng gần như nguyên vẹn exportEngine.ts: CanvasKit hỗ trợ RuntimeEffect (SkSL y nguyên: TONE_SKSL, CINEMA_SKSL, noise grain), ColorFilter.MakeMatrix, ImageFilter.MakeMatrixConvolution/MakeBlur, BlendMode.Overlay, drawImageRect, makeFreeTypeFaceFromData, encodeToBytes(JPEG, 95). → parity cao nhất với app, code ít nhất.
  3. Engine chạy trong Web Worker (+ OffscreenCanvas) để UI không giật; preview chỉnh ở bản thu nhỏ (~2048px), render full-res khi export.
  4. Docker: multi-stage node:20-alpine (build) → nginx:alpine (serve dist/), 1 container, docker compose up -d. Cấu hình application/wasm + cache header cho .wasm.
  5. Trạng thái: localStorage thay AsyncStorage (recipes, favorites, phiên gần nhất).

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 — SKSL nguyên văn cho CanvasKit
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 mặc định
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

Cần 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ỏ phần expo-file-system/MediaLibrary — thay bằng 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
AdjustmentPanel chip row + strip (một row mở tại một thời điểm) Cột thứ 2 cạnh rail: chip của tab + strip/slider mở tại chỗ; panel rộng ~280–320px
Viewfinder (khung ảnh giữa) Khung canvas giữa, nhận drag&drop file ảnh
Drag-on-image để đổi giá trị slider Giữ được: kéo dọc trên ảnh = chỉnh param đang mở
PEEK (giữ để so sánh) Giữ chuột phải / phím \ để xem ảnh gốc
TopBar (tên recipe, flash, UNDO, SAVE/SAVE AS, share) Header trên: tên recipe, UNDO/RESET, SAVE AS, EXPORT, IMPORT/EXPORT recipe, ẩn các mục camera-only (flash)
PhotoViewerModal (swipe lịch sử) Strip thumbnail kết quả export ở đáy, click mở lớn
SettingsModal Popover settings; bỏ mục camera-only (shutter sound, RAW DNG, metering, touch-to-shutter, startup mode); giữ PHOTO RATIO

Danh sách tool đầy đủ theo tab (lấy từ code hiện tại)

  • PRESETS: PHOTO STYLE (8 sim: provia, velvia, classic-chrome, classic-neg, astia, eterna, monochrome, leica, leica-vivid) · RECIPES (strip recipe bundled + user, có star) · CREATE · IMPORT. Recipe bundled gồm 4 recipe cinema (SPRING/SUMMER/AUTUMN/WINTER) + retro-amber v.v.
  • FAVORITED: recipe đã star.
  • LIGHT (iq): EXPOSURE · EV · CONTRAST · COLOR · VIBRANCE · HIGHLIGHT · SHADOW · D.RANGE (AUTO/DR100/200/400).
  • WB: TEMP (chip mở strip preset: AUTO / DAYLIGHT / DAYLIGHT -3R / CLOUDY / SHADE / TUNGSTEN / FLUOR + slider COLOR TEMP) · TINT · COLOR CHROME (none/weak/strong) · CHROME BLUE (none/weak/strong).
  • 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, free crop kéo rect trên ảnh) · ROTATE (0/90/180/270 + STRAIGHTEN + AUTO STRAIGHTEN) · WATERMARK: GPS WATERMARK ON/OFF, PLACE NAME, TIME, TEXT COLOR, TEXT SIZE, TEXT FONT, TEXT ROTATE, CUSTOM WATERMARK (nội dung/color/size/font/rotate).
  • Settings: PHOTO RATIO (FULL/4:3/3:2) — ảnh hưởng crop khi export.

Khác biệt web (bắt buộc xử lý)

  • GPS watermark: web không có GPS thiết bị → cho phép nhập PLACE NAME + timestamp thủ công (mặc định lấy tên file / thời điểm nạp ảnh). Không đọc EXIF GPS ở v1 (có thể thêm sau bằng exifr).
  • Crop/rotate/straighten: thao tác chuột trên canvas (kéo rect, kéo góc xoay).
  • Export: Bitmap.compress → canvas.toBlob('image/jpeg', 0.95) hoặc CanvasKit encodeToBytes → patchJpegDpi(300) → tải file recipescam_<tên>_<timestamp>.jpg.

Cấu trúc thư mục đề xuất

web/
  Dockerfile              # multi-stage: build (node:20-alpine) → nginx:alpine
  nginx.conf              # wasm mime, cache, fallback index.html
  docker-compose.yml      # service webui, ports 8090:80, restart unless-stopped
  package.json            # vite, react, typescript, canvaskit-wasm
  vite.config.ts          # server.fs.allow ['..'], alias @shared -> ../src/utils, worker format es
  index.html
  src/
    main.tsx  App.tsx  state/recipeStore.ts
    ui/ (ToolRail, ChipColumn, ImageStage, SliderRow, StripRow, ThumbStrip, SettingsPopover)
    engine/ (skiaShim.ts, exportEngine.ts, worker.ts, previewEngine.ts)
    assets/ (fonts, wallframe.png — copy hoặc import từ ../assets)

Docker build context = repo root (cần src/utils, assets/, wallframe.png), -f web/Dockerfile.

Phases

W0 — Spike CanvasKit (parity gate)

Port thử 1 ảnh qua chuỗi: decode → crop → matrix → tone → cinema → denoise/clarity → HDF → grain → vignette → frame → watermark → JPEG 95 + DPI 300. Exit: chạy được trong Node (không cần browser) trên 3 ả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 font FreeType (Cousine, NotoEmoji-GPS) render trong CanvasKit — nếu emoji không ra thì fallback: vẽ watermark bằng lớp Canvas2D riêng. Rủi ro chính của cả kế hoạch nằm ở đây → làm trước, tốn ~0.5–1 ngày.

W1 — Scaffold + khung UI + drag&drop

Vite/React/TS; layout 3 vùng; 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 cơ bản. Exit: thả ảnh từ desktop vào → hiển thị; build npm run build sạch; typecheck.

W2 — Toàn bộ tool phi cấu trúc ảnh

PRESETS (sim + recipes + create + import), FAVORITED, LIGHT, WB, FX; slider/chip/strip giống app (một row mở tại thời điểm); undo/reset; PEEK; trạng thái lưu localStorage. Exit: đổi mọi tham số thấy thay đổi tức thì trên preview; reload giữ nguyên trạng thái.

W3 — FRAME / CROP / ROTATE / WATERMARK / RATIO

Frame 4 loại + wall landscape; free crop kéo rect + crop cố định; rotate/straighten/auto; watermark GPS (nhập tay) + custom text; photo ratio. Exit: preview đúng layout từng frame (đối chiếu tỉ lệ với app).

W4 — EXPORT / RENDER full-res

Worker render full-res + thanh tiến trình + hủy; encode JPEG 95 + DPI 300; tải file; thumbnail strip lịch sử; tuỳ chọn batch nhiều ảnh (cùng recipe). Exit: file tải về mở đúng 300 DPI, kích thước = app cho cùng input.

W5 — Parity & perf

Bộ ảnh mẫu chạy cả app (native/Skia) và web; so sánh diff + mắt; tối ưu (worker pool, preview cache, tránh decode lại khi export — dùng lại bitmap đã decode). Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s trên server/khách.

W6 — Docker + deploy + docs

docker compose up -d --build; kiểm tra qua cổng server; viết web/README.md (RUNBOOK: port, domain, cách đổi); cập nhật README.md gốc; commit + push. Exit: mở webUI từ máy khác trong LAN, chụp màn hình xác nhận; container restart tự lên lại.

Rủi ro

  • CanvasKit không có emoji màu → watermark 📍/📷 có thể ra ô vuông; fallback Canvas2D overlay (đã tính ở W0).
  • Kích thước wasm ~7MB (gzip ~3MB): lazy-load, nginx cache dài hạn.
  • RAM 12MP trong browser (bitmap ~48MB + surface) — giới hạn pool worker = 1–2, giải phóng bitmap sau export.
  • Skia API shim: makeShaderWithChildren, MakeMatrixConvolution signature khác nhau giữa JSI và CanvasKit → W0 chốt.
  • HDF/vignette dùng gradient + blend (Skia mới thêm) — kiểm tra CanvasKit tương đương.
  • Không xoá/đổi code app RN: web chỉ đọc src/utils/* (nếu cần sửa util để dùng chung, giữ tương thích app và typecheck lại).
  • Import TS ngoài root web/: cấu hình server.fs.allow + alias; Docker context phải là repo root.

Câu hỏi cần chốt trước khi code

  1. Cổng/domain chạy trên server (đề xuất 8090, đổi qua docker-compose.yml)?
  2. Có cần đăng nhập / nhiều người dùng không, hay mở nội bộ LAN là đủ?
  3. Có cần batch export nhiều ảnh cùng recipe ở v1 không?
  4. WebUI cần hỗ trợ màn hình điện thoại (responsive) hay chỉ desktop?
  5. Ảnh nạp bằng kéo-thả: có cần đọc EXIF GPS của ảnh để tự điền watermark không (v1 đang để nhập tay)?

Checklist

  • Tạo branch recipes-web
  • W0 spike CanvasKit (parity + font + perf)
  • W1 scaffold + drag&drop
  • W2 tool phi cấu trúc ảnh
  • W3 frame/crop/rotate/watermark/ratio
  • W4 export/render full-res
  • W5 parity & perf
  • W6 Docker + deploy + docs