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, `<html lang>` theo ngôn ngữ đang chọn); landing cũng đổi được theme + ngôn ngữ. +Exit: trang mở đẹp ở cả 360px và 1440px ở cả 2 theme; link store + CTA đúng đích; nội dung landing có đủ vi/en. + +### W7 — BATCH EXPORT (add-on) +Panel riêng: nạp nhiều ảnh, chọn recipe (bundle hoặc của người dùng), hàng đợi render tuần tự có tiến trình, tải từng file hoặc ZIP. Yêu cầu đăng nhập. +Exit: 10 ảnh + 1 recipe → 10 file cùng tông màu, không tràn RAM. + +### W8 — Parity & perf +Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối ưu (worker pool, preview cache, dùng lại bitmap khi export). +Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s. + +### W9 — Docker + deploy + docs +`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `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 @@ +<!doctype html> +<html> +<head><meta charset="utf-8"><title>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" + ] +}