Add CanvasKit web engine spike + W0 findings (recipes-web)
This commit is contained in:
+176
-81
@@ -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 `<html>`; mọi màu trong UI đi qua token, không hardcode. Đổi theme = set 2 attribute, không re-render React.
|
||||
- **Theme nền**: `light`, `dark` (mặc định theo `prefers-color-scheme`, người dùng chọn được thì ghi đè). Dark dùng nền đen thuần theo `web-design`.
|
||||
- **Nhóm màu accent**: `amber` (mặc định — đúng chất RecipesCam), `blue`, `emerald`, `rose`, `violet`. Accent chỉ đổi token `--accent*`, không đổi layout.
|
||||
- **Lưu**: `localStorage` (`theme`, `accent`); đồng bộ sang `<meta name="theme-color">` cho mobile browser.
|
||||
- **UI chọn**: popover trong header (icon bảng màu) trên cả landing lẫn workspace; landing cũng đổi theme được.
|
||||
- `ponytail:` 2 theme nền + 5 accent là cấu hình dữ liệu trong `web/src/theme/themes.ts`; thêm theme mới = thêm 1 object, không sửa component.
|
||||
|
||||
## Đa ngôn ngữ (i18n)
|
||||
- **Ngôn ngữ v1**: `vi` (mặc định theo `navigator.language` nếu là tiếng Việt) + `en`. Thêm ngôn ngữ = thêm 1 file từ điển.
|
||||
- **Cách làm**: tự viết, **không thêm thư viện** — từ điển `web/src/i18n/vi.ts` / `en.ts` (object lồng nhau, có kiểu từ `vi` để `en` thiếu key là lỗi typecheck), `t(key, params)` + `I18nProvider` giữ `lang` trong `localStorage`. Đổi ngôn ngữ = đổi state, không reload.
|
||||
- **Phạm vi dịch**: toàn bộ nhãn UI (rail tab, chip, slider, tooltip, nút, thông báo lỗi, landing/review/auth form), tiêu đề trang + SEO (`<html lang>`, OG locale).
|
||||
- **Không dịch**: tên film sim (`PROVIPES`, `LC STREETLIFE CLASSIC`…), tên recipe người dùng đặt, nội dung watermark người dùng nhập — là danh từ riêng/thương hiệu, giữ nguyên ở mọi ngôn ngữ.
|
||||
- `ponytail:` từ điển là file TS được bundle; chỉ tách lazy-load theo ngôn ngữ khi bundle vượt ~50KB text.
|
||||
|
||||
## Kiến trúc
|
||||
```
|
||||
docker-compose.yml
|
||||
├── web nginx:alpine 8090:80 static SPA (Vite dist) + proxy /api → api
|
||||
└── api node:20-alpine 3000 Fastify + better-sqlite3, volume ./data
|
||||
```
|
||||
- **Frontend**: Vite + React + TS + react-router (`/`, `/app`, `/login`, `/signup`).
|
||||
- **Engine ảnh**: **CanvasKit (canvaskit-wasm)** trong Web Worker — nhưng **bắt buộc trên surface WebGL** (`MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))`), không phải raster CPU: xem W0. Preview bản thu nhỏ (~2048px), render full-res khi export.
|
||||
- **API**: Fastify + better-sqlite3 (1 file DB, không cần container DB riêng). Session cookie `httpOnly` + `SameSite=Lax`, mật khẩu hash `argon2`. Bảng: `users`, `sessions`, `recipes`, `favorites`.
|
||||
- **Recipe bundle** lấy thẳng từ `src/utils/defaultRecipes.ts` (client-side) — server chỉ lưu recipe người dùng tạo.
|
||||
- Nginx Proxy Manager bên ngoài lo TLS/domain; container chỉ nghe HTTP.
|
||||
|
||||
### Vì sao CanvasKit
|
||||
Tái dùng gần như nguyên vẹn `src/utils/exportEngine.ts`: CanvasKit có `RuntimeEffect` (SkSL y nguyên `TONE_SKSL`, `CINEMA_SKSL`), `ColorFilter.MakeMatrix`, `ImageFilter.MakeBlur`/`MakeCompose`, `BlendMode.Overlay`, `drawImageRect`, `MakeFreeTypeFaceFromData`, `encodeToBytes(JPEG, 95)` → parity cao nhất với app, code ít nhất.
|
||||
Hai chỗ CanvasKit khác Skia JSI (đã xử lý ở W0): encode JPEG cần build `canvaskit-wasm/full`; không có `ImageFilter.MakeMatrixConvolution` → CLARITY/sharpening đi qua `CLARITY_SKSL`.
|
||||
**Không** dùng `react-native-web`/Expo web: app phụ thuộc `react-native-vision-camera` + Skia JSI, web sẽ vỡ.
|
||||
|
||||
## Tái sử dụng (không viết lại)
|
||||
| Nguồn trong repo | Dùng cho web |
|
||||
|---|---|
|
||||
| `src/utils/colorUtils.ts` | `getSkiaColorMatrix`, `applyExposureGain`, `kelvinToRGB` — nguyên văn |
|
||||
| `src/utils/toneShader.ts` | `TONE_SKSL` + `getToneUniforms` — 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_<tên>_<timestamp>.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_<tên>_<timestamp>.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 (`<title>`, OG tags, `<html lang>` theo ngôn ngữ đang chọn); landing cũng đổi được theme + ngôn ngữ.
|
||||
Exit: trang mở đẹp ở cả 360px và 1440px ở cả 2 theme; link store + CTA đúng đích; nội dung landing có đủ vi/en.
|
||||
|
||||
### W7 — BATCH EXPORT (add-on)
|
||||
Panel riêng: nạp nhiều ảnh, chọn recipe (bundle hoặc của người dùng), hàng đợi render tuần tự có tiến trình, tải từng file hoặc ZIP. Yêu cầu đăng nhập.
|
||||
Exit: 10 ảnh + 1 recipe → 10 file cùng tông màu, không tràn RAM.
|
||||
|
||||
### W8 — Parity & perf
|
||||
Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối ưu (worker pool, preview cache, dùng lại bitmap khi export).
|
||||
Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s.
|
||||
|
||||
### W9 — Docker + deploy + docs
|
||||
`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+556
@@ -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: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>W0 GPU bench</title></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<script src="/bin/full/canvaskit.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const CanvasKitInit = window.CanvasKitInit;
|
||||
|
||||
const log = (s) => { document.getElementById('out').textContent += '\n' + s; };
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
const CK = await CanvasKitInit({ locateFile: (f) => '/bin/full/' + f });
|
||||
|
||||
const img = await fetch('/samples/p2.jpg').then((r) => r.arrayBuffer()).then((b) => new Uint8Array(b));
|
||||
let t0 = performance.now();
|
||||
const src = CK.MakeImageFromEncoded(img);
|
||||
log(`decode 12MP: ${(performance.now() - t0).toFixed(0)}ms ${src.width()}x${src.height()}`);
|
||||
|
||||
const W = src.width();
|
||||
const H = src.height();
|
||||
const sizes = [
|
||||
{ name: '1024', w: 1024, h: Math.round((1024 * H) / W) },
|
||||
{ name: '2048', w: 2048, h: Math.round((2048 * H) / W) },
|
||||
{ name: 'full', w: W, h: H },
|
||||
];
|
||||
|
||||
const tone = CK.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);}'
|
||||
);
|
||||
const sharpen = CK.RuntimeEffect.Make(
|
||||
'uniform shader img; uniform float2 texel; uniform float amount; vec4 main(vec2 p){ vec4 c=img.eval(p); vec4 n=img.eval(p+float2(0.0,-texel.y)); vec4 s=img.eval(p+float2(0.0,texel.y)); vec4 w=img.eval(p+float2(-texel.x,0.0)); vec4 e=img.eval(p+float2(texel.x,0.0)); return vec4(clamp(c.rgb*(1.0+4.0*amount)-amount*(n.rgb+s.rgb+w.rgb+e.rgb),0.0,1.0), c.a);}'
|
||||
);
|
||||
log('runtime effects: tone=' + !!tone + ' sharpen=' + !!sharpen);
|
||||
|
||||
for (const s of sizes) {
|
||||
log(`\n[webgl canvas ${s.w}x${s.h}]`);
|
||||
const canvas = new OffscreenCanvas(s.w, s.h);
|
||||
const gl0 = canvas.getContext('webgl2');
|
||||
const dbg = gl0 && gl0.getExtension('WEBGL_debug_renderer_info');
|
||||
if (dbg) log(' GL: ' + gl0.getParameter(dbg.UNMASKED_RENDERER_WEBGL));
|
||||
t0 = performance.now();
|
||||
const surface = CK.MakeWebGLCanvasSurface(canvas);
|
||||
log(` MakeWebGLCanvasSurface: ${(performance.now() - t0).toFixed(0)}ms ok=${!!surface}`);
|
||||
if (!surface) continue;
|
||||
const cv = surface.getCanvas();
|
||||
results[s.name] = {};
|
||||
|
||||
const shader = src.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
|
||||
const toneShader = tone.makeShaderWithChildren([0.2], [shader]);
|
||||
|
||||
const draw = () => {
|
||||
const p = new CK.Paint();
|
||||
p.setShader(toneShader);
|
||||
cv.drawRect(CK.XYWHRect(0, 0, s.w, s.h), p);
|
||||
surface.flush();
|
||||
p.delete();
|
||||
};
|
||||
draw(); // warm-up: shader compile + texture upload
|
||||
const measure = (label, fn) => {
|
||||
fn();
|
||||
const t = performance.now();
|
||||
fn();
|
||||
const ms = performance.now() - t;
|
||||
log(` ${label.padEnd(26)} ${ms.toFixed(0)}ms`);
|
||||
results[s.name][label] = ms;
|
||||
};
|
||||
|
||||
measure('tone skSL pass', draw);
|
||||
|
||||
measure('clarity skSL 5-tap', () => {
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const child = snap.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
|
||||
const sh = sharpen.makeShaderWithChildren([0.3, 1, 1], [child]);
|
||||
const p = new CK.Paint();
|
||||
p.setShader(sh);
|
||||
cv.drawRect(CK.XYWHRect(0, 0, s.w, s.h), p);
|
||||
surface.flush();
|
||||
p.delete(); sh.delete(); child.delete(); snap.delete();
|
||||
});
|
||||
|
||||
measure('blur sigma=40', () => {
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const p = new CK.Paint();
|
||||
p.setImageFilter(CK.ImageFilter.MakeBlur(40, 40, CK.TileMode.Clamp, null));
|
||||
cv.clear(CK.Color(0, 0, 0, 0));
|
||||
cv.drawImage(snap, 0, 0, p);
|
||||
surface.flush();
|
||||
p.delete(); snap.delete();
|
||||
});
|
||||
|
||||
let enc = -1;
|
||||
measure('jpeg q95 encode', () => {
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const b = snap.encodeToBytes(CK.ImageFormat.JPEG, 95);
|
||||
enc = b ? b.length : -1;
|
||||
snap.delete();
|
||||
});
|
||||
log(' encoded bytes: ' + enc);
|
||||
surface.delete();
|
||||
}
|
||||
window.__results = results;
|
||||
log('\nDONE');
|
||||
document.title = 'done';
|
||||
} catch (e) {
|
||||
log('ERROR ' + (e && e.stack ? e.stack : e));
|
||||
document.title = 'error';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;i<px.length;i+=4) if (px[i]>128) lit++;
|
||||
console.log('lit pixels in emoji draw:', lit);
|
||||
+175
@@ -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=<dir with .jpg> W0_OUT=<dir> 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<string, unknown> }[] = [
|
||||
...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);
|
||||
});
|
||||
@@ -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<RenderResult | null> {
|
||||
const { recipe, frameId, options } = input;
|
||||
const owned: SkDisposable[] = [];
|
||||
const own = <T,>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user