diff --git a/WEB_PLAN.md b/WEB_PLAN.md
index 2949881..62bc9a5 100644
--- a/WEB_PLAN.md
+++ b/WEB_PLAN.md
@@ -1,134 +1,229 @@
-# WEB_PLAN — RecipesCam WebUI trên Docker (branch `recipes-web`)
+# WEB_PLAN — RecipesCam Web (Docker, branch `recipes-web`)
-Trạng thái: kế hoạch, chưa code. Ngày: 2026-09-06.
+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
-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.
+Một web app chạy bằng Docker, gồm 2 phần:
-## 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).
+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` — SKSL nguyên văn cho CanvasKit |
+| `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 mặc định |
+| `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 |
-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).
+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 |
-| `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 |
+| `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 (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.
+- **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 (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).
+- **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, 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.
+- **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**: 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__.jpg`.
+- **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__.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 # 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
+ 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 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)
+ 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/`, `wallframe.png`), `-f web/Dockerfile`.
+Docker build context = repo root (cần `src/utils`, `assets/`), `-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.
+### 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.
-### 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.
+#### 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:
-### 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.
+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):
-### 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).
+| 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 + 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.
+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 — 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.
+### 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 — 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.
+### 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, `` 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** → 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).
+- ~~**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.
-
-## 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)?
+- **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`
-- [ ] W0 spike CanvasKit (parity + font + perf)
-- [ ] W1 scaffold + drag&drop
+- [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
+- [ ] W3 frame/crop/rotate/watermark/ratio + EXIF GPS
- [ ] W4 export/render full-res
-- [ ] W5 parity & perf
-- [ ] W6 Docker + deploy + docs
+- [ ] W5 API + auth + demo
+- [ ] W6 landing page
+- [ ] W7 batch export
+- [ ] W8 parity & perf
+- [ ] W9 Docker + deploy + docs
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..8c52da1
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "recipescam-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "dependencies": {
+ "canvaskit-wasm": "^0.42.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.3",
+ "playwright-core": "^1.63.0",
+ "tsx": "^4.23.13",
+ "typescript": "^7.0.2"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "esbuild"
+ ]
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 0000000..9600899
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,556 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ canvaskit-wasm:
+ specifier: ^0.42.0
+ version: 0.42.0
+ devDependencies:
+ '@types/node':
+ specifier: ^22.20.3
+ version: 22.20.3
+ playwright-core:
+ specifier: ^1.63.0
+ version: 1.63.0
+ tsx:
+ specifier: ^4.23.13
+ version: 4.23.13
+ typescript:
+ specifier: ^7.0.2
+ version: 7.0.2
+
+packages:
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/node@22.20.3':
+ resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==}
+
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@webgpu/types@0.1.21':
+ resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==}
+
+ canvaskit-wasm@0.42.0:
+ resolution: {integrity: sha512-N3ikotmzYwDVAcpQKDMptBT2P5pIEWS9ywdmrQA2awwaMF9cxKEFuxZZHv9ay1Em9kDjxDsPtwUzDm1uNj136g==}
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ playwright-core@1.63.0:
+ resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ tsx@4.23.13:
+ resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ typescript@7.0.2:
+ resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
+ engines: {node: '>=16.20.0'}
+ hasBin: true
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+snapshots:
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@types/node@22.20.3':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ optional: true
+
+ '@webgpu/types@0.1.21': {}
+
+ canvaskit-wasm@0.42.0:
+ dependencies:
+ '@webgpu/types': 0.1.21
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ fsevents@2.3.3:
+ optional: true
+
+ playwright-core@1.63.0: {}
+
+ tsx@4.23.13:
+ dependencies:
+ esbuild: 0.28.2
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ typescript@7.0.2:
+ optionalDependencies:
+ '@typescript/typescript-aix-ppc64': 7.0.2
+ '@typescript/typescript-darwin-arm64': 7.0.2
+ '@typescript/typescript-darwin-x64': 7.0.2
+ '@typescript/typescript-freebsd-arm64': 7.0.2
+ '@typescript/typescript-freebsd-x64': 7.0.2
+ '@typescript/typescript-linux-arm': 7.0.2
+ '@typescript/typescript-linux-arm64': 7.0.2
+ '@typescript/typescript-linux-loong64': 7.0.2
+ '@typescript/typescript-linux-mips64el': 7.0.2
+ '@typescript/typescript-linux-ppc64': 7.0.2
+ '@typescript/typescript-linux-riscv64': 7.0.2
+ '@typescript/typescript-linux-s390x': 7.0.2
+ '@typescript/typescript-linux-x64': 7.0.2
+ '@typescript/typescript-netbsd-arm64': 7.0.2
+ '@typescript/typescript-netbsd-x64': 7.0.2
+ '@typescript/typescript-openbsd-arm64': 7.0.2
+ '@typescript/typescript-openbsd-x64': 7.0.2
+ '@typescript/typescript-sunos-x64': 7.0.2
+ '@typescript/typescript-win32-arm64': 7.0.2
+ '@typescript/typescript-win32-x64': 7.0.2
+
+ undici-types@6.21.0: {}
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
new file mode 100644
index 0000000..00f6fc4
--- /dev/null
+++ b/web/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: set this to true or false
diff --git a/web/spike/bench.ts b/web/spike/bench.ts
new file mode 100644
index 0000000..4323133
--- /dev/null
+++ b/web/spike/bench.ts
@@ -0,0 +1,99 @@
+// Per-step cost of the web pipeline, measured at three sizes. The 12MP run of
+// spike/w0.ts was unusably slow; this isolates WHICH pass eats the time.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { initSkia, Skia } from '../src/engine/skiaShim';
+import { CLARITY_SKSL } from '../../src/utils/toneShader';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repo = path.resolve(here, '../..');
+const SAMPLE = process.env.W0_SAMPLE ?? '/tmp/w0/p2.jpg';
+
+function time(label: string, fn: () => void): number {
+ const t = performance.now();
+ fn();
+ const ms = performance.now() - t;
+ console.log(` ${label.padEnd(26)} ${ms.toFixed(0)}ms`);
+ return ms;
+}
+
+async function main() {
+ await initSkia();
+ const bytes = new Uint8Array(fs.readFileSync(SAMPLE));
+ const src = Skia.Image.MakeImageFromEncoded(bytes);
+ console.log(`source ${src.width()}x${src.height()}`);
+
+ const full = { w: src.width(), h: src.height() };
+ const sizes = [
+ { name: '1024', w: 1024, h: Math.round((1024 * full.h) / full.w) },
+ { name: '2048', w: 2048, h: Math.round((2048 * full.h) / full.w) },
+ { name: 'full', w: full.w, h: full.h },
+ ];
+
+ const sharpen = Skia.RuntimeEffect.Make(CLARITY_SKSL);
+ const tone = Skia.RuntimeEffect.Make(
+ 'uniform shader src; uniform float a; vec4 main(vec2 p){ vec4 c = src.eval(p); return vec4(c.rgb*(1.0+a), c.a);}'
+ );
+
+ for (const s of sizes) {
+ console.log(`\n[sample downscaled to ${s.w}x${s.h}]`);
+ const surf = Skia.Surface.Make(s.w, s.h);
+ const canvas = surf.getCanvas();
+ const paint = Skia.Paint();
+ time('draw downscaled image', () => {
+ canvas.drawImageRect(
+ src, Skia.XYWHRect(0, 0, full.w, full.h), Skia.XYWHRect(0, 0, s.w, s.h), paint
+ );
+ });
+
+ const shader = src.makeShaderOptions(
+ Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
+ );
+ const toneShader = tone.makeShaderWithChildren([0.2], [shader]);
+ time('tone skSL pass', () => {
+ const p = Skia.Paint();
+ p.setShader(toneShader);
+ canvas.drawRect(Skia.XYWHRect(0, 0, s.w, s.h), p);
+ p.dispose();
+ });
+
+ time('clarity skSL 5-tap', () => {
+ const snap = surf.makeImageSnapshot();
+ const child = snap.makeShaderOptions(
+ Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
+ );
+ const sh = sharpen.makeShaderWithChildren([0.3, 1, 1], [child]);
+ const p = Skia.Paint();
+ p.setShader(sh);
+ canvas.drawRect(Skia.XYWHRect(0, 0, s.w, s.h), p);
+ p.dispose(); sh.dispose(); child.dispose(); snap.dispose();
+ });
+
+ time('blur sigma=40 (imagefilter)', () => {
+ const snap = surf.makeImageSnapshot();
+ const p = Skia.Paint();
+ p.setImageFilter(Skia.ImageFilter.MakeBlur(40, 40, Skia.TileMode.Clamp, null));
+ canvas.clear(Skia.Color('transparent'));
+ canvas.drawImage(snap, 0, 0, p);
+ p.dispose(); snap.dispose();
+ });
+
+ time('jpeg q95 encode', () => {
+ const snap = surf.makeImageSnapshot();
+ snap.encodeToBytes(Skia.ImageFormat.JPEG, 95);
+ snap.dispose();
+ });
+ surf.dispose();
+ }
+
+ // Full-res decode cost (what happens once per loaded photo).
+ time('decode 12MP jpeg', () => {
+ Skia.Image.MakeImageFromEncoded(bytes);
+ });
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/web/spike/gpu-bench.mjs b/web/spike/gpu-bench.mjs
new file mode 100644
index 0000000..4fd0b62
--- /dev/null
+++ b/web/spike/gpu-bench.mjs
@@ -0,0 +1,56 @@
+// Drives spike/gpu.html in headless Chromium: is a WebGL-backed CanvasKit
+// surface fast enough to replace the CPU raster path (which measured 3.2s for a
+// single 12MP tone pass in Node)? Run:
+// node spike/gpu-bench.mjs
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright-core';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const webRoot = path.resolve(here, '..');
+const SAMPLES = process.env.W0_SAMPLES ?? '/tmp/w0';
+const CK_BIN = path.join(webRoot, 'node_modules/canvaskit-wasm/bin');
+
+const routes = [
+ { prefix: '/bin/', dir: CK_BIN },
+ { prefix: '/samples/', dir: SAMPLES },
+ { prefix: '/', dir: path.join(webRoot, 'spike') },
+];
+const MIME = { '.js': 'text/javascript', '.wasm': 'application/wasm', '.html': 'text/html', '.jpg': 'image/jpeg', '.png': 'image/png' };
+
+const server = http.createServer((req, res) => {
+ const url = decodeURIComponent(req.url.split('?')[0]);
+ const route = routes.find((r) => url.startsWith(r.prefix));
+ if (!route) { res.writeHead(404).end(); return; }
+ const rel = url.slice(route.prefix.length) || 'gpu.html';
+ const file = path.join(route.dir, rel);
+ fs.readFile(file, (err, data) => {
+ if (err) { res.writeHead(404).end(String(err)); return; }
+ res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
+ res.end(data);
+ });
+});
+
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const port = server.address().port;
+console.log(`serving on ${port}`);
+
+const browser = await chromium.launch({
+ // The cached playwright build is chromium-1228 while playwright-core wants
+ // 1243; point straight at the cached binary instead of downloading one.
+ executablePath: process.env.CHROME_PATH ??
+ '/home/locpham/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
+ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox'],
+});
+const page = await browser.newPage();
+page.on('console', (m) => console.log('[browser]', m.text()));
+page.on('pageerror', (e) => console.log('[pageerror]', e.message));
+await page.goto(`http://127.0.0.1:${port}/gpu.html`, { waitUntil: 'load' });
+await page.waitForFunction(() => document.title === 'done' || document.title === 'error', null, { timeout: 180000 });
+console.log(await page.textContent('#out').catch(() => '(no output element)'));
+const results = await page.evaluate(() => window.__results ?? null);
+console.log('\njson:', JSON.stringify(results));
+await browser.close();
+server.close();
diff --git a/web/spike/gpu.html b/web/spike/gpu.html
new file mode 100644
index 0000000..aa40fc6
--- /dev/null
+++ b/web/spike/gpu.html
@@ -0,0 +1,114 @@
+
+
+W0 GPU bench
+
+running…
+
+
+
+
diff --git a/web/spike/probe.mjs b/web/spike/probe.mjs
new file mode 100644
index 0000000..33ee315
--- /dev/null
+++ b/web/spike/probe.mjs
@@ -0,0 +1,14 @@
+import CanvasKitInit from 'canvaskit-wasm';
+const CK = await CanvasKitInit();
+const has = (o, k) => typeof o?.[k];
+console.log('CK version', CK.version ?? '?');
+console.log('ImageFilter keys:', Object.keys(CK.ImageFilter).join(','));
+console.log('ColorFilter keys:', Object.keys(CK.ColorFilter).join(','));
+console.log('Shader keys:', Object.keys(CK.Shader).join(','));
+console.log('Typeface keys:', Object.keys(CK.Typeface).join(','));
+console.log('Surface keys:', Object.keys(CK.Surface).join(','));
+console.log('RuntimeEffect keys:', Object.keys(CK.RuntimeEffect).join(','));
+console.log('Paint proto keys:', Object.getOwnPropertyNames(CK.Paint.prototype).join(','));
+console.log('Canvas draw*:', Object.getOwnPropertyNames(CK.Canvas.prototype).filter(n=>n.startsWith('draw')).join(','));
+console.log('hasImageFilter', has(CK,'ImageFilter'), 'hasX', has(CK,'XYWHRect'), 'hasColor', has(CK,'Color'));
+console.log('MakeBlur', has(CK.ImageFilter,'MakeBlur'), 'MakeCompose', has(CK.ImageFilter,'MakeCompose'), 'MakeMatrixConvolution', has(CK.ImageFilter,'MakeMatrixConvolution'));
diff --git a/web/spike/probe2.mjs b/web/spike/probe2.mjs
new file mode 100644
index 0000000..8103ff7
--- /dev/null
+++ b/web/spike/probe2.mjs
@@ -0,0 +1,34 @@
+import CanvasKitInit from 'canvaskit-wasm/full';
+import fs from 'node:fs';
+const CK = await CanvasKitInit();
+console.log('MakeSurface', typeof CK.MakeSurface, 'MakeWebGLSurface', typeof CK.MakeWebGLSurface, 'Font', typeof CK.Font, 'TypefaceFontProvider', typeof CK.TypefaceFontProvider, 'MakeManagedAnimation', typeof CK.MakeManagedAnimation);
+console.log('top keys sample:', Object.keys(CK).filter(k=>/^Make|Font|Typeface|Paragraph/.test(k)).join(','));
+const surf = CK.MakeSurface(400, 200);
+console.log('surface', !!surf);
+const c = surf.getCanvas();
+c.clear(CK.Color(255,255,255,255));
+const p = new CK.Paint();
+p.setColor(CK.Color(20,20,20,255));
+const ttf = fs.readFileSync('../assets/Cousine-Regular.ttf');
+const ab = ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset+ttf.byteLength);
+const tf = CK.Typeface.MakeFreeTypeFaceFromData(ab);
+console.log('typeface', !!tf, tf && tf.getGlyphIDs('AB')[0] !== undefined);
+const font = new CK.Font(tf, 32);
+c.drawText('RecipesCam ABC 0123', 20, 80, p, font);
+const emoji = fs.readFileSync('../assets/NotoEmoji-GPS.ttf');
+const eab = emoji.buffer.slice(emoji.byteOffset, emoji.byteOffset+emoji.byteLength);
+const etf = CK.Typeface.MakeFreeTypeFaceFromData(eab);
+console.log('emoji typeface', !!etf);
+if (etf) { const ef = new CK.Font(etf, 32); c.drawText('\u{1F4CD} \u{1F4F7}', 20, 140, p, ef); }
+const img = surf.makeImageSnapshot();
+const bytes = img.encodeToBytes(CK.ImageFormat.JPEG, 95);
+console.log('jpeg bytes', bytes && bytes.length);
+fs.writeFileSync('/tmp/ck-text.jpg', Buffer.from(bytes));
+// runtime effect with child shader
+const eff = CK.RuntimeEffect.Make(`
+uniform shader img; uniform float2 texel; uniform float amount;
+half4 main(float2 p){ half4 c = img.eval(p); half4 n = img.eval(p+float2(0.0,-texel.y)); half4 s = img.eval(p+float2(0.0,texel.y)); half4 w = img.eval(p+float2(-texel.x,0.0)); half4 e = img.eval(p+float2(texel.x,0.0)); return c + amount*(4.0*c-n-s-w-e); }`);
+console.log('runtime effect', !!eff, eff && eff.getUniformCount?.());
+const imgSh = img.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
+const sh = eff && eff.makeShaderWithChildren([1/400, 1/200, 1.0], [imgSh]);
+console.log('shader', !!sh);
diff --git a/web/spike/probe3.mjs b/web/spike/probe3.mjs
new file mode 100644
index 0000000..6c01f71
--- /dev/null
+++ b/web/spike/probe3.mjs
@@ -0,0 +1,10 @@
+import CanvasKitInit from 'canvaskit-wasm';
+const CK = await CanvasKitInit();
+console.log('ImageFormat', JSON.stringify(CK.ImageFormat));
+const surf = CK.MakeSurface(64, 64);
+const c = surf.getCanvas(); c.clear(CK.Color(255,0,0,255));
+const img = surf.makeImageSnapshot();
+for (const k of Object.keys(CK.ImageFormat)) {
+ try { const b = img.encodeToBytes(CK.ImageFormat[k], 90); console.log(k, b ? b.length : null); } catch(e){ console.log(k, 'ERR', String(e).slice(0,80)); }
+}
+console.log('columns/readPixels', typeof img.readPixels, typeof img.encodeToBytes);
diff --git a/web/spike/probe4.mjs b/web/spike/probe4.mjs
new file mode 100644
index 0000000..b3852ba
--- /dev/null
+++ b/web/spike/probe4.mjs
@@ -0,0 +1,11 @@
+import CanvasKitInit from 'canvaskit-wasm/full';
+import fs from 'node:fs';
+const CK = await CanvasKitInit();
+console.log('ImageFormat', JSON.stringify(CK.ImageFormat));
+const surf = CK.MakeSurface(64, 64);
+const c = surf.getCanvas(); c.clear(CK.Color(255,0,0,255));
+const img = surf.makeImageSnapshot();
+for (const k of Object.keys(CK.ImageFormat)) { try { const b = img.encodeToBytes(CK.ImageFormat[k], 90); console.log(k, b ? b.length : null); } catch(e){ console.log(k,'ERR'); } }
+const ttf = fs.readFileSync('../assets/Cousine-Regular.ttf');
+const tf = CK.Typeface.MakeFreeTypeFaceFromData(ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset+ttf.byteLength));
+console.log('freetype', !!tf, 'drawText', typeof CK.Canvas.prototype.drawText, 'MakeSurface', typeof CK.MakeSurface);
diff --git a/web/spike/probe5.mjs b/web/spike/probe5.mjs
new file mode 100644
index 0000000..205be23
--- /dev/null
+++ b/web/spike/probe5.mjs
@@ -0,0 +1,18 @@
+import CanvasKitInit from 'canvaskit-wasm/full';
+import fs from 'node:fs';
+const CK = await CanvasKitInit();
+const load = (p) => { const b = fs.readFileSync(p); return CK.Typeface.MakeFreeTypeFaceFromData(b.buffer.slice(b.byteOffset, b.byteOffset+b.byteLength)); };
+const tf = load('../assets/Cousine-Regular.ttf');
+const etf = load('../assets/NotoEmoji-GPS.ttf');
+const s = '📍📷';
+console.log('cousine glyphs', JSON.stringify(tf.getGlyphIDs(s)));
+console.log('emoji glyphs ', JSON.stringify(etf.getGlyphIDs(s)));
+console.log('textWidth', tf.getTextWidth?.('RECIPESCAM', 24), etf.getTextWidth?.('📍', 24));
+const surf = CK.MakeSurface(200, 100); const c = surf.getCanvas();
+c.clear(CK.Color(0,0,0,255));
+const p = new CK.Paint(); p.setColor(CK.Color(255,255,255,255));
+const f = new CK.Font(etf, 48);
+c.drawText(s, 10, 60, p, f);
+const px = surf.makeImageSnapshot().readPixels(0, 0, {width:200,height:100,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB});
+let lit = 0; for (let i=0;i128) lit++;
+console.log('lit pixels in emoji draw:', lit);
diff --git a/web/spike/w0.ts b/web/spike/w0.ts
new file mode 100644
index 0000000..860c99e
--- /dev/null
+++ b/web/spike/w0.ts
@@ -0,0 +1,175 @@
+// W0 parity/perf spike: run the real web render pipeline in Node (no browser)
+// over sample photos and report time + determinism. Usage:
+// W0_SAMPLES= W0_OUT= corepack pnpm exec tsx spike/w0.ts
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { initSkia, Skia } from '../src/engine/skiaShim';
+import { renderPhoto, type RenderAssets } from '../src/engine/exportEngine';
+import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from '../../src/utils/defaultRecipes';
+import type { FrameId, Recipe } from '../../src/types';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repo = path.resolve(here, '../..');
+const SAMPLE_DIR = process.env.W0_SAMPLES ?? '/tmp/w0';
+const OUT_DIR = process.env.W0_OUT ?? '/tmp/w0-out';
+
+const ab = (p: string): ArrayBuffer => {
+ const b = fs.readFileSync(p);
+ return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer;
+};
+
+const assets: RenderAssets = {
+ textFont: ab(path.join(repo, 'assets/Cousine-Regular.ttf')),
+ emojiFont: ab(path.join(repo, 'assets/NotoEmoji-GPS.ttf')),
+ wallframe: new Uint8Array(fs.readFileSync(path.join(repo, 'wallframe.png'))),
+};
+
+// One recipe per film sim + a "everything on" torture case + a cinema grade.
+const heavy: Recipe = {
+ id: 'w0-heavy',
+ name: 'W0 HEAVY',
+ frameId: 'none',
+ useGeotag: true,
+ baseFilter: 'classic-neg',
+ cinema: 'autumn',
+ adjustments: {
+ ...DEFAULT_ADJUSTMENTS,
+ exposure: 0.6,
+ exposureCompensation: 2,
+ contrast: 12,
+ saturation: 8,
+ vibrance: 6,
+ highlight: -4,
+ shadow: 5,
+ temperature: 6100,
+ tint: 3,
+ colorChrome: 'weak',
+ colorChromeBlue: 'weak',
+ denoise: 3,
+ clarity: 4,
+ sharpening: 3,
+ grain: 4,
+ hdf: 4,
+ vignette: 3,
+ },
+};
+
+const QUICK = process.env.W0_QUICK === '1';
+
+const CASES: { label: string; recipe: Recipe; frame: FrameId; extra?: Record }[] = [
+ ...FILM_SIMS.map((s) => ({ label: `sim:${s.id}`, recipe: filmSimToRecipe(s), frame: 'none' as FrameId })),
+ { label: 'heavy', recipe: heavy, frame: 'none' },
+ { label: 'heavy+polaroid', recipe: heavy, frame: 'polaroid' },
+ { label: 'heavy+classic', recipe: heavy, frame: 'classic-white' },
+ {
+ label: 'heavy+wallframe',
+ recipe: heavy,
+ frame: 'wallframe',
+ extra: {
+ gpsWatermark: { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true },
+ useGeotag: true,
+ },
+ },
+ { label: 'heavy+wm-rot90', recipe: heavy, frame: 'none', extra: {
+ watermark: { text: 'RECIPESCAM', x: 0.5, y: 0.9 },
+ watermarkRotation: 90,
+ } },
+];
+
+const GPS = {
+ latitude: 21.0285,
+ longitude: 105.8542,
+ locality: 'HÀ NỘI',
+ timestamp: Date.now(),
+};
+
+async function main() {
+ const t0 = performance.now();
+ await initSkia();
+ console.log(`CanvasKit init: ${(performance.now() - t0).toFixed(0)}ms`);
+
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ const samples = fs.readdirSync(SAMPLE_DIR).filter((f) => /\.jpe?g$/i.test(f)).sort();
+ if (samples.length === 0) throw new Error(`no sample jpgs in ${SAMPLE_DIR}`);
+ console.log(`samples: ${samples.join(', ')}`);
+
+ // Stats on the encoded file: a render that silently produced a blank frame
+ // would still "succeed", so mean/stddev of the decoded output is the cheap
+ // proof it contains a picture.
+ const stats = (bytes: Uint8Array) => {
+ const img = Skia.Image.MakeImageFromEncoded(bytes);
+ if (!img) return 'decode-fail';
+ const w = img.width();
+ const h = img.height();
+ const px = img.readPixels(0, 0, {
+ width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
+ alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
+ });
+ img.dispose();
+ if (!px) return 'read-fail';
+ let sum = 0;
+ let sum2 = 0;
+ let n = 0;
+ for (let i = 0; i < px.length; i += 4) {
+ const l = 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
+ sum += l;
+ sum2 += l * l;
+ n++;
+ }
+ const mean = sum / n;
+ return `mean=${mean.toFixed(1)} sd=${Math.sqrt(Math.max(0, sum2 / n - mean * mean)).toFixed(1)}`;
+ };
+
+ const rows: string[] = [];
+ for (const sample of samples) {
+ const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, sample)));
+ const base = sample.replace(/\.jpe?g$/i, '');
+ const cases = QUICK ? CASES.slice(0, 2).concat(CASES.filter((x) => /heavy/.test(x.label)).slice(0, 2)) : CASES;
+ for (const c of cases) {
+ const dpi = 300;
+ const input = {
+ source,
+ recipe: c.recipe,
+ frameId: c.frame,
+ useGeotag: (c.extra?.useGeotag as boolean) ?? false,
+ gpsInfo: GPS,
+ options: {
+ dpi,
+ sharpen: true,
+ watermark: (c.extra?.watermark as never) ?? null,
+ watermarkRotation: (c.extra?.watermarkRotation as number) ?? 0,
+ gpsWatermark: (c.extra?.gpsWatermark as never) ?? null,
+ wallframeLandscape: false,
+ },
+ assets,
+ };
+ const t = performance.now();
+ const out = await renderPhoto(input);
+ const ms = performance.now() - t;
+ if (!out) {
+ console.log(`FAIL ${sample} ${c.label}`);
+ continue;
+ }
+ const file = path.join(OUT_DIR, `${base}__${c.label.replace(/[^a-z0-9+]/gi, '_')}.jpg`);
+ fs.writeFileSync(file, out.bytes);
+ const jfif = new DataView(out.bytes.buffer, out.bytes.byteOffset).getUint16(14, false);
+ rows.push(`${sample}\t${c.label}\t${out.width}x${out.height}\t${(out.bytes.length / 1024).toFixed(0)}KB\t${ms.toFixed(0)}ms\tdpi=${jfif}\t${stats(out.bytes)}`);
+ }
+ }
+ console.log('\nsample\tcase\tsize\tencoded\ttime\tdpi');
+ console.log(rows.join('\n'));
+
+ // Determinism: the same input twice must produce identical bytes (a renderer
+ // that is stable in Node is the baseline the browser worker must match).
+ const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, samples[0])));
+ const args = { source, recipe: heavy, frameId: 'none' as FrameId, assets };
+ const a = await renderPhoto(args);
+ const b = await renderPhoto(args);
+ console.log('\ndeterministic:', !!a && !!b && Buffer.compare(Buffer.from(a.bytes), Buffer.from(b.bytes)) === 0);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/web/src/engine/exportEngine.ts b/web/src/engine/exportEngine.ts
new file mode 100644
index 0000000..7c8f62d
--- /dev/null
+++ b/web/src/engine/exportEngine.ts
@@ -0,0 +1,690 @@
+// Full-resolution render pipeline for the web build.
+//
+// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit,
+// step for step, so the file a browser downloads matches the file the phone
+// writes. What changed and why (W0 probe, measured — see skiaShim.ts header):
+// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
+// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
+// gone (the browser downloads a Blob instead).
+// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
+// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
+// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
+// as a shader pass. `convolve` replaces the pixels outright (the shader's
+// output is opaque and covers the same rect), so no clearing pass is needed.
+// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
+// be composited over the un-blurred content, so those passes snapshot, clear
+// and redraw. Order matches RN's nested filter chain: denoise → clarity →
+// mist → soften.
+// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
+// alone keeps the print size metadata.
+// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
+
+import { Skia } from './skiaShim';
+import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../../src/types';
+import { ASPECT_RATIO_W_H } from '../../../src/types';
+import { getSkiaColorMatrix, applyExposureGain } from '../../../src/utils/colorUtils';
+import {
+ TONE_SKSL,
+ GLOW_SKSL,
+ CLARITY_SKSL,
+ getToneUniforms,
+ toneIsActive,
+ toneUniformArray,
+ glowUniformArray,
+} from '../../../src/utils/toneShader';
+import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../../src/utils/cinemaShader';
+import {
+ drawFrameOnCanvas,
+ polaroidLayout,
+ POLAROID_CARD,
+ POLAROID_WIN_W,
+ wallframeLayout,
+ WALLFRAME_W,
+ WALLFRAME_H,
+ WALLFRAME_LAND_W,
+ WALLFRAME_LAND_H,
+} from '../../../src/utils/frameUtils';
+import { patchJpegDpi } from '../../../src/utils/jpegDpi';
+import { applyPhotoRotation } from '../../../src/utils/skiaImage';
+
+export interface RenderAssets {
+ textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
+ emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
+ wallframe: Uint8Array; // wallframe.png
+}
+
+export interface RenderOptions {
+ dpi?: number;
+ sharpen?: boolean;
+ evFromCamera?: boolean;
+ aspect?: AspectRatio;
+ cropWH?: number;
+ cropRect?: CropRect | null;
+ watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
+ liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
+ watermarkRotation?: number;
+ gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
+ wallframeLandscape?: boolean;
+ frameWindowZoom?: { s: number; u: number; v: number } | null;
+ photoRotation?: 0 | 90 | 180 | 270;
+ photoStraighten?: number;
+}
+
+export interface RenderInput {
+ source: Uint8Array;
+ recipe: Recipe;
+ frameId: FrameId;
+ useGeotag?: boolean;
+ gpsInfo?: GPSInfo | null;
+ options?: RenderOptions;
+ assets: RenderAssets;
+}
+
+export interface RenderResult {
+ bytes: Uint8Array;
+ width: number;
+ height: number;
+}
+
+type SkDisposable = { dispose(): void };
+
+function disposeAll(items: SkDisposable[]): void {
+ for (let i = items.length - 1; i >= 0; i--) {
+ try {
+ items[i].dispose();
+ } catch {
+ /* already released */
+ }
+ }
+}
+
+function release(owned: SkDisposable[], item: SkDisposable | null): void {
+ if (!item) return;
+ const i = owned.indexOf(item);
+ if (i >= 0) owned.splice(i, 1);
+ disposeAll([item]);
+}
+
+function createSurface(width: number, height: number) {
+ return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
+}
+
+// A WebGL surface batches draws; the readback paths (snapshot, encode) must see
+// them submitted first. A no-op on the raster fallback.
+function flush(surface: any): void {
+ if (typeof surface?.flush === 'function') surface.flush();
+}
+
+// --- shared runtime effects -------------------------------------------------
+
+let sharpenEffect: any = null;
+let toneEffect: any = null;
+let cinemaEffect: any = null;
+let glowEffect: any = null;
+
+function effects() {
+ if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
+ if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
+ if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
+ if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
+ return { sharpenEffect, toneEffect, cinemaEffect, glowEffect };
+}
+
+// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
+// a 1:1 export canvas, so the radius matches what the preview tuned.
+function convolvePaint(srcImage: any, amount: number): any {
+ const { sharpenEffect: effect } = effects();
+ if (!effect) return null;
+ const paint = Skia.Paint();
+ const child = srcImage.makeShaderOptions(
+ Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
+ );
+ const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]);
+ if (!shader) {
+ paint.dispose();
+ child.dispose();
+ return null;
+ }
+ paint.setShader(shader);
+ return { paint, shader, child };
+}
+
+// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
+// outermost filter of the draw), so the pass reads a snapshot of the surface
+// and writes the sharpened result back over the same rect.
+function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
+ flush(surface);
+ const snap = surface.makeImageSnapshot();
+ if (!snap) return;
+ drawConvolved(canvas, snap, w, h, amount);
+ snap.dispose();
+}
+
+// Replaces the pixels under the rect with the convolved result.
+function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
+ const built = convolvePaint(srcImage, amount);
+ if (!built) return;
+ canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint);
+ disposeAll([built.paint, built.shader, built.child]);
+}
+
+function convolveImage(image: any, amount: number): any {
+ const w = image.width();
+ const h = image.height();
+ const surf = createSurface(w, h);
+ if (!surf) return image;
+ try {
+ drawConvolved(surf.getCanvas(), image, w, h, amount);
+ return surf.makeImageSnapshot() ?? image;
+ } finally {
+ surf.dispose();
+ }
+}
+
+function screenSharpenImage(image: any, amount = 0.5): any {
+ return convolveImage(image, amount);
+}
+
+// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
+// pixels it is meant to replace.
+function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
+ flush(surface);
+ const snap = surface.makeImageSnapshot();
+ const paint = Skia.Paint();
+ const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
+ paint.setImageFilter(filter);
+ canvas.clear(Skia.Color('transparent'));
+ canvas.drawImage(snap, 0, 0, paint);
+ disposeAll([paint, filter, snap]);
+}
+
+// --- fonts / artwork --------------------------------------------------------
+
+let faces: { typeface: any; iconTypeface: any } | null = null;
+let wallframeImage: any = null;
+
+function loadFaces(assets: RenderAssets) {
+ if (faces) return faces;
+ const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont);
+ const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
+ if (!typeface || !iconTypeface) return null;
+ faces = { typeface, iconTypeface };
+ // Named-family lookup for the custom watermark's FONT option: register the
+ // bundled face so at least that one name resolves on web.
+ try {
+ Skia.FontMgr.System().registerFace(typeface, 'Cousine');
+ } catch {
+ /* provider optional */
+ }
+ return faces;
+}
+
+function loadWallframe(assets: RenderAssets) {
+ if (wallframeImage) return wallframeImage;
+ wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe);
+ return wallframeImage;
+}
+
+export async function renderPhoto(input: RenderInput): Promise {
+ const { recipe, frameId, options } = input;
+ const owned: SkDisposable[] = [];
+ const own = (obj: T | null | undefined): T => {
+ if (obj) owned.push(obj as unknown as SkDisposable);
+ return obj as T;
+ };
+ try {
+ const skImage0 = Skia.Image.MakeImageFromEncoded(input.source);
+ if (!skImage0) {
+ console.error('Failed to parse source image');
+ return null;
+ }
+ own(skImage0);
+ let skImage = skImage0;
+
+ const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
+ if (rotated && rotated !== skImage) {
+ own(rotated);
+ release(owned, skImage);
+ skImage = rotated;
+ }
+ let width = skImage.width();
+ let height = skImage.height();
+
+ // 1b. Composition / FRAME-tab crop — identical maths to the RN engine.
+ const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
+ const cropRect = options?.cropRect ?? null;
+ const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
+ if ((cropRect || ratioWH) && frameId !== 'wallframe') {
+ const curAspect = width / height;
+ let cropW = width;
+ let cropH = height;
+ let srcX = 0;
+ let srcY = 0;
+ if (cropRect) {
+ srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width)));
+ srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height)));
+ cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width)));
+ cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height)));
+ } else if (curAspect > (ratioWH as number)) {
+ cropW = Math.round(height * (ratioWH as number));
+ srcX = Math.round((width - cropW) / 2);
+ } else {
+ cropH = Math.round(width / (ratioWH as number));
+ srcY = Math.round((height - cropH) / 2);
+ }
+ const cropSurface = createSurface(cropW, cropH);
+ if (!cropSurface) return null;
+ own(cropSurface);
+ cropSurface.getCanvas().drawImageRect(
+ skImage,
+ Skia.XYWHRect(srcX, srcY, cropW, cropH),
+ Skia.XYWHRect(0, 0, cropW, cropH),
+ own(Skia.Paint())
+ );
+ const cropped = cropSurface.makeImageSnapshot();
+ if (!cropped) return null;
+ own(cropped);
+ release(owned, skImage);
+ release(owned, cropSurface);
+ skImage = cropped;
+ width = cropW;
+ height = cropH;
+ }
+
+ // 2. Render surface.
+ const surface = createSurface(width, height);
+ if (!surface) return null;
+ own(surface);
+ const canvas = surface.getCanvas();
+ const paint = own(Skia.Paint());
+
+ const adjustments = recipe.adjustments;
+
+ // 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
+ const evStops = adjustments.exposureCompensation ?? 0;
+ const evMatrix = options?.evFromCamera
+ ? getSkiaColorMatrix(recipe.baseFilter, adjustments)
+ : applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
+ const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
+ paint.setColorFilter(colorFilter);
+
+ // 3b. Tone shader.
+ const tone = getToneUniforms(adjustments, recipe.baseFilter);
+ let toneShader: any = null;
+ if (toneIsActive(tone)) {
+ const { toneEffect: effect } = effects();
+ if (effect) {
+ const imageShader = own(
+ skImage.makeShaderOptions(
+ Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
+ )
+ );
+ toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
+ if (toneShader) paint.setShader(toneShader);
+ }
+ }
+
+ // 3c. Cinema seasonal grade (cinema → tone → image).
+ const cinema = getCinemaUniforms(recipe.cinema);
+ let paintShader = toneShader;
+ if (cinema && cinemaIsActive(cinema.flat)) {
+ const { cinemaEffect: effect } = effects();
+ if (effect) {
+ const child =
+ toneShader ??
+ own(skImage.makeShaderOptions(
+ Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
+ ));
+ const cs = own(effect.makeShaderWithChildren(cinema.flat, [child]));
+ if (cs) {
+ paintShader = cs;
+ paint.setShader(paintShader);
+ }
+ }
+ }
+
+ // 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
+ if (adjustments.denoise > 0) {
+ const sigma = (adjustments.denoise / 10) * 0.6;
+ paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
+ }
+
+ // 5. Draw the graded photo.
+ if (paintShader) {
+ canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
+ } else {
+ canvas.drawImage(skImage, 0, 0, paint);
+ }
+
+ // 4b. CLARITY (positive) — convolution outside the denoise blur, the same
+ // nesting RN builds with MakeMatrixConvolution(input = previous filter).
+ if (adjustments.clarity > 0) {
+ convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
+ } else if (adjustments.clarity < 0) {
+ const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
+ drawBlurred(canvas, surface, width, height, mistSigma);
+ }
+ // Negative SHARPENING takes edge enhancement back out — after the conv, so
+ // it cannot blur away what the CLARITY pass just added.
+ const sharpKnob = adjustments.sharpening ?? 0;
+ if (sharpKnob < 0) {
+ drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
+ }
+
+ // 5b. HDF bright-pass bloom.
+ if ((adjustments.hdf ?? 0) > 0) {
+ const strength = (adjustments.hdf ?? 0) / 10;
+ const sigma = width * (0.004 + 0.015 * strength);
+ const { glowEffect: effect } = effects();
+ const srcShader =
+ paintShader ??
+ own(skImage.makeShaderOptions(
+ Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
+ ));
+ const glowShader =
+ effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
+ const glowPaint = own(Skia.Paint());
+ glowPaint.setBlendMode(Skia.BlendMode.Screen);
+ glowPaint.setAlphaf(0.15 + 0.35 * strength);
+ if (glowShader != null) {
+ glowPaint.setShader(glowShader);
+ glowPaint.setImageFilter(
+ own(Skia.ImageFilter.MakeCompose(
+ own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
+ own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
+ ))
+ );
+ canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
+ } else {
+ const hiMatrix = [
+ 2.5, 0, 0, 0, -1.5,
+ 0, 2.5, 0, 0, -1.5,
+ 0, 0, 2.5, 0, -1.5,
+ 0, 0, 0, 1, 0,
+ ];
+ glowPaint.setImageFilter(
+ own(Skia.ImageFilter.MakeCompose(
+ own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
+ own(Skia.ImageFilter.MakeColorFilter(
+ own(Skia.ColorFilter.MakeCompose(
+ own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
+ colorFilter
+ )),
+ null
+ ))
+ ))
+ );
+ if (paintShader) {
+ glowPaint.setShader(paintShader);
+ canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
+ } else {
+ canvas.drawImage(skImage, 0, 0, glowPaint);
+ }
+ }
+ }
+
+ // 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
+ const grainAmount = Math.min(
+ 10,
+ Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
+ );
+ if (grainAmount > 0) {
+ const grainPaint = own(Skia.Paint());
+ grainPaint.setBlendMode(Skia.BlendMode.Overlay);
+ grainPaint.setAlphaf(grainAmount / 20);
+ const noiseEffect = own(Skia.RuntimeEffect.Make(`
+ uniform float u;
+ float grainHash(vec2 q) {
+ return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
+ }
+ vec4 main(vec2 pos) {
+ vec2 p = pos.xy / max(u, 1.0);
+ vec2 i = floor(p);
+ vec2 f = p - i;
+ f = f * f * (3.0 - 2.0 * f);
+ float g00 = grainHash(i);
+ float g10 = grainHash(i + vec2(1.0, 0.0));
+ float g01 = grainHash(i + vec2(0.0, 1.0));
+ float g11 = grainHash(i + vec2(1.0, 1.0));
+ return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
+ }
+ `));
+ const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
+ if (noiseShader) {
+ grainPaint.setShader(noiseShader);
+ canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
+ }
+ }
+
+ // 6b. Vignette.
+ if ((adjustments.vignette ?? 0) > 0) {
+ const v = Math.min(10, adjustments.vignette ?? 0) / 10;
+ const vignetteShader = own(Skia.Shader.MakeRadialGradient(
+ Skia.Point(width / 2, height / 2),
+ Math.hypot(width, height) / 2,
+ [Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
+ [0, 0.45, 1],
+ Skia.TileMode.Clamp
+ ));
+ if (vignetteShader) {
+ const vignettePaint = own(Skia.Paint());
+ vignettePaint.setShader(vignetteShader);
+ vignettePaint.setAlphaf(0.9 * v);
+ canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
+ }
+ }
+
+ release(owned, skImage);
+
+ // 7. Frame.
+ let outputSurface = surface;
+ let stampCanvas = canvas;
+ let stampW = width;
+ let stampH = height;
+ let cardSource: any = null;
+ if (frameId === 'polaroid') {
+ flush(surface);
+ const photoImg = own(surface.makeImageSnapshot());
+ cardSource = photoImg;
+ const outW = width;
+ const frameAspect =
+ ratioWH ??
+ (options?.photoRotation === 90 || options?.photoRotation === 270
+ ? height / width
+ : width / height);
+ const outH = Math.round(
+ width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) +
+ (width * POLAROID_WIN_W) / frameAspect
+ );
+ const cardSurface = createSurface(outW, outH);
+ if (!cardSurface) return null;
+ own(cardSurface);
+ const cardCanvas = cardSurface.getCanvas();
+ const bgPaint = own(Skia.Paint());
+ bgPaint.setColor(Skia.Color('#faf9f6'));
+ cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
+ const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
+ const win = pl.window;
+ const plainPaint = own(Skia.Paint());
+ const pz = options?.frameWindowZoom ?? null;
+ const pwz = pz && pz.s > 1.001 ? pz : null;
+ const pVisW = pwz ? width / pwz.s : width;
+ const pVisH = pwz ? height / pwz.s : height;
+ const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0;
+ const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0;
+ const srcAspect = width / height;
+ let dstW = win.w;
+ let dstH = dstW / srcAspect;
+ if (dstH > win.h) {
+ dstH = win.h;
+ dstW = dstH * srcAspect;
+ }
+ cardCanvas.drawImageRect(
+ photoImg,
+ Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
+ Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
+ plainPaint
+ );
+ const seamPaint = own(Skia.Paint());
+ seamPaint.setColor(Skia.Color('#e5e5e5'));
+ seamPaint.setStyle(1);
+ seamPaint.setStrokeWidth(1);
+ cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
+ outputSurface = cardSurface;
+ stampCanvas = cardCanvas;
+ stampW = outW;
+ stampH = outH;
+ } else if (frameId === 'wallframe') {
+ flush(surface);
+ const photoImg = own(surface.makeImageSnapshot());
+ cardSource = photoImg;
+ const pw = photoImg.width();
+ const ph = photoImg.height();
+ const wallLand = options?.wallframeLandscape === true;
+ const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W;
+ const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H;
+ const cardSurface = createSurface(outW, outH);
+ if (!cardSurface) return null;
+ own(cardSurface);
+ const cardCanvas = cardSurface.getCanvas();
+ const wfImage = loadWallframe(input.assets);
+ if (!wfImage) {
+ console.error('Failed to decode wallframe.png');
+ return null;
+ }
+ const wl = wallframeLayout(outW, outH, 'fill', wallLand);
+ const win = wl.window;
+ const plainPaint = own(Skia.Paint());
+ const scale = Math.max(win.w / pw, win.h / ph);
+ const srcW = win.w / scale;
+ const srcH = win.h / scale;
+ const planeX = (pw - srcW) / 2;
+ const planeY = (ph - srcH) / 2;
+ const wz = options?.frameWindowZoom ?? null;
+ const wZoom = wz && wz.s > 1.001 ? wz : null;
+ const wVisW = wZoom ? srcW / wZoom.s : srcW;
+ const wVisH = wZoom ? srcH / wZoom.s : srcH;
+ const wSx = wZoom
+ ? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW)
+ : planeX;
+ const wSy = wZoom
+ ? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH)
+ : planeY;
+ cardCanvas.drawImageRect(
+ photoImg,
+ Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
+ Skia.XYWHRect(win.x, win.y, win.w, win.h),
+ plainPaint
+ );
+ cardCanvas.save();
+ if (wallLand) {
+ cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
+ } else {
+ cardCanvas.translate(outW, 0);
+ cardCanvas.rotate(90, 0, 0);
+ cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
+ }
+ cardCanvas.restore();
+ outputSurface = cardSurface;
+ stampCanvas = cardCanvas;
+ stampW = outW;
+ stampH = outH;
+ } else {
+ drawFrameOnCanvas(canvas, width, height, frameId);
+ }
+ if (cardSource) {
+ release(owned, cardSource);
+ release(owned, surface);
+ }
+
+ // 8. GPS + custom watermark.
+ const customWm = options?.watermark ?? null;
+ const liteWm = options?.liteMark ?? null;
+ if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) {
+ const loaded = loadFaces(input.assets);
+ if (loaded) {
+ const { typeface, iconTypeface } = loaded;
+ const fontSize = Math.round(stampW * 0.032);
+ const textPaint = own(Skia.Paint());
+ textPaint.setColor(Skia.Color('#f59e0b'));
+ const iconPaint = own(Skia.Paint());
+ iconPaint.setColor(Skia.Color('#ffffff'));
+
+ if (input.useGeotag && input.gpsInfo) {
+ const locationName = input.gpsInfo.locality || 'STREET VIEW';
+ const d = new Date(input.gpsInfo.timestamp);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ const gpsPos = options?.gpsWatermark ?? null;
+ const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
+ const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
+ const gpsFont = own(Skia.Font(typeface, gpsSize));
+ const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
+ const gpsGap = Math.round(gpsSize * 1.6);
+ const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
+ const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
+ const lines: { icon: string; text: string }[] = [];
+ if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
+ if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
+ textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
+ const wmRot = options?.watermarkRotation ?? 0;
+ stampCanvas.save();
+ if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
+ lines.forEach((line, i) => {
+ const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2));
+ stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont);
+ stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont);
+ });
+ stampCanvas.restore();
+ }
+
+ if (customWm || liteWm) {
+ const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
+ const wmRot = options?.watermarkRotation ?? 0;
+ for (const mark of [customWm, liteWm]) {
+ if (!mark) continue;
+ const wmSize = Math.round(fontSize * (mark.size ?? 1));
+ const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
+ const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
+ const wmPaint = own(Skia.Paint());
+ wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
+ const tx = clamp01(mark.x) * stampW;
+ const ty = clamp01(mark.y) * stampH + Math.round(wmSize * 0.35);
+ stampCanvas.save();
+ if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
+ stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont);
+ stampCanvas.restore();
+ }
+ }
+ }
+ }
+
+ // 9. Snapshot, optional output sharpen, JPEG encode + DPI patch.
+ flush(outputSurface);
+ let resultImage = own(outputSurface.makeImageSnapshot());
+ const sharpenAmount =
+ sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
+ if (sharpenAmount > 0) {
+ const sharpened = screenSharpenImage(resultImage, sharpenAmount);
+ if (sharpened && sharpened !== resultImage) {
+ own(sharpened);
+ release(owned, resultImage);
+ resultImage = sharpened;
+ }
+ }
+ let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
+ if (!bytes || bytes.length === 0) {
+ console.error('Failed to encode image to JPEG');
+ return null;
+ }
+ if (options?.dpi && options.dpi > 0) {
+ bytes = patchJpegDpi(bytes, options.dpi);
+ }
+ return { bytes, width: resultImage.width(), height: resultImage.height() };
+ } catch (error) {
+ console.error('Render failed:', error);
+ return null;
+ } finally {
+ disposeAll(owned);
+ }
+}
diff --git a/web/src/engine/skiaShim.ts b/web/src/engine/skiaShim.ts
new file mode 100644
index 0000000..fb74b62
--- /dev/null
+++ b/web/src/engine/skiaShim.ts
@@ -0,0 +1,198 @@
+// CanvasKit-backed stand-in for `@shopify/react-native-skia`.
+//
+// The web build aliases the package name to THIS module (vite `resolve.alias`,
+// tsconfig `paths`), so every renderer we share with the app — `frameUtils.ts`,
+// `skiaImage.ts`, the ported `exportEngine.ts` — runs unchanged on CanvasKit.
+//
+// W0 probe results that shaped this file (measured in Node, canvaskit-wasm 0.42):
+// - the default build CANNOT encode JPEG (`encodeToBytes(JPEG)` → null), the
+// `canvaskit-wasm/full` build can → always import the full build (+0.9MB wasm);
+// - `Skia.ImageFilter.MakeMatrixConvolution` DOES NOT EXIST in CanvasKit → the
+// two convolution steps (CLARITY, output sharpen) are re-implemented as an
+// SkSL runtime effect, see convolve.ts;
+// - `Surface.MakeOffscreen` does not exist either → raster `MakeSurface` only
+// (workers have no GPU context, which is fine: the render is CPU anyway);
+// - `Skia.Font`/`Typeface.MakeFreeTypeFaceFromData`/`Canvas.drawText` all work,
+// and `assets/NotoEmoji-GPS.ttf` really paints 📍/📷 (1731 lit pixels in the
+// probe) → GPS watermark needs no Canvas2D fallback.
+
+import CanvasKitInit from 'canvaskit-wasm/full';
+
+// Live binding: modules import `{ Skia }` once, `initSkia()` fills it in before
+// the first render. ESM live bindings make the late assignment visible.
+export let Skia: any = null;
+export let CK: any = null;
+
+export type SkCanvas = any;
+export type SkPaint = any;
+export type SkImage = any;
+export type SkShader = any;
+export type SkSurface = any;
+export type SkTypeface = any;
+export type SkFont = any;
+
+// CanvasKit's EMBind objects are released with `delete()`; the RN Skia API (and
+// therefore the shared renderers) calls `dispose()`.
+const DISPOSABLE = [
+ 'Image', 'Surface', 'Paint', 'Shader', 'ImageFilter', 'ColorFilter',
+ 'Typeface', 'Font', 'RuntimeEffect', 'Path', 'Canvas', 'BlendMode',
+];
+
+function patchPrototypes(canvasKit: any): void {
+ for (const name of DISPOSABLE) {
+ const proto = canvasKit[name]?.prototype;
+ if (proto && typeof proto.delete === 'function' && typeof proto.dispose !== 'function') {
+ proto.dispose = function dispose() { this.delete(); };
+ }
+ }
+ // frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke).
+ const setStyle = canvasKit.Paint.prototype.setStyle;
+ canvasKit.Paint.prototype.setStyle = function (style: any) {
+ return setStyle.call(
+ this,
+ typeof style === 'number'
+ ? (style === 1 ? canvasKit.PaintStyle.Stroke : canvasKit.PaintStyle.Fill)
+ : style
+ );
+ };
+}
+
+export interface InitSkiaOptions {
+ // Browser: path of canvaskit.wasm (Vite serves it as an asset). Node: omitted.
+ locateFile?: (file: string) => string;
+}
+
+function rasterSurface(w: number, h: number) {
+ return CK.MakeSurface(w, h);
+}
+
+// ponytail: one OffscreenCanvas per surface. The pipeline holds at most ~3 at
+// once, far under the browser's ~16 live WebGL contexts; add a size-keyed pool
+// if batch rendering ever keeps more alive.
+function gpuSurface(w: number, h: number) {
+ if (typeof OffscreenCanvas === 'undefined') return null;
+ try {
+ const canvas = new OffscreenCanvas(w, h);
+ const surf = CK.MakeWebGLCanvasSurface(canvas);
+ if (!surf) return null;
+ const dispose = surf.dispose.bind(surf);
+ surf.dispose = () => {
+ try {
+ dispose();
+ } finally {
+ canvas.width = 0;
+ canvas.height = 0;
+ }
+ };
+ return surf;
+ } catch {
+ return null; // no GL context available — caller falls back to raster
+ }
+}
+
+export async function initSkia(options: InitSkiaOptions = {}): Promise {
+ if (Skia) return Skia;
+ const canvasKit = await (CanvasKitInit as any)(
+ options.locateFile ? { locateFile: options.locateFile } : undefined
+ );
+ CK = canvasKit;
+ patchPrototypes(canvasKit);
+
+ Skia = {
+ // --- geometry / color ---------------------------------------------------
+ XYWHRect: (x: number, y: number, w: number, h: number) => canvasKit.XYWHRect(x, y, w, h),
+ Point: (x: number, y: number) => [x, y],
+ Color: (value: string) =>
+ value === 'transparent'
+ ? canvasKit.Color(0, 0, 0, 0)
+ : canvasKit.parseColorString(value),
+ Color4f: (r: number, g: number, b: number, a: number) => canvasKit.Color4f(r, g, b, a),
+
+ // --- factories ----------------------------------------------------------
+ Paint: () => new canvasKit.Paint(),
+ Font: (typeface: any, size: number) => new canvasKit.Font(typeface, size),
+ Data: {
+ // CanvasKit exposes no Data class; callers pass bytes straight through.
+ fromBytes: (bytes: Uint8Array) => bytes,
+ },
+ Image: {
+ MakeImageFromEncoded: (data: Uint8Array) =>
+ canvasKit.MakeImageFromEncoded(
+ data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer)
+ ),
+ },
+ Typeface: {
+ MakeFreeTypeFaceFromData: (data: ArrayBuffer) =>
+ canvasKit.Typeface.MakeFreeTypeFaceFromData(data),
+ },
+ ColorFilter: {
+ MakeMatrix: (m: number[]) => canvasKit.ColorFilter.MakeMatrix(m),
+ MakeCompose: (outer: any, inner: any) => canvasKit.ColorFilter.MakeCompose(outer, inner),
+ },
+ ImageFilter: {
+ MakeBlur: (sx: number, sy: number, tile: any, input: any) =>
+ canvasKit.ImageFilter.MakeBlur(sx, sy, tile, input ?? null),
+ MakeCompose: (outer: any, inner: any) =>
+ canvasKit.ImageFilter.MakeCompose(outer ?? null, inner ?? null),
+ MakeColorFilter: (cf: any, input: any) =>
+ canvasKit.ImageFilter.MakeColorFilter(cf, input ?? null),
+ },
+ Shader: {
+ MakeRadialGradient: (
+ center: number[], radius: number, colors: any[], pos: number[], mode: any
+ ) => canvasKit.Shader.MakeRadialGradient(center, radius, colors, pos, mode),
+ },
+ RuntimeEffect: {
+ Make: (sksl: string) => canvasKit.RuntimeEffect.Make(sksl),
+ },
+ Surface: {
+ // A WebGL-backed surface is not a nicety here, it is the difference
+ // between usable and unusable: the same 12MP pipeline that finishes in
+ // well under a second on a GL surface took 3.2s for ONE tone pass and
+ // 13.7s for the CLARITY tap in the Node CPU raster benchmark. Raster is
+ // the fallback for a context-less environment (Node spikes, tests).
+ Make: (w: number, h: number) => rasterSurface(w, h),
+ MakeOffscreen: (w: number, h: number) => gpuSurface(w, h) ?? rasterSurface(w, h),
+ },
+ FontMgr: {
+ System: () => fontMgr,
+ },
+
+ // --- enums --------------------------------------------------------------
+ BlendMode: canvasKit.BlendMode,
+ TileMode: canvasKit.TileMode,
+ FilterMode: canvasKit.FilterMode,
+ MipmapMode: canvasKit.MipmapMode,
+ FontStyle: canvasKit.FontStyle,
+ ImageFormat: canvasKit.ImageFormat,
+ ColorType: canvasKit.ColorType,
+ ColorSpace: canvasKit.ColorSpace,
+ AlphaType: canvasKit.AlphaType,
+ PaintStyle: canvasKit.PaintStyle,
+ };
+
+ fontMgr = makeFontMgr(canvasKit);
+ return Skia;
+}
+
+// Named-family lookup for the custom watermark's FONT option. Only faces the
+// bundle ships can be matched on web; anything else falls back to the caller's
+// default typeface — the honest behaviour when there is no OS font service.
+let fontMgr: any = null;
+
+function makeFontMgr(canvasKit: any) {
+ const provider = canvasKit.TypefaceFontProvider.Make();
+ return {
+ registerFace(typeface: any, familyName: string) {
+ provider.registerFont(typeface, familyName);
+ },
+ matchFamilyStyle(family: string) {
+ if (!family) return null;
+ try {
+ return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null;
+ } catch {
+ return null;
+ }
+ },
+ };
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..c49a2e4
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "allowImportingTsExtensions": true,
+ "types": ["node"],
+ "baseUrl": ".",
+ "paths": {
+ "@shopify/react-native-skia": ["./src/engine/skiaShim.ts"]
+ }
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "spike/**/*.ts",
+ "../src/types/index.ts",
+ "../src/utils/colorUtils.ts",
+ "../src/utils/toneShader.ts",
+ "../src/utils/cinemaShader.ts",
+ "../src/utils/frameUtils.ts",
+ "../src/utils/jpegDpi.ts",
+ "../src/utils/skiaImage.ts",
+ "../src/utils/paramDefs.ts",
+ "../src/utils/defaultRecipes.ts"
+ ]
+}