diff --git a/WEB_PLAN.md b/WEB_PLAN.md index 11bca15..064bc1e 100644 --- a/WEB_PLAN.md +++ b/WEB_PLAN.md @@ -212,13 +212,15 @@ Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối 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. +`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `docker/README.md` (RUNBOOK: port, domain, backup `./data`, đổi `TIER` config); commit + push. Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export chạy; container tự lên lại sau reboot. +**Trạng thái**: `docker/` đã tự chứa và chạy được — `docker/docker-compose.yml` (service `frontend` + `api`), `docker/backend/` (Fastify + SQLite), `docker/frontend/` (Vite + CanvasKit + nginx). Chạy `docker compose up -d --build` trong `docker/`, UI ở `:8090`; đã kiểm chứng health/signup/login/recipes và export ảnh thật end-to-end trong Chromium headless. Còn lại: gắn domain/TLS qua Nginx Proxy Manager, cập nhật `README.md` gốc. + ## Rủi ro - ~~**CanvasKit không có emoji màu**~~ → **đã loại ở W0**: `NotoEmoji-GPS.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. +- ~~**Hiệu năng**: raster CPU chậm gấp ~1000 lần WebGL~~ → **đã chốt**: engine dùng GPU surface, nhưng **một `GrDirectContext` dùng chung** + `MakeRenderTarget` cho mọi surface. `MakeWebGLCanvasSurface` gọi nhiều lần tạo nhiều WebGL context riêng → snapshot của surface này vẽ sang surface kia ra **đen thui** (đo được: 12MP JPEG + output sharpen, GL mean 0.0 vs raster 107.0). Không có WebGL thì fallback raster `MakeSurface` — đúng pipeline, chỉ chậm hơn. - **wasm ~8MB** (build `full`, gzip ~3MB): lazy-load, nginx cache dài hạn. - **RAM 12MP** trong browser (bitmap ~48MB + surface) — giới hạn worker pool 1–2, giải phóng bitmap sau export; batch render tuần tự, không song song. - **HDF/vignette** dùng gradient + blend — kiểm tra tương đương CanvasKit. @@ -243,4 +245,5 @@ Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export - [ ] W6 landing page - [ ] W7 batch export - [ ] W8 parity & perf +- [x] `docker/` tự chứa + chạy được (code FE/BE nằm trong `docker/`, compose + Dockerfile + `.env.example` + README) — đã verify end-to-end - [ ] W9 Docker + deploy + docs diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..ae97f74 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,3 @@ +# Host port the web UI is published on. Everything else is internal: the API is +# only reachable through nginx at /api/. +WEB_PORT=8090 diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..24f9f8d --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,2 @@ +.env +data/ diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..bf98003 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,80 @@ +# RecipesCam web — self-contained stack + +A Docker-hosted web build of RecipesCam. Everything it needs is in this folder: +move it to another machine, run two commands, and the app is up. It does **not** +need the React Native project around it. + +```bash +cp .env.example .env +docker compose up -d --build +# → http://localhost:8090 +``` + +## What runs where + +| Service | Image | Role | +| --- | --- | --- | +| `frontend` | `nginx:1.27-alpine` (built by `frontend/Dockerfile`) | Static SPA + `/api/` reverse proxy | +| `api` | `node:22-slim` (built by `backend/Dockerfile`) | Accounts + saved recipes, SQLite on `./data` | + +`frontend` resolves `api` through Docker's embedded DNS and proxies `/api/*` to +it — that is why the API container is named `api` and why it is not published on +the host. Only `${WEB_PORT:-8090}` is exposed. + +Photos never leave the browser. The CanvasKit render pipeline (grade, frame, +watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes +as JSON. + +## Layout + +``` +docker-compose.yml the stack +.env.example WEB_PORT +data/ SQLite (created on first run, gitignored) +backend/ Fastify + better-sqlite3 API, own Dockerfile +frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf + shared/ vendored copies of the app's types + utils (see below) + src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline) +``` + +### Vendored files + +`frontend/shared/{types/index.ts,utils/*.ts}` are byte-identical copies of +`src/types/index.ts` and ten `src/utils/*.ts` files from the React Native +project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in +`vite.config.ts` + `tsconfig.json`, so those files compile unchanged): + +`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi` +`paramDefs` `skiaImage` `toneShader`. + +When the app changes one of them, copy it back in — the renderer is only +"parity" for as long as these stay in sync: + +```bash +cd /docker/frontend/shared/utils +cp /src/utils/.ts . +``` + +## Operations + +```bash +docker compose logs -f api # API log +docker compose restart api # after backend/src changes (rebuild: --build) +docker compose down # stop; ./data survives +``` + +Backup is the `./data` folder — that is the whole database. + +## Checks + +```bash +curl -s http://localhost:8090/api/health # {"ok":true} +curl -sI http://localhost:8090/ # 200, index.html +``` + +Then open the UI, drop a photo in, and confirm the preview shows the picture and +EXPORT downloads a JPEG that opens. The preview going solid black while the +export still reports a plausible size is the one failure mode worth knowing: it +means the CanvasKit GPU surfaces lost their shared `GrDirectContext` (see +`frontend/src/engine/skiaShim.ts`), and with no GPU the raster fallback renders +the same pipeline correctly, just slower. diff --git a/docker/backend/.dockerignore b/docker/backend/.dockerignore new file mode 100644 index 0000000..673ce8b --- /dev/null +++ b/docker/backend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +data +*.log +Dockerfile +.dockerignore diff --git a/docker/backend/.gitignore b/docker/backend/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/docker/backend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile new file mode 100644 index 0000000..5d46c6e --- /dev/null +++ b/docker/backend/Dockerfile @@ -0,0 +1,20 @@ +# RecipesCam API — self-contained build (context = this folder). +# Builder compiles TypeScript; runtime carries production deps only. +FROM node:22-slim AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-slim +ENV NODE_ENV=production \ + PORT=3000 \ + DATA_DIR=/data +WORKDIR /app +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY package.json ./ +EXPOSE 3000 +CMD ["node", "dist/server.js"] diff --git a/docker/backend/package-lock.json b/docker/backend/package-lock.json new file mode 100644 index 0000000..21fdcfe --- /dev/null +++ b/docker/backend/package-lock.json @@ -0,0 +1,1653 @@ +{ + "name": "recipescam-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recipescam-api", + "version": "1.0.0", + "dependencies": { + "better-sqlite3": "^12.11.1", + "fastify": "^5.12.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^22.20.3", + "tsx": "^4.23.13", + "typescript": "^5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.5.tgz", + "integrity": "sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/docker/backend/package.json b/docker/backend/package.json new file mode 100644 index 0000000..0b18882 --- /dev/null +++ b/docker/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "recipescam-api", + "version": "1.0.0", + "private": true, + "description": "RecipesCam web API - accounts + user recipes (server never receives photos)", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts" + }, + "dependencies": { + "better-sqlite3": "^12.11.1", + "fastify": "^5.12.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^22.20.3", + "tsx": "^4.23.13", + "typescript": "^5.9.3" + } +} diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts new file mode 100644 index 0000000..7da132d --- /dev/null +++ b/docker/backend/src/db.ts @@ -0,0 +1,158 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +export const SESSION_COOKIE = 'rc_session'; +export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days +export const MAX_RECIPE_BYTES = 256 * 1024; + +const DATA_DIR = process.env.DATA_DIR || './data'; +mkdirSync(DATA_DIR, { recursive: true }); + +export const db = new Database(join(DATA_DIR, 'recipescam.db')); +db.pragma('journal_mode = WAL'); + +db.exec(` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id); +`); + +export type User = { id: number; email: string }; +export type Recipe = { + id: number; + name: string; + recipe: unknown; + createdAt: string; + updatedAt: string; +}; + +// scrypt: per-user random salt, stored as "salt:hash" (hex). +const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 } as const; + +export function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, SCRYPT.keylen, SCRYPT).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const expected = Buffer.from(hash, 'hex'); + const actual = scryptSync(password, salt, SCRYPT.keylen, SCRYPT); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +// Burned on unknown-email logins so response time does not leak account existence. +export const DUMMY_HASH = hashPassword('invalid-password-placeholder'); + +const now = () => new Date().toISOString(); + +export function createUser(email: string, password: string): User | null { + try { + const info = db + .prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)') + .run(email, hashPassword(password), now()); + return { id: Number(info.lastInsertRowid), email }; + } catch (err) { + if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null; + throw err; + } +} + +export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined { + return db + .prepare('SELECT id, email, password_hash FROM users WHERE email = ?') + .get(email) as (User & { password_hash: string }) | undefined; +} + +export function findUserById(id: number): User | undefined { + return db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as User | undefined; +} + +export function createSession(userId: number): string { + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_S * 1000).toISOString(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + userId, + expiresAt, + ); + return token; +} + +export function sessionUser(token: string): User | undefined { + const row = db + .prepare('SELECT token, user_id AS userId, expires_at AS expiresAt FROM sessions WHERE token = ?') + .get(token) as { token: string; userId: number; expiresAt: string } | undefined; + if (!row) return undefined; + if (row.expiresAt <= now()) { + db.prepare('DELETE FROM sessions WHERE token = ?').run(row.token); // lazy cleanup + return undefined; + } + return findUserById(row.userId); +} + +export function deleteSession(token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); +} + +export function listRecipes(userId: number): Recipe[] { + const rows = db + .prepare( + 'SELECT id, name, json, created_at AS createdAt, updated_at AS updatedAt FROM recipes WHERE user_id = ? ORDER BY updated_at DESC, id DESC', + ) + .all(userId) as { id: number; name: string; json: string; createdAt: string; updatedAt: string }[]; + return rows.map((r) => ({ id: r.id, name: r.name, recipe: JSON.parse(r.json), createdAt: r.createdAt, updatedAt: r.updatedAt })); +} + +export function getRecipe(userId: number, id: number): Recipe | undefined { + const row = db + .prepare( + 'SELECT id, name, json, created_at AS createdAt, updated_at AS updatedAt FROM recipes WHERE id = ? AND user_id = ?', + ) + .get(id, userId) as { id: number; name: string; json: string; createdAt: string; updatedAt: string } | undefined; + return row && { id: row.id, name: row.name, recipe: JSON.parse(row.json), createdAt: row.createdAt, updatedAt: row.updatedAt }; +} + +export function createRecipe(userId: number, name: string, recipe: unknown): Recipe { + const ts = now(); + const info = db + .prepare('INSERT INTO recipes (user_id, name, json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)') + .run(userId, name, JSON.stringify(recipe), ts, ts); + const id = Number(info.lastInsertRowid); + return { id, name, recipe, createdAt: ts, updatedAt: ts }; +} + +export function updateRecipe(userId: number, id: number, name: string, recipe: unknown): Recipe | undefined { + const ts = now(); + const info = db + .prepare('UPDATE recipes SET name = ?, json = ?, updated_at = ? WHERE id = ? AND user_id = ?') + .run(name, JSON.stringify(recipe), ts, id, userId); + if (info.changes === 0) return undefined; + return getRecipe(userId, id); +} + +export function deleteRecipe(userId: number, id: number): boolean { + return db.prepare('DELETE FROM recipes WHERE id = ? AND user_id = ?').run(id, userId).changes > 0; +} diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts new file mode 100644 index 0000000..9e973a9 --- /dev/null +++ b/docker/backend/src/server.ts @@ -0,0 +1,191 @@ +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import { + MAX_RECIPE_BYTES, + SESSION_COOKIE, + SESSION_MAX_AGE_S, + DUMMY_HASH, + createRecipe, + createSession, + createUser, + deleteRecipe, + deleteSession, + findUserByEmail, + listRecipes, + sessionUser, + updateRecipe, + verifyPassword, + type Recipe, + type User, +} from './db'; + +const PORT = Number(process.env.PORT || 3000); +const HOST = '0.0.0.0'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_EMAIL = 254; +const MIN_PASSWORD = 8; +const MAX_PASSWORD = 200; +const MAX_NAME = 120; + +const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 }); + +// Bodyless DELETE/logout requests still often carry Content-Type: application/json. +app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + const raw = (body as string).trim(); + if (raw === '') return done(null, undefined); + try { + done(null, JSON.parse(raw)); + } catch { + done(Object.assign(new Error('invalid JSON body'), { statusCode: 400 })); + } +}); + +// Single error shape for the whole API: { error: "..." } +app.setErrorHandler((err, req, reply) => { + const e = err as { statusCode?: number; message?: string }; + const status = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500; + if (status >= 500) req.log.error(err); + reply.status(status).send({ error: status >= 500 ? 'internal_error' : (e.message ?? 'error') }); +}); +app.setNotFoundHandler((_req, reply) => reply.status(404).send({ error: 'not_found' })); + +// ---- cookie helpers (hand-rolled: only one cookie, no plugin needed) ---- +function cookieOf(req: FastifyRequest, name: string): string | undefined { + const raw = req.headers.cookie; + if (!raw) return undefined; + for (const part of raw.split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return undefined; +} + +function setSession(reply: FastifyReply, token: string): void { + reply.header( + 'set-cookie', + `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}`, + ); +} + +function clearSession(reply: FastifyReply): void { + reply.header('set-cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); +} + +// ---- validation at the trust boundary ---- +type Json = Record; + +function bodyOf(req: FastifyRequest): Json | undefined { + const b = req.body as unknown; + return b !== null && typeof b === 'object' && !Array.isArray(b) ? (b as Json) : undefined; +} + +function credentials(b: Json): { email: string; password: string } | string { + const email = typeof b.email === 'string' ? b.email.trim().toLowerCase() : ''; + const password = typeof b.password === 'string' ? b.password : ''; + if (!email || email.length > MAX_EMAIL || !EMAIL_RE.test(email)) return 'invalid email'; + if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) + return `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters`; + return { email, password }; +} + +function recipePayload(b: Json): { name: string; recipe: Json } | string { + const name = typeof b.name === 'string' ? b.name.trim() : ''; + const recipe = b.recipe; + if (!name || name.length > MAX_NAME) return `name must be 1-${MAX_NAME} characters`; + if (recipe === null || typeof recipe !== 'object' || Array.isArray(recipe)) return 'recipe must be an object'; + if (Buffer.byteLength(JSON.stringify(recipe)) > MAX_RECIPE_BYTES) return 'recipe too large'; + return { name, recipe: recipe as Json }; +} + +function auth(req: FastifyRequest): User | undefined { + const token = cookieOf(req, SESSION_COOKIE); + return token ? sessionUser(token) : undefined; +} + +// ---- routes ---- +app.get('/api/health', async () => ({ ok: true })); + +app.post('/api/auth/signup', async (req, reply) => { + const b = bodyOf(req); + if (!b) return reply.status(400).send({ error: 'invalid body' }); + const creds = credentials(b); + if (typeof creds === 'string') return reply.status(400).send({ error: creds }); + if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' }); + const user = createUser(creds.email, creds.password); + if (!user) return reply.status(409).send({ error: 'email already registered' }); + setSession(reply, createSession(user.id)); + return reply.status(201).send({ user }); +}); + +app.post('/api/auth/login', async (req, reply) => { + const b = bodyOf(req); + if (!b || typeof b.email !== 'string' || typeof b.password !== 'string') + return reply.status(400).send({ error: 'invalid body' }); + const email = b.email.trim().toLowerCase(); + const row = findUserByEmail(email); + const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH); + if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' }); + setSession(reply, createSession(row.id)); + return reply.status(200).send({ user: { id: row.id, email: row.email } }); +}); + +app.post('/api/auth/logout', async (req, reply) => { + const token = cookieOf(req, SESSION_COOKIE); + if (token) deleteSession(token); + clearSession(reply); + return reply.status(204).send(); +}); + +app.get('/api/auth/me', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + return reply.status(200).send({ user }); +}); + +app.get('/api/recipes', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + return reply.status(200).send({ recipes: listRecipes(user.id) }); +}); + +app.post('/api/recipes', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + const b = bodyOf(req); + const payload = b && recipePayload(b); + if (typeof payload === 'string' || !payload) + return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' }); + const recipe: Recipe = createRecipe(user.id, payload.name, payload.recipe); + return reply.status(201).send({ recipe }); +}); + +app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' }); + const b = bodyOf(req); + const payload = b && recipePayload(b); + if (typeof payload === 'string' || !payload) + return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' }); + const recipe = updateRecipe(user.id, id, payload.name, payload.recipe); + if (!recipe) return reply.status(404).send({ error: 'recipe not found' }); + return reply.status(200).send({ recipe }); +}); + +app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' }); + if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' }); + return reply.status(204).send(); +}); + +app + .listen({ port: PORT, host: HOST }) + .catch((err) => { + app.log.error(err); + process.exit(1); + }); diff --git a/docker/backend/tsconfig.json b/docker/backend/tsconfig.json new file mode 100644 index 0000000..3ec504e --- /dev/null +++ b/docker/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts"] +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..1df2a7c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,26 @@ +# RecipesCam web — the whole stack. Copy this folder anywhere, then: +# cp .env.example .env +# docker compose up -d --build +name: recipescam-web + +services: + # Accounts + saved recipes. Never sees a photo: every pixel of the render + # pipeline runs in the visitor's browser. + api: + build: ./backend + restart: unless-stopped + environment: + DATA_DIR: /data + volumes: + # SQLite (WAL) lives on the host so a rebuild never loses accounts. + - ./data:/data + + # Static SPA + /api reverse proxy. "api" is the DNS name nginx.conf proxies to, + # so this service name is load-bearing. + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + - api + ports: + - "${WEB_PORT:-8090}:80" diff --git a/docker/frontend/.dockerignore b/docker/frontend/.dockerignore new file mode 100644 index 0000000..7125fe8 --- /dev/null +++ b/docker/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +public/wasm +*.log +.env diff --git a/docker/frontend/.gitignore b/docker/frontend/.gitignore new file mode 100644 index 0000000..de5b0aa --- /dev/null +++ b/docker/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +public/wasm/ diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile new file mode 100644 index 0000000..ba19e00 --- /dev/null +++ b/docker/frontend/Dockerfile @@ -0,0 +1,14 @@ +# Build stage. Node 22 (glibc) so nothing here depends on the host. +FROM node:22-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +# tsc --noEmit && vite build; the prebuild hook copies canvaskit.wasm into public/ +RUN npm run build + +# Runtime: static files only. The photo pipeline runs in the visitor's browser. +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/docker/frontend/index.html b/docker/frontend/index.html new file mode 100644 index 0000000..360074d --- /dev/null +++ b/docker/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + RecipesCam — web + + + +
+ + + diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf new file mode 100644 index 0000000..bee49e9 --- /dev/null +++ b/docker/frontend/nginx.conf @@ -0,0 +1,40 @@ +# Static SPA + API proxy. TLS/domain are terminated outside (Nginx Proxy +# Manager), so this container only ever speaks plain HTTP on port 80. +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json application/wasm image/svg+xml; + + # canvas.wasm is ~8MB: immutable, hashed or not, it never changes under a + # given build. Fonts and the wall-frame artwork are likewise static. + location ~* ^/(wasm|assets)/ { + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /api/ { + # Resolved per request through Docker's embedded DNS, so the frontend can + # start before the API container without nginx refusing to boot. + resolver 127.0.0.11 valid=10s ipv6=off; + set $api_upstream http://api:3000; + proxy_pass $api_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 4m; + } + + # SPA fallback: /app, /login and /signup are all index.html. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker/frontend/package-lock.json b/docker/frontend/package-lock.json new file mode 100644 index 0000000..2201e05 --- /dev/null +++ b/docker/frontend/package-lock.json @@ -0,0 +1,1908 @@ +{ + "name": "recipescam-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recipescam-web", + "version": "0.1.0", + "dependencies": { + "canvaskit-wasm": "^0.42.0", + "exifr": "^7.1.3", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.20.3", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvaskit-wasm": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.42.0.tgz", + "integrity": "sha512-N3ikotmzYwDVAcpQKDMptBT2P5pIEWS9ywdmrQA2awwaMF9cxKEFuxZZHv9ay1Em9kDjxDsPtwUzDm1uNj136g==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/docker/frontend/package.json b/docker/frontend/package.json new file mode 100644 index 0000000..6d0857d --- /dev/null +++ b/docker/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "recipescam-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "prepare-wasm": "node scripts/copy-wasm.mjs", + "predev": "npm run prepare-wasm", + "dev": "vite", + "prebuild": "npm run prepare-wasm", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview --port 4173" + }, + "dependencies": { + "canvaskit-wasm": "^0.42.0", + "exifr": "^7.1.3", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.20.3", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/docker/frontend/public/assets/Cousine-Regular.ttf b/docker/frontend/public/assets/Cousine-Regular.ttf new file mode 100644 index 0000000..e01ff6f Binary files /dev/null and b/docker/frontend/public/assets/Cousine-Regular.ttf differ diff --git a/docker/frontend/public/assets/NotoEmoji-GPS.ttf b/docker/frontend/public/assets/NotoEmoji-GPS.ttf new file mode 100644 index 0000000..207709b Binary files /dev/null and b/docker/frontend/public/assets/NotoEmoji-GPS.ttf differ diff --git a/docker/frontend/public/assets/wallframe.png b/docker/frontend/public/assets/wallframe.png new file mode 100644 index 0000000..c3cfd0a Binary files /dev/null and b/docker/frontend/public/assets/wallframe.png differ diff --git a/docker/frontend/scripts/copy-wasm.mjs b/docker/frontend/scripts/copy-wasm.mjs new file mode 100644 index 0000000..cfef15c --- /dev/null +++ b/docker/frontend/scripts/copy-wasm.mjs @@ -0,0 +1,16 @@ +// canvaskit-wasm resolves its wasm at runtime, not through the bundler, so the +// binary has to be a served asset. 8MB in the repo is worse than 8MB copied out +// of node_modules at build time (the Docker build stage does the same). +import { copyFileSync, mkdirSync, existsSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const from = 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm'; +const to = 'public/wasm/canvaskit.wasm'; + +if (!existsSync(from)) { + console.error(`missing ${from} — run npm install first`); + process.exit(1); +} +mkdirSync(dirname(to), { recursive: true }); +copyFileSync(from, to); +console.log(`copied ${from} -> ${to}`); diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts new file mode 100644 index 0000000..0a0fff6 --- /dev/null +++ b/docker/frontend/shared/types/index.ts @@ -0,0 +1,142 @@ +export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic' | 'wallframe'; + +// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT +// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing +// 2:3 photo (w/h = 2/3). "full" = current fullscreen behaviour (no ratio crop). +// Wall Frame ignores the ratio (frame + export keep fullscreen as before). +export type AspectRatio = 'full' | '4:3' | '3:2'; +// Full-frame metering behaviour. Evaluative = the camera's default whole-scene +// metering (offset 0). Highlight-weighted protects bright areas by underexposing +// (~ -0.7EV) — the camera lib exposes no real metering-mode API, so this is the +// closest honest approximation via the existing EV path. +export type MeterMode = 'evaluative' | 'highlight'; +// Camera shutter sound. OS policy may force the click on in some regions; +// there is no native volume control (OFF/ON only). +export type ShutterSound = 'on' | 'off'; +// LED flash for photo capture (never a live torch): 'auto' lets the capture +// pipeline decide from the metered scene, 'on' always fires, 'off' never does. +export type FlashMode = 'off' | 'auto' | 'on'; +export type StartupMode = 'default' | 'last'; +// One photo in the in-app session history (recent captures + library exports). +// uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset +// so the trash button can remove the gallery copy too. +export interface PhotoEntry { + id: string; + uri: string; + recipeName: string; + savedToLibrary: boolean; + assetId?: string | null; +} +// w/h of the final upright photo for each non-full choice. +export const ASPECT_RATIO_W_H: Record, number> = { + '4:3': 3 / 4, + '3:2': 2 / 3, +}; + +// FRAME-tab CROP. Ratio convention is w:h (so 2:3 is an upright crop, 16:9 a +// wide one); 'free' is a user-dragged rectangle instead of a fixed shape. +export type CropRatio = 'none' | 'free' | '1:1' | '2:3' | '3:2' | '3:4' | '4:3' | '16:9'; +// Keep-rectangle as 0..1 fractions of the (post-rotation) source photo. +export interface CropRect { + x: number; + y: number; + w: number; + h: number; +} +// w/h of the kept area for each fixed crop choice. +export const CROP_W_H: Record, number> = { + '1:1': 1, + '2:3': 2 / 3, + '3:2': 3 / 2, + '3:4': 3 / 4, + '4:3': 4 / 3, + '16:9': 16 / 9, +}; +// FREE crop starts as a visible inset rectangle the user can immediately grab. +export const DEFAULT_CROP_RECT: CropRect = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }; +// Smallest FREE-crop side, in source fractions. +export const MIN_CROP_FRAC = 0.08; + +// Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the +// photo's EXISTING exposure — warm tint + soft glow on areas already lit, +// cool tint in shade, plus a seasonal atmospheric haze/fog veil. +export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter'; + +export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none'; + +export interface ColorAdjustments { + exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) + contrast: number; // -10 to +10 + saturation: number; // -10 to +10 + temperature: number; // 2500 to 10000 (Kelvin) + tint: number; // -10 to +10 + wbRed?: number; // Fuji WB fine-tune, Red axis (-9..9) — extra red-channel gain + wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain + highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens) + shadow: number; // -10 to +10 (+ lifts shadows, - deepens) + denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back) + clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) + grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) + colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect + colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue) + dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve) + sharpening: number; // -10 to +10 (+ = screen sharpen; - = soften the frame) + hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only) + vignette?: number; // 0 to 10 (darken the four corners; 0 = off) + vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least) + exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. +} + +export interface Recipe { + id: string; + name: string; + isCustom?: boolean; + baseFilter: BaseFilter; + adjustments: ColorAdjustments; + frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe' + cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional) + useGeotag: boolean; +} + +export interface GPSInfo { + latitude: number; + longitude: number; + locality?: string; + country?: string; + timestamp: number; + source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from +} + +// Custom text watermark (WATERMARK tab -> CUSTOM WATERMARK chip): enabled flag, +// the typed text, and its anchor position as 0..1 fractions of the photo area +// (0.5/0.5 = centered). The anchor is the text's BASELINE plus a small 0.35em +// drop in both renderers — keep the formula in sync across Viewfinder + +// exportEngine. +export interface CustomWatermark { + enabled: boolean; + text: string; + x: number; + y: number; + // Styling set by the COLOR / SIZE / FONT chips. Absent on marks saved before + // those controls existed — the renderers then fall back to amber, 1x and the + // bundled Cousine face, i.e. exactly the previous look. + color?: string; + size?: number; // multiplier on the base 3.2%-of-width text size + font?: string | null; // OS font family; null/absent = bundled font +} + +// GPS watermark placement (WATERMARK tab drag + pinch): x/y are 0..1 fractions +// of the SAME area the custom mark uses (frame card / photo rect / screen), and +// the anchor is the top-left corner of the two-line block. size multiplies the +// base 3.2%-of-width size. Defaults reproduce the old fixed top-left look. +export interface GpsWatermark { + x: number; + y: number; + size: number; + // Styling set by the WATERMARK tab's PLACE NAME / TIME / COLOR chips. Absent + // on marks saved before those controls existed — the renderers then fall back + // to amber text with both lines shown, i.e. exactly the previous look. + color?: string; + showName?: boolean; + showTime?: boolean; +} diff --git a/docker/frontend/shared/utils/cinemaShader.ts b/docker/frontend/shared/utils/cinemaShader.ts new file mode 100644 index 0000000..8efc322 --- /dev/null +++ b/docker/frontend/shared/utils/cinemaShader.ts @@ -0,0 +1,138 @@ +import { CinemaSeason } from '../types'; + +// Cinematic seasonal atmosphere grade. Deliberately NOT a fake re-light: a +// still photo has no depth, so "moving the sun" would look plastic. Instead +// this reads the photo's EXISTING exposure and pushes it a certain way: +// - lit areas get the season's warm/golden tint + a soft sun glow (halation) +// - shadow side gets a complementary cool tint +// - a seasonal haze/fog veil blends toward a fog color (strongest in winter) +// That reads as "sunlight / morning mist" without inventing new light. +// +// Uniforms are 16 flat floats (scalar form keeps the JSX uniforms +// object and the worklet/export flat arrays in the exact same order): +// strength, contrast, saturation, haze, +// warmR, warmG, warmB, warmAmt, +// coolR, coolG, coolB, coolAmt, +// fogR, fogG, fogB, fogAmt +export const CINEMA_SKSL = ` +uniform shader src; +uniform float strength; +uniform float contrast; +uniform float saturation; +uniform float haze; +uniform float warmR; +uniform float warmG; +uniform float warmB; +uniform float warmAmt; +uniform float coolR; +uniform float coolG; +uniform float coolB; +uniform float coolAmt; +uniform float fogR; +uniform float fogG; +uniform float fogB; +uniform float fogAmt; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); + vec3 r = c.rgb; + // Filmic-ish gentle contrast on the luma (keeps hue). + r = mix(r, clamp((r - 0.5) * (1.0 + contrast * strength) + 0.5, 0.0, 1.0), strength); + // Saturation. + float l = dot(r, vec3(0.2126, 0.7152, 0.0722)); + r = mix(vec3(l), r, mix(1.0, saturation, strength)); + // Season tints follow the EXISTING light: warm on lit areas, cool in shade. + float lit = smoothstep(0.30, 1.0, t); + float shad = 1.0 - smoothstep(0.0, 0.45, t); + vec3 warm = vec3(warmR, warmG, warmB); + vec3 cool = vec3(coolR, coolG, coolB); + r *= 1.0 + (warm - 1.0) * lit * warmAmt * strength; + r *= 1.0 + (cool - 1.0) * shad * coolAmt * strength; + // Soft sun glow at the brightest points (halation of existing highlights). + float glow = smoothstep(0.65, 1.0, t); + r += warm * glow * warmAmt * 0.45 * strength; + // Atmospheric haze/fog veil (lightest shadow side => misty air). + vec3 fog = vec3(fogR, fogG, fogB); + r = mix(r, fog, fogAmt * strength * (0.3 + 0.7 * shad)); + return vec4(clamp(r, 0.0, 1.0), c.a); +} +`; + +export interface CinemaUniformSet { + season: CinemaSeason; + // 16 flat floats — order matches CINEMA_SKSL declaration order above. + flat: number[]; + // Name-keyed, for the JSX form. + uniforms: Record; +} + +const ZERO: Record = {}; + +const SEASONS: Record = { + // Soft fresh light, gentle warm haze, barely-there cool in shade. + spring: { + name: 'CINEMA SPRING', + values: [ + 0.6, 0.08, 1.04, 0.18, + 1.02, 0.998, 0.95, 0.3, + 0.97, 0.995, 1.03, 0.12, + 0.95, 0.96, 0.98, 0.16, + ], + }, + // Golden hour-ish high sun: strong warm on lit, bluish shade, crisp. + summer: { + name: 'CINEMA SUMMER', + values: [ + 0.75, 0.2, 1.12, 0.1, + 1.08, 1.04, 0.88, 0.4, + 0.93, 0.97, 1.06, 0.2, + 1.0, 0.97, 0.88, 0.05, + ], + }, + // Amber low sun + warm atmospheric haze. + autumn: { + name: 'CINEMA AUTUMN', + values: [ + 0.8, 0.16, 1.1, 0.24, + 1.12, 1.02, 0.78, 0.42, + 1.02, 0.98, 0.88, 0.16, + 1.0, 0.93, 0.75, 0.18, + ], + }, + // Cold winter fog: desaturated, blue-grey, heavy mist, no warm sun. + winter: { + name: 'CINEMA WINTER', + values: [ + 0.65, -0.02, 0.88, 0.38, + 0.99, 0.995, 1.02, 0.06, + 0.94, 0.97, 1.08, 0.3, + 0.9, 0.93, 0.97, 0.45, + ], + }, +}; + +const KEYS = [ + 'strength', 'contrast', 'saturation', 'haze', + 'warmR', 'warmG', 'warmB', 'warmAmt', + 'coolR', 'coolG', 'coolB', 'coolAmt', + 'fogR', 'fogG', 'fogB', 'fogAmt', +] as const; + +export function getCinemaUniforms(season: CinemaSeason | null | undefined): CinemaUniformSet | null { + if (!season) return null; + const s = SEASONS[season]; + if (!s) return null; + const uniforms: Record = {}; + KEYS.forEach((k, i) => (uniforms[k] = s.values[i])); + return { season, flat: s.values, uniforms }; +} + +// Zeroed 16-float block — used to turn the cinema pass OFF in the worklet +// synchronizable (strength === 0 means inactive there). +export function cinemaOffFlat(): number[] { + return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +} + +export function cinemaIsActive(flat: number[] | null | undefined): boolean { + return !!flat && flat.length === 16 && flat[0] > 0; +} diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts new file mode 100644 index 0000000..4a69a24 --- /dev/null +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -0,0 +1,313 @@ +import { ColorAdjustments, BaseFilter } from '../types'; + +// Kelvin to RGB compensation for White Balance. +// The WB setting names the light the camera is told to expect, so its gain is +// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a +// daylight scene (5500K) and the camera thinks the light is bluer than it is +// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at +// 5500K, the app's neutral reference. +// +// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin: +// - moving the three channels independently (the old one lifted green 20% at +// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow- +// green rather than orange. Here every gain is divided by its own Rec.709 +// luma, so a neutral pixel keeps its level and only the cast moves. +// - the excursion was arbitrary. These are the physical ratios of the sRGB +// blackbody colours, tamed by a square root: the raw linear-light ratio +// (2.1x on blue at 2500K) over-corrects once it lands on already tone-mapped +// pixels, which is exactly the domain a 4x5 colour matrix works in. +export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } { + const k = Math.min(10000, Math.max(2500, kelvin)); + const light = planckianSRGB(k); + const ref = planckianSRGB(5500); + return normalizeGainLuma([ + Math.sqrt(ref.r / Math.max(light.r, 1e-4)), + Math.sqrt(ref.g / Math.max(light.g, 1e-4)), + Math.sqrt(ref.b / Math.max(light.b, 1e-4)), + ]); +} + +// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit +// of the Planckian locus; exact enough from 1000K up, and exactly neutral where +// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the +// caller. +function planckianSRGB(kelvin: number): { r: number; g: number; b: number } { + const t = kelvin / 100; + const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592); + const g = + t <= 66 + ? 99.4708025861 * Math.log(t) - 161.1195681661 + : 288.1221695283 * Math.pow(t - 60, -0.0755148492); + const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307; + const norm = (v: number) => Math.min(1, Math.max(0, v / 255)); + return { r: norm(r), g: norm(g), b: norm(b) }; +} + +// Divide a set of channel gains by their own luma: the CAST moves, the exposure +// does not. Channels still clip where they must — this only keeps a neutral +// pixel sitting at its neutral level. +function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } { + const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2]; + const s = luma > 0 ? 1 / luma : 1; + return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s }; +} + +// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and +// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the +// exposure-compensation EV gain are intentionally NOT part of the matrix — +// dynamicRange/highlight/shadow run as a tone shader (see toneShader.ts), +// and EV is applied either as hardware AE bias (camera) or by the caller via +// applyExposureGain (library), never doubled. +export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] { + const exposure = adj.exposure; + const contrast = adj.contrast; + const saturation = adj.saturation; + const temperature = adj.temperature; + const tint = adj.tint; + // colorChrome/colorChromeBlue are deliberately unused here — the chrome + // effects run in the tone shader (per-pixel chroma), not the matrix. + + // 1. Start with Identity Matrix + let matrix = [ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0, + ]; + + // 2. Base Filters + if (baseFilter === 'classic-neg') { + // Classic Neg (Superia-like): restrained colour, higher contrast around + // mid-grey, reds a touch deeper. Its signature split tone — green/cyan + // darks drifting to warm brights — cannot live in a linear matrix (one cast + // would hit both ends), so it ships in FILM_TONE (toneShader.ts) instead. + matrix = [ + 0.9961, 0.0773, 0.0078, 0, -0.0406, + 0.0226, 1.0298, 0.0076, 0, -0.0300, + 0.0224, 0.0750, 0.9520, 0, -0.0247, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'provia') { + // Provia (Standard): the reference stock — balanced, faithful, easy on the + // eye. Mild saturation and a shallow contrast lift around mid-grey, with no + // channel cast, so neutrals stay neutral and only the chroma deepens. + matrix = [ + 1.1492, -0.0901, -0.0091, 0, -0.0250, + -0.0268, 1.0859, -0.0091, 0, -0.0250, + -0.0268, -0.0901, 1.1669, 0, -0.0250, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'velvia') { + // Velvia (Vivid): the loud one — saturation pushed very hard onto green, + // blue and red alike, on top of strong contrast with deep, dense darks. + // Saturated reds, skies and greens are meant to ride the clip; that hard + // edge is the look. + matrix = [ + 1.3895, -0.2540, -0.0256, 0, -0.0550, + -0.0772, 1.2355, -0.0261, 0, -0.0661, + -0.0775, -0.2603, 1.4756, 0, -0.0689, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'classic-chrome') { + // Classic Chrome: magazine-reportage look. Saturation is pulled well down + // while the G/B rows are crossed so blue skies lean teal/cyan (green comes + // up towards blue, with blue kept high). The heavy shadow crush that goes + // with it is the `sh` entry in FILM_TONE — a matrix alone cannot deepen one + // end of the curve without dragging the rest. + matrix = [ + 0.7792, 0.2188, 0.0220, 0, -0.0100, + 0.0652, 0.7186, 0.2362, 0, -0.0100, + 0.0652, 0.3973, 0.5575, 0, -0.0100, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'astia') { + // Astia (Soft): gentler than standard — contrast below neutral (shadows + // lift, highlights ease off) and colour held back, with a faint warm/pink + // bias that flatters skin. The outdoor-portrait stock. + matrix = [ + 0.9162, 0.0201, 0.0020, 0, 0.0428, + 0.0058, 0.9030, 0.0020, 0, 0.0386, + 0.0059, 0.0197, 0.8944, 0, 0.0400, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'eterna') { + // Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff. + matrix = [ + 0.94, 0.02, 0.02, 0, 0.02, + 0.02, 0.96, 0.02, 0, 0.02, + 0.02, 0.02, 0.94, 0, 0.02, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'leica') { + // LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation + // push. What makes this file read as one is the separation BETWEEN the + // hues, so the deepening lives in the off-diagonal terms, not in a global + // chroma gain: + // - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and + // every saturated colour gains ~5% through channel separation; Velvia + // pushes 39%, this stays quiet ("dậm đà, không gắt"). + // - a small uniform offset deepens the darks: solid shadows that keep their + // texture, no lifted black and no crushed toe. + // - red up / blue down with green held at 1.00 is the warm European cast: + // it lands on skin as warmth, never as the yellow-green a warm gain on + // all three channels would give. + matrix = [ + 1.060, -0.045, -0.010, 0, -0.010, + -0.030, 1.055, -0.025, 0, -0.008, + -0.025, -0.020, 1.040, 0, -0.005, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'leica-vivid') { + // LC STREETLIFE VIVID — the same slanted channel separation pushed into the + // digital era: ~20% of chroma gain (still well under Velvia's 39%, so it + // sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so + // VIVID is a brighter read of one look rather than a second, neutral one. + // No positive lift here: the brightness is the sim's own exposure value + // (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of + // the clip — white lands at most at 1.000 on the red row. + matrix = [ + 1.125, -0.100, -0.010, 0, -0.015, + -0.055, 1.110, -0.055, 0, -0.005, + -0.050, -0.070, 1.100, 0, -0.005, + 0, 0, 0, 1, 0, + ]; + } else if (baseFilter === 'monochrome') { + // Acros: pure BT.709 grayscale, zero colour leakage, and NO gain or offset. + // The weights sum to 1.0 and the offset is 0, so this matrix is a straight + // luminance readout: nothing clips at either end and every grey step the + // sensor recorded survives. (An earlier 1.10x / -0.05 version plugged every + // black below ~5% and blew every white above ~95% — exactly the fine + // graduation this stock is prized for.) The deep black and the highlight + // shoulder live in FILM_TONE (toneShader.ts), where the curve can be shaped + // one end at a time without touching the midtones. (Grain/sharpness stay + // the user's call — a film sim here is the colour grade only.) + const r = 0.2126; + const g = 0.7152; + const b = 0.0722; + matrix = [ + r, g, b, 0, 0, + r, g, b, 0, 0, + r, g, b, 0, 0, + 0, 0, 0, 1, 0, + ]; + } + + // 3. Apply Saturation adjustment (standard color matrix transformation) + if (baseFilter !== 'monochrome' && saturation !== 0) { + const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation + const invS = 1 - s; + const r = 0.213 * invS; + const g = 0.715 * invS; + const b = 0.072 * invS; + + const satMat = [ + r + s, g, b, 0, 0, + r, g + s, b, 0, 0, + r, g, b + s, 0, 0, + 0, 0, 0, 1, 0, + ]; + + matrix = multiplyMatrices(satMat, matrix); + } + + // 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the + // already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a + // matrix gain on R/B is a global saturation push that also moves greys, and a + // blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are + // consumed by getToneUniforms instead. + + // 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage + if (baseFilter !== 'monochrome') { + const rgbTemp = kelvinToRGB(temperature); + // Tint is the green↔magenta axis, and it is symmetric: + pushes magenta + // (red and blue up, green down), - pushes green (green up, red and blue + // down). The old version only ever ADDED a channel (magenta lifted R+B, + // green lifted G), so the knob brightened the frame instead of tinting it. + // Normalising the product keeps that at a tint shift, not an exposure one. + const tintMagenta = (tint / 10) * 0.08; + const wbGain = normalizeGainLuma([ + rgbTemp.r * (1 + tintMagenta), + rgbTemp.g * (1 - tintMagenta), + rgbTemp.b * (1 + tintMagenta), + ]); + + const wbMat = [ + wbGain.r, 0, 0, 0, 0, + 0, wbGain.g, 0, 0, 0, + 0, 0, wbGain.b, 0, 0, + 0, 0, 0, 1, 0, + ]; + matrix = multiplyMatrices(wbMat, matrix); + } + + // 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These + // axes are independent channel gains on top of the Kelvin/tint preset: each + // step nudges its channel ~1.1% toward/away from neutral, matching the + // perceptible size of one Fuji fine-tune step (-9..+9). + const wbRed = adj.wbRed ?? 0; + const wbBlue = adj.wbBlue ?? 0; + if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) { + const shiftMat = [ + 1 + (wbRed / 9) * 0.1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0, + 0, 0, 0, 1, 0, + ]; + matrix = multiplyMatrices(shiftMat, matrix); + } + + // 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so + // both keep affecting mono shots. Contrast scales around mid-grey 0.5. + if (exposure !== 0 || contrast !== 0) { + const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x + const expOffset = (exposure / 10) * 0.15; + const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x + const conOffset = 0.5 * (1 - conScale); + const adjMat = [ + expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale, + 0, expScale * conScale, 0, 0, expOffset + conOffset * expScale, + 0, 0, expScale * conScale, 0, expOffset + conOffset * expScale, + 0, 0, 0, 1, 0, + ]; + matrix = multiplyMatrices(adjMat, matrix); + } + + return matrix; +} + +// Apply photographic EV compensation (stops) as a linear gain: 2^EV on every +// RGB channel (offsets scale too, matching true exposure). Only used for +// stills that were NOT exposed with a hardware AE bias (library images). +export function applyExposureGain(matrix: number[], evStops: number): number[] { + if (!evStops) return matrix; + const s = Math.pow(2, evStops); + const gainMat = [ + s, 0, 0, 0, 0, + 0, s, 0, 0, 0, + 0, 0, s, 0, 0, + 0, 0, 0, 1, 0, + ]; + return multiplyMatrices(gainMat, matrix); +} + +// 4x5 Matrix multiplication utility: A * B +function multiplyMatrices(a: number[], b: number[]): number[] { + const result = new Array(20).fill(0); + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + result[r * 5 + c] = + a[r * 5 + 0] * b[0 * 5 + c] + + a[r * 5 + 1] * b[1 * 5 + c] + + a[r * 5 + 2] * b[2 * 5 + c] + + a[r * 5 + 3] * b[3 * 5 + c]; + } + // Handle the 5th column translation + result[r * 5 + 4] = + a[r * 5 + 0] * b[0 * 5 + 4] + + a[r * 5 + 1] * b[1 * 5 + 4] + + a[r * 5 + 2] * b[2 * 5 + 4] + + a[r * 5 + 3] * b[3 * 5 + 4] + + a[r * 5 + 4]; + } + return result; +} diff --git a/docker/frontend/shared/utils/defaultRecipes.ts b/docker/frontend/shared/utils/defaultRecipes.ts new file mode 100644 index 0000000..22cb950 --- /dev/null +++ b/docker/frontend/shared/utils/defaultRecipes.ts @@ -0,0 +1,303 @@ +import { Recipe, ColorAdjustments } from '../types'; + +// Fallback values for every adjustable knob. Old recipes/sessions persisted in +// AsyncStorage lack the newer fields — merge this under stored JSON to keep +// every adjustment well-defined (see storageUtils.getAllRecipes / App session). +export const DEFAULT_ADJUSTMENTS: ColorAdjustments = { + exposure: 0, + contrast: 0, + saturation: 0, + temperature: 5500, + tint: 0, + wbRed: 0, + wbBlue: 0, + highlight: 0, + shadow: 0, + denoise: 0, + clarity: 0, + grain: 0, + colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, +}; + +// Standard Fuji film simulations. These are the fixed "look" presets shown at +// the front of the PRESETS chip row: tapping one applies the simulation — its +// baseFilter colour matrix plus whatever the stock itself carries outside the +// matrix (its FILM_TONE curve, and a grain amount when the grain IS the look, +// as on Acros). Every other knob still resets to its neutral default (0 / +// auto), so a sim is a clean starting point, never a baked recipe; any further +// look (DR, CC, contrast, more grain) is added by the user afterwards. +export interface FilmSim { + id: string; // Recipe-like id used only while the sim is the current selection + name: string; + baseFilter: Recipe['baseFilter']; + adjustments: ColorAdjustments; +} + +export const FILM_SIMS: FilmSim[] = [ + { id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS, grain: 3 } }, + // The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own + // pair instead of one look that has to compromise: + // CLASSIC — the stock character: deep solid darks, natural shadows, rich but + // quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both + // renderers), never a global saturation boost, and a whisker of grain + // keeps it feeling like film rather than digital sharpness. + // VIVID — the same channel separation opened up for the digital era: lifted + // exposure, lifted shadows, a little colour. It reads bright and flattering + // straight off the shutter, no editing pass needed. + // Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's + // own adjustments, which every apply path now carries into the panel. + { id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } }, + { id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } }, +]; + +// Film sim → full Recipe (never persisted; sims are applied on the fly). +export function filmSimToRecipe(sim: FilmSim): Recipe { + return { + id: sim.id, + name: sim.name, + baseFilter: sim.baseFilter, + adjustments: { ...sim.adjustments }, + frameId: 'none', + useGeotag: true, + }; +} + +export const DEFAULT_RECIPES: Recipe[] = [ + { + id: 'classic-neg-default', + name: 'CLASSIC NEGIPES.', + baseFilter: 'classic-neg', + adjustments: { + exposure: 2, + contrast: 1, + saturation: -1, + temperature: 6300, + tint: 2, + highlight: -2, + shadow: 1, + denoise: 1, + clarity: 2, + grain: 4, + colorChrome: 'weak', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + useGeotag: true, + }, + { + id: 'velvia-default', + name: 'VELVIPES VIVID', + baseFilter: 'velvia', + adjustments: { + exposure: 0, + contrast: 2, + saturation: 3, + temperature: 5500, + tint: 0, + highlight: 1, + shadow: -1, + denoise: 2, + clarity: 4, + grain: 1, + colorChrome: 'strong', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + useGeotag: true, + }, + { + id: 'provia-default', + name: 'PROVIPES STD', + baseFilter: 'provia', + adjustments: { + exposure: 1, + contrast: 0, + saturation: 0, + temperature: 5000, + tint: 0, + highlight: 0, + shadow: 0, + denoise: 2, + clarity: 1, + grain: 2, + colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + useGeotag: true, + }, + { + id: 'acros-default', + name: 'ACRIPES MONO', + baseFilter: 'monochrome', + adjustments: { + exposure: 0, + // Low: the deep black comes from FILM_TONE's toe and the micro-contrast + // from CLARITY, both of which add separation WITHOUT flattening the grey + // ramp. The old 4 / highlight 3 / shadow -2 stack was three global + // contrast moves on top of a matrix that was already clipping — it + // bought its deep black by throwing away every step below 9% and every + // one above 91%. + contrast: 2, + saturation: 0, + temperature: 5000, + tint: 0, + highlight: 0, + shadow: 0, + denoise: 0, + clarity: 3, + grain: 5, + colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + useGeotag: true, + }, + { + id: 'retro-amber-default', + name: 'RETRO AMBER 🏮', + baseFilter: 'classic-neg', + adjustments: { + exposure: 3, + contrast: 2, + saturation: 1, + temperature: 7500, + tint: -2, + highlight: -3, + shadow: 2, + denoise: 3, + clarity: -2, + grain: 5, + colorChrome: 'strong', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'classic-white', + useGeotag: true, + }, + + { + id: 'cinema-spring-default', + name: 'CINEMA SPRING', + baseFilter: 'none', + adjustments: { + exposure: 0, + contrast: 1, + saturation: 1, + temperature: 5800, + tint: 0, + highlight: -1, + shadow: 1, + denoise: 2, + clarity: 1, + grain: 3, + colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + cinema: 'spring', + useGeotag: true, + }, + { + id: 'cinema-summer-default', + name: 'CINEMA SUMMER', + baseFilter: 'none', + adjustments: { + exposure: 0, + contrast: 2, + saturation: 2, + temperature: 6000, + tint: -1, + highlight: 1, + shadow: -1, + denoise: 1, + clarity: 2, + grain: 2, + colorChrome: 'weak', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + cinema: 'summer', + useGeotag: true, + }, + { + id: 'cinema-autumn-default', + name: 'CINEMA AUTUMN', + baseFilter: 'none', + adjustments: { + exposure: 0, + contrast: 1, + saturation: 2, + temperature: 7200, + tint: -2, + highlight: -2, + shadow: 1, + denoise: 2, + clarity: 0, + grain: 4, + colorChrome: 'weak', + colorChromeBlue: 'none', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + cinema: 'autumn', + useGeotag: true, + }, + { + id: 'cinema-winter-default', + name: 'CINEMA WINTER', + baseFilter: 'none', + adjustments: { + exposure: 1, + contrast: -1, + saturation: -2, + temperature: 4500, + tint: 2, + highlight: -2, + shadow: 2, + denoise: 3, + clarity: -2, + grain: 3, + colorChrome: 'none', + colorChromeBlue: 'weak', + dynamicRange: 'auto', + sharpening: 0, + exposureCompensation: 0, + }, + frameId: 'none', + cinema: 'winter', + useGeotag: true, + }, +]; diff --git a/docker/frontend/shared/utils/exifWrite.ts b/docker/frontend/shared/utils/exifWrite.ts new file mode 100644 index 0000000..14818d1 --- /dev/null +++ b/docker/frontend/shared/utils/exifWrite.ts @@ -0,0 +1,475 @@ +// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly. +// +// Skia re-encodes the photo, which drops the original EXIF block (see +// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE +// photo's TIFF, keeps every tag it does not own, and stamps the fields this app +// is responsible for — software + version, capture date + zone, GPS, pixel +// dimensions — plus the baseline tags a conforming EXIF block must carry +// (ExifVersion, FlashPixVersion, ComponentsConfiguration, ColorSpace, Interop). +// Same in-place byte approach as jpegDpi.ts: no dependency, no native module. +// +// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried +// thumbnail would show the PRE-export pixels, and MakerNote holds absolute +// offsets that a relocated TIFF invalidates. Read them back when a use case +// needs them. + +// Stamped into every file this app writes (kept in step with app.json). +export const SOFTWARE = 'RecipesCam 1.2'; + +export interface ExifStamp { + software?: string; + // Shutter instant. null/undefined = keep the loaded photo's own + // DateTimeOriginal and only fill it in when the source has none. + dateTime?: Date | null; + gps?: { latitude: number; longitude: number } | null; + // Place name shown on the Gallery frame (EXIF 0x889d). Stock photos carry it + // whenever the capture was geotagged; ours have to state it from the same + // info the watermark prints. + locationName?: string | null; + width?: number | null; + height?: number | null; + dpi?: number | null; + // Device identity for the Gallery watermark blob (ref. watermarkBlob). Only + // used when the source photo carries no blob of its own. + device?: DeviceIdentity | null; +} + +/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */ +export interface DeviceIdentity { + device: string; // ro.product.device codename, e.g. "aurora" + marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra" + manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi" + model: string; // Build.MODEL — fallback when marketName is empty +} + +interface Entry { + tag: number; + type: number; + count: number; + data: Uint8Array; + ref?: 'exif' | 'gps' | 'interop'; // IFD pointer entry — value patched during layout +} + +const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10; + +const TYPE_SIZE: Record = { + [BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8, + [UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8, +}; + +// Tags this module owns, or that would dangle once the TIFF is rebuilt. +const DROP = new Set([ + 0x0112, // Orientation — the exported pixels are already upright (stamped back as 1) + 0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size) + 0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set) + 0x0131, 0x0132, // Software / DateTime + 0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt) + 0x0200, 0x0201, 0x0202, // thumbnail offset/length + 0x927c, // MakerNote — internal offsets do not survive relocation +]); + +// --- little-endian writers ------------------------------------------------- + +function u16w(b: Uint8Array, p: number, v: number): void { + b[p] = v & 0xff; + b[p + 1] = (v >> 8) & 0xff; +} + +function u32w(b: Uint8Array, p: number, v: number): void { + b[p] = v & 0xff; + b[p + 1] = (v >> 8) & 0xff; + b[p + 2] = (v >> 16) & 0xff; + b[p + 3] = (v >> 24) & 0xff; +} + +// --- entry builders -------------------------------------------------------- + +// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8: +// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the +// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text +// encodes byte-for-byte the same as before. +function asciiEntry(tag: number, text: string): Entry { + const bytes: number[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0) as number; + if (cp < 0x80) bytes.push(cp); + else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); + else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + } + bytes.push(0); + return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) }; +} + +function shortEntry(tag: number, v: number): Entry { + const data = new Uint8Array(2); + u16w(data, 0, v); + return { tag, type: SHORT, count: 1, data }; +} + +// UNDEFINED value: a raw byte string whose length IS the count (ExifVersion etc). +function undefinedEntry(tag: number, text: string): Entry { + const data = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff; + return { tag, type: UNDEFINED, count: data.length, data }; +} + +function longEntry(tag: number, v: number): Entry { + const data = new Uint8Array(4); + u32w(data, 0, v); + return { tag, type: LONG, count: 1, data }; +} + +function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry { + const data = new Uint8Array(8); + u32w(data, 0, num); + u32w(data, 4, den); + return { tag, type, count: 1, data }; +} + +// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000 +// for the seconds so ~0.1 m survives. +function gpsCoordEntry(tag: number, value: number): Entry { + const abs = Math.abs(value); + let deg = Math.floor(abs); + let min = Math.floor((abs - deg) * 60); + let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000); + if (sec >= 600000) { sec = 0; min += 1; } + if (min >= 60) { min = 0; deg += 1; } + const data = new Uint8Array(24); + u32w(data, 0, deg); u32w(data, 4, 1); + u32w(data, 8, min); u32w(data, 12, 1); + u32w(data, 16, sec); u32w(data, 20, 10000); + return { tag, type: RATIONAL, count: 3, data }; +} + +// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted +// into a little-endian TIFF, so every numeric value must be swapped first +// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are). +function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array { + const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0; + if (!unit) return data; + // `new Uint8Array(data)` (not data.slice()) — the input may be a Node Buffer, + // whose slice() aliases the source and would corrupt the caller's bytes. + const out = new Uint8Array(data); + for (let p = 0; p + unit <= out.length; p += unit) { + for (let i = 0; i < unit >> 1; i++) { + const a = out[p + i]; + out[p + i] = out[p + unit - 1 - i]; + out[p + unit - 1 - i] = a; + } + } + return out; +} + +function asciiValue(e: Entry | undefined): string | null { + if (!e || e.type !== ASCII || e.data.length === 0) return null; + let s = ''; + for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]); + return s || null; +} + +// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes). +function exifDate(d: Date): string { + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +// "±HH:MM" — the OffsetTime* tag format. Framing tools read it to place a +// capture correctly, and it is what makes the date unambiguous without GPS. +function tzOffset(d: Date): string { + const mins = -d.getTimezoneOffset(); + const a = Math.abs(mins); + const p = (n: number) => String(n).padStart(2, '0'); + return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`; +} + +// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it +// will open its watermark editor: without it, a photo edited here fails with +// "cannot recognize the parameters". Only the stock camera app writes this tag +// — CameraX/HAL never does — so a camera capture has to synthesize it from the +// same props the stock app derives it from. Shape matches the stock bytes: +// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}. +function watermarkBlob(d: DeviceIdentity): string { + const brand = d.manufacturer.trim(); + const market = d.marketName.trim(); + // "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand. + const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `) + ? market.slice(brand.length + 1) + : market || d.model; + return JSON.stringify({ + buildDevice: d.device, + customize: '', + cvLensName: '', + filterName: '', + livephotoInfo: '', + version: 2, + waterLogo: brand.toUpperCase(), + waterName, + }); +} + +// --- parse ----------------------------------------------------------------- + +interface ParsedTiff { + ifd0: Entry[]; + exif: Entry[]; + gps: Entry[]; + interop: Entry[]; +} + +function parseTiff(tiff: Uint8Array): ParsedTiff { + const none: ParsedTiff = { ifd0: [], exif: [], gps: [], interop: [] }; + if (tiff.length < 8) return none; + const le = tiff[0] === 0x49 && tiff[1] === 0x49; + const be = tiff[0] === 0x4d && tiff[1] === 0x4d; + if (!le && !be) return none; + + const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]); + const u32 = (p: number): number => + le + ? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0 + : ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0; + + const read = (off: number): { list: Entry[]; ptr: Record } => { + const out: { list: Entry[]; ptr: Record } = { list: [], ptr: {} }; + if (off <= 0 || off + 2 > tiff.length) return out; + const n = u16(off); + for (let i = 0; i < n; i++) { + const p = off + 2 + i * 12; + if (p + 12 > tiff.length) break; + const tag = u16(p); + const type = u16(p + 2); + const count = u32(p + 4); + if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) { + out.ptr[tag] = u32(p + 8); // sub-IFD pointers are rebuilt, never copied + continue; + } + const size = TYPE_SIZE[type]; + if (!size || DROP.has(tag)) continue; + const len = size * count; + if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts + if (len <= 4) { + out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) }); + } else { + const o = u32(p + 8); + if (o + len > tiff.length) continue; + const raw = tiff.slice(o, o + len); + out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw }); + } + } + return out; + }; + + const a = read(u32(4)); + const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} }; + return { + ifd0: a.list, + exif: exifRead.list, + gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [], + interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [], + }; +} + +// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the +// payload after the "Exif\0\0" signature. +function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null { + if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null; + let i = 2; + while (i + 1 < jpeg.length && jpeg[i] === 0xff) { + const start = i; + while (jpeg[i] === 0xff) i++; + const marker = jpeg[i]; + i++; + if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (i + 2 > jpeg.length) return null; + const len = (jpeg[i] << 8) | jpeg[i + 1]; + if (len < 2 || i + len > jpeg.length) return null; + const dataStart = i + 2; + if ( + marker === 0xe1 && len >= 8 && + jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 && + jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00 + ) { + return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) }; + } + i += len; + } + return null; +} + +// --- serialize ------------------------------------------------------------- + +function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array { + const ifdLen = (n: number) => 2 + n * 12 + 4; + const outLen = (es: Entry[]) => { + let s = 0; + for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1; + return s; + }; + + const o0 = 8; + const d0 = o0 + ifdLen(ifd0.length); + const oE = d0 + outLen(ifd0); + const dE = oE + ifdLen(exif.length); + const oG = dE + outLen(exif); + const dG = oG + ifdLen(gps.length); + const oI = dG + outLen(gps); + const dI = oI + ifdLen(interop.length); + const t = new Uint8Array(dI + outLen(interop)); + + t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42 + u32w(t, 4, o0); + + const writeIfd = (off: number, es: Entry[], dataOff: number): void => { + u16w(t, off, es.length); + let c = dataOff; + es.forEach((e, i) => { + const p = off + 2 + i * 12; + u16w(t, p, e.tag); + u16w(t, p + 2, e.type); + u32w(t, p + 4, e.count); + if (e.ref === 'exif') { u32w(t, p + 8, oE); return; } + if (e.ref === 'gps') { u32w(t, p + 8, oG); return; } + if (e.ref === 'interop') { u32w(t, p + 8, oI); return; } + if (e.data.length <= 4) { t.set(e.data, p + 8); return; } + u32w(t, p + 8, c); + t.set(e.data, c); + c += (e.data.length + 1) & ~1; // values start on a word boundary + }); + u32w(t, off + 2 + es.length * 12, 0); // no IFD1 + }; + + writeIfd(o0, ifd0, d0); + writeIfd(oE, exif, dE); + writeIfd(oG, gps, dG); + writeIfd(oI, interop, dI); + return t; +} + +/** + * Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates + * every tag this module does not own; `stamp` sets the ones the app owns. + * Returns a new array; input is never modified. A JPEG that cannot take the + * segment (not a JPEG, EXIF larger than 64 KB) comes back untouched. + */ +export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array { + if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg; + + // An unreadable source (content:// uri, IO error) must not wipe the tags the + // jpeg already carries, so fall back to the jpeg's own EXIF block. + const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg); + const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [], interop: [] }; + const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e])); + const exif = new Map(parsed.exif.map((e) => [e.tag, e])); + const gps = new Map(parsed.gps.map((e) => [e.tag, e])); + const interop = new Map(parsed.interop.map((e) => [e.tag, e])); + + const now = exifDate(stamp.dateTime ?? new Date()); + const keepSourceDate = !stamp.dateTime; + const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003)); + const dateStr = keepSourceDate ? sourceDate ?? now : now; + + if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr)); + if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr)); + if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr)); + if (!keepSourceDate) { + // We own the timestamp for camera shots, so we also state its zone. + const off = asciiEntry(0x9010, tzOffset(stamp.dateTime as Date)); + ifd0.set(0x9010, off); // OffsetTime (0th IFD) + exif.set(0x9011, { ...off, tag: 0x9011 }); // OffsetTimeOriginal + exif.set(0x9012, { ...off, tag: 0x9012 }); // OffsetTimeDigitized + } + ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE)); + ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight + + // Baseline tags a conforming EXIF block is expected to carry. Framing apps + // reject a file that omits them, and HALs (the emulator's especially) do not + // always supply them, so fill the gaps and keep whatever the source had. + if (!exif.has(0x9000)) exif.set(0x9000, undefinedEntry(0x9000, '0230')); // ExifVersion + if (!exif.has(0xa000)) exif.set(0xa000, undefinedEntry(0xa000, '0100')); // FlashPixVersion + if (!exif.has(0x9101)) exif.set(0x9101, { tag: 0x9101, type: UNDEFINED, count: 4, data: new Uint8Array([1, 2, 3, 0]) }); // YCbCr + if (!exif.has(0xa001)) exif.set(0xa001, shortEntry(0xa001, 65535)); // ColorSpace = uncalibrated + if (!interop.has(0x0001)) interop.set(0x0001, asciiEntry(0x0001, 'R98')); // InteropIndex + if (!interop.has(0x0002)) interop.set(0x0002, undefinedEntry(0x0002, '0100')); // InteropVersion + + if (stamp.width && stamp.height) { + ifd0.set(0x0100, longEntry(0x0100, stamp.width)); + ifd0.set(0x0101, longEntry(0x0101, stamp.height)); + exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension + exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension + } + + if (stamp.dpi && stamp.dpi > 0) { + ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1)); + ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1)); + ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch + } + + // HyperOS Gallery refuses to open its watermark editor without 0x889e, and a + // CameraX capture never carries it. Never overwrite a blob the source has. + const xiaomi = !!stamp.device && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer); + if (stamp.device && xiaomi && !exif.has(0x889e)) { + exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device))); + } + + // Stock Xiaomi photos name the device in 0x9a00 and in Model (0x0110) as the + // MARKET name ("Xiaomi 14 Ultra"); the framing tool matches those against its + // known-device list and reports an unknown device otherwise. A CameraX capture + // only has the raw codename, so state the market name. A source that already + // carries 0x9a00 is a stock photo of some other device — leave it alone. + if (stamp.device && xiaomi && !ifd0.has(0x9a00)) { + const market = (stamp.device.marketName || stamp.device.model).trim(); + if (market) { + ifd0.set(0x9a00, asciiEntry(0x9a00, market)); + ifd0.set(0x0110, asciiEntry(0x0110, market)); + } + } + + // Place name the frame prints (0x889d) — stock writes it in both IFDs. + const place = stamp.locationName?.trim(); + if (place) { + if (!ifd0.has(0x889d)) ifd0.set(0x889d, asciiEntry(0x889d, place)); + if (!exif.has(0x889d)) exif.set(0x889d, asciiEntry(0x889d, place)); + } + + if (stamp.gps) { + const { latitude: lat, longitude: lon } = stamp.gps; + gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S')); + gps.set(0x0002, gpsCoordEntry(0x0002, lat)); + gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W')); + gps.set(0x0004, gpsCoordEntry(0x0004, lon)); + } + + const list0 = [...ifd0.values()]; + if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' }); + if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' }); + list0.sort((a, b) => a.tag - b.tag); + const listE = [...exif.values()].sort((a, b) => a.tag - b.tag); + const listG = [...gps.values()].sort((a, b) => a.tag - b.tag); + if (interop.size > 0) listE.push({ tag: 0xa005, type: LONG, count: 1, data: new Uint8Array(4), ref: 'interop' }); + listE.sort((a, b) => a.tag - b.tag); + const listI = [...interop.values()].sort((a, b) => a.tag - b.tag); + + const tiff = serializeTiff(list0, listE, listG, listI); + const payload = 2 + 6 + tiff.length; + if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field + + const app1 = new Uint8Array(2 + payload); + app1[0] = 0xff; app1[1] = 0xe1; + // APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian) + app1[2] = (payload >> 8) & 0xff; + app1[3] = payload & 0xff; + app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0" + app1.set(tiff, 10); + + const old = findExifSegment(jpeg); + const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2); + const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2); + const out = new Uint8Array(head.length + app1.length + tail.length); + out.set(head, 0); + out.set(app1, head.length); + out.set(tail, head.length + app1.length); + return out; +} diff --git a/docker/frontend/shared/utils/frameUtils.ts b/docker/frontend/shared/utils/frameUtils.ts new file mode 100644 index 0000000..f4290d9 --- /dev/null +++ b/docker/frontend/shared/utils/frameUtils.ts @@ -0,0 +1,204 @@ +import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia'; +import { FrameId } from '../types'; + +export interface FrameDefinition { + id: FrameId; + name: string; +} + +export const FRAMES: FrameDefinition[] = [ + { id: 'none', name: 'NO FRAME' }, + { id: 'classic-white', name: 'CLASSIC BORDER' }, + { id: 'polaroid', name: 'RETRO INSTANT' }, + { id: 'wallframe', name: 'WALL FRAME' }, +]; + +// Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print). +// All fractions are relative to the CARD WIDTH so the layout scales to any +// pixel size while keeping the physical look. +export const POLAROID_CARD = { + // Horizontal + top margin: (88 - 79)/2 / 88 mm each side. + sideMargin: (88 - 79) / 2 / 88, // ≈ 0.05114 + // Bottom deck: remaining height of the 107 mm card below the 79 mm print, + // minus the top margin: (107 - 79 - 4.5) / 88 mm. + bottomDeck: (107 - 79 - (88 - 79) / 2) / 88, // ≈ 0.26705 +} as const; +// Image window width as a fraction of card width (79 / 88 mm). +export const POLAROID_WIN_W = 1 - 2 * POLAROID_CARD.sideMargin; // ≈ 0.89773 + +export interface PolaroidLayout { + card: { x: number; y: number; w: number; h: number }; + window: { x: number; y: number; w: number; h: number }; +} + +// Wall frame: the PNG artwork lives at the project root (wallframe.png, +// 4000x3117 RGBA, transparent window cols 675..3322 x rows 675..2441). Only the +// geometry lives here; the bitmap itself is drawn by the caller. The phone is +// portrait, so the app shows the artwork ROTATED 90° CW (original top edge → +// right edge): a portrait frame whose transparent window is a fixed 2:3 +// vertical opening. The photo is cover-cropped (center) into that opening — the +// mat of a real frame hides the print edges. WALLFRAME_W/H and WALLFRAME_WIN +// are the rotated display orientation; the affine in wallframeLayout maps the +// source PNG pixels onto the frame rect so the drawn opening and the photo +// window always line up. +export const WALLFRAME_PNG_W = 4000; // source PNG width +export const WALLFRAME_PNG_H = 3117; // source PNG height +export const WALLFRAME_W = 3117; // rotated display width (= PNG height) +export const WALLFRAME_H = 4000; // rotated display height (= PNG width) +// Transparent window as fractions of the rotated image, derived from the same +// affine used to draw the bitmap: X = w - (y/3117)w, Y = (x/4000)h. +export const WALLFRAME_WIN = { + left: 676 / 3117, // ≈ 0.21688 + top: 675 / 4000, // 0.16875 + w: 1766 / 3117, // ≈ 0.56657 + h: 2647 / 4000, // 0.66175 +} as const; +// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the +// canvas is the artwork (4000x3117) and the opening is the source window as +// fractions of it. Same 3:2 mat opening, hung the other way round. +export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000 +export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117 +export const WALLFRAME_LAND_WIN = { + left: 675 / WALLFRAME_PNG_W, // 0.16875 + top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688 + w: 2647 / WALLFRAME_PNG_W, // 0.66175 + h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657 +} as const; + +export interface WallframeLayout { + frame: { x: number; y: number; w: number; h: number }; + window: { x: number; y: number; w: number; h: number }; + // Row-major Skia matrix that rotates the source PNG (WALLFRAME_PNG_W/H) 90° CW + // into the frame rect. Feed it to (viewfinder) or concat it on + // the canvas (export) before drawing the bitmap. + drawMatrix: number[]; +} + +// Geometry of the wall-frame inside an arbitrary container. The frame is +// contain-fitted ('contain', centered, letterboxed) for the live viewfinder, +// or fills the container exactly ('fill', export canvas already at frame size). +// `landscape` hangs the artwork the other way round: same PNG, no rotation. +export function wallframeLayout( + contW: number, + contH: number, + mode: 'contain' | 'fill' = 'contain', + landscape = false +): WallframeLayout { + const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W; + const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H; + const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN; + let frameW: number; + let frameH: number; + if (mode === 'fill') { + frameW = contW; + frameH = contH; + } else { + const s = Math.min(contW / FW, contH / FH); + frameW = FW * s; + frameH = FH * s; + } + const fx = (contW - frameW) / 2; + const fy = (contH - frameH) / 2; + const scale = frameW / FW; // == frameH / FH + return { + frame: { x: fx, y: fy, w: frameW, h: frameH }, + window: { + x: fx + WIN.left * frameW, + y: fy + WIN.top * frameH, + w: WIN.w * frameW, + h: WIN.h * frameH, + }, + // Portrait: the source PNG must be turned 90° CW onto the canvas. + // Landscape: it already sits the right way up — scale and centre it. + drawMatrix: landscape + ? [scale, 0, fx, 0, scale, fy, 0, 0, 1] + : [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1], + }; +} + +// Geometry of a retro-polaroid card inside an arbitrary container. The photo is +// NEVER cropped: the image window keeps the photo aspect ratio (photoW/photoH) +// and the card grows/shrinks around it, so the whole picture always fits the +// window. 'contain' fits the full card inside the container (centered, with +// letterbox bars); 'fill' sizes the container exactly to the natural card +// proportions (used at export where the canvas IS the card). +export function polaroidLayout( + contW: number, + contH: number, + photoAspect: number, // photo width / photo height (>1 = landscape) + mode: 'contain' | 'fill' = 'contain' +): PolaroidLayout { + const S = POLAROID_CARD; + // Height of the card once the photo window (width WIN_W of the card width, + // height = that / photoAspect) is stacked below the top margin and above the + // bottom deck: cardH = cardW*(sideMargin + bottomDeck) + cardW*WIN_W/photoAspect + const naturalAspect = S.sideMargin + S.bottomDeck + POLAROID_WIN_W / photoAspect; // cardH/cardW + + let cardW: number; + let cardH: number; + if (mode === 'fill') { + cardW = contW; + cardH = contH; + } else { + cardW = Math.min(contW, contH / naturalAspect); + cardH = cardW * naturalAspect; + } + const cx = (contW - cardW) / 2; + const cy = (contH - cardH) / 2; + + const top = S.sideMargin * cardW; + const side = S.sideMargin * cardW; + let winW = POLAROID_WIN_W * cardW; + let winH = winW / photoAspect; + // Container aspect may leave less vertical room than the natural card (e.g. + // phone camera feed) — shrink the window to the available space instead of + // overflowing the card. + const maxWinH = cardH - top - S.bottomDeck * cardW; + if (winH > maxWinH) { + winH = maxWinH; + winW = winH * photoAspect; + } + return { + card: { x: cx, y: cy, w: cardW, h: cardH }, + window: { x: cx + (cardW - winW) / 2, y: cy + top, w: winW, h: winH }, + }; +} + +export function drawFrameOnCanvas( + canvas: SkCanvas, + width: number, + height: number, + frameId: FrameId +) { + if (frameId === 'none') return; + + const paint = Skia.Paint(); + + if (frameId === 'classic-white') { + // A clean white border around the picture + paint.setColor(Skia.Color('#ffffff')); + paint.setStyle(0); // Fill + + const borderWidth = Math.min(width, height) * 0.05; // 5% border + + // Top border + canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint); + // Bottom border + canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint); + // Left border + canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint); + // Right border + canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint); + } else if (frameId === 'cinematic') { + // Cinematic black bars on top and bottom + paint.setColor(Skia.Color('#000000')); + paint.setStyle(0); // Fill + + const barHeight = height * 0.12; // 12% cinematic letterbox height + + // Top letterbox + canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint); + // Bottom letterbox + canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint); + } +} diff --git a/docker/frontend/shared/utils/jpegDpi.ts b/docker/frontend/shared/utils/jpegDpi.ts new file mode 100644 index 0000000..976b211 --- /dev/null +++ b/docker/frontend/shared/utils/jpegDpi.ts @@ -0,0 +1,126 @@ +// Pure JPEG DPI metadata patcher. +// Re-encoding a JPEG through Skia drops the original EXIF/JFIF headers, so the +// DPI stamp ("72 dpi") is lost or defaulted. DPI is header metadata only — pixel +// dimensions never change — but print pipelines read it, so this rewrites the +// JFIF density (and EXIF XResolution/YResolution when present) to the requested +// value. All writes are same-length, so no segment offsets move. +// +// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is +// inserted right after SOI. + +function u16(bytes: Uint8Array, p: number): number { + return (bytes[p] << 8) | bytes[p + 1]; +} +function setU16(bytes: Uint8Array, p: number, v: number): void { + bytes[p] = (v >> 8) & 0xff; + bytes[p + 1] = v & 0xff; +} + +// Patches `bytes` in place for JFIF; returns the (possibly rebuilt) array. +export function patchJpegDpi(input: Uint8Array, dpi: number): Uint8Array { + if (input.length < 2 || input[0] !== 0xff || input[1] !== 0xd8) return input; + const bytes = input.slice(); // work on a copy; insertion path rebuilds anyway + const dpiLo = dpi & 0xff; + const dpiHi = (dpi >> 8) & 0xff; + + let sawJfif = false; + let sawExif = false; + + let i = 2; + while (i + 1 < bytes.length && bytes[i] === 0xff) { + while (bytes[i] === 0xff) i++; + const marker = bytes[i]; + i++; + if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS — headers only + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (i + 2 > bytes.length) break; + const len = u16(bytes, i); + if (len < 2 || i + len > bytes.length) break; + const dataStart = i + 2; + const payloadLen = len - 2; + + // APP0 JFIF: [FF E0] len [5x id "JFIF\0"][2 ver][1 units][2 Xd][2 Yd][1 thW][1 thH]... + if (marker === 0xe0 && payloadLen >= 14 && bytes[dataStart] === 0x4a && + bytes[dataStart + 1] === 0x46 && bytes[dataStart + 2] === 0x49 && + bytes[dataStart + 3] === 0x46 && bytes[dataStart + 4] === 0x00) { + sawJfif = true; + bytes[dataStart + 7] = 1; // units = dots per inch + bytes[dataStart + 8] = dpiHi; + bytes[dataStart + 9] = dpiLo; + bytes[dataStart + 10] = dpiHi; + bytes[dataStart + 11] = dpiLo; + } + + // APP1 EXIF: [FF E1] len "Exif\0\0" then TIFF + if (marker === 0xe1 && payloadLen >= 14 && + bytes[dataStart] === 0x45 && bytes[dataStart + 1] === 0x78 && + bytes[dataStart + 2] === 0x69 && bytes[dataStart + 3] === 0x66 && + bytes[dataStart + 4] === 0x00 && bytes[dataStart + 5] === 0x00) { + sawExif = true; + const tiff = dataStart + 6; + const le = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + const be = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d; + if ((le || be) && tiff + 8 <= bytes.length) { + const read32 = (p: number): number => + le + ? bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24) + : ((bytes[p] << 24) | (bytes[p + 1] << 16) | (bytes[p + 2] << 8) | bytes[p + 3]) >>> 0; + const write32 = (p: number, v: number): void => { + if (le) { + bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; + bytes[p + 2] = (v >> 16) & 0xff; bytes[p + 3] = (v >> 24) & 0xff; + } else { + bytes[p] = (v >> 24) & 0xff; bytes[p + 1] = (v >> 16) & 0xff; + bytes[p + 2] = (v >> 8) & 0xff; bytes[p + 3] = v & 0xff; + } + }; + // TIFF integer fields (tag/type/count/values) follow the file byte order + const u16r = (p: number): number => + le ? bytes[p] | (bytes[p + 1] << 8) : (bytes[p] << 8) | bytes[p + 1]; + const u16w = (p: number, v: number): void => { + if (le) { bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; } + else { bytes[p] = (v >> 8) & 0xff; bytes[p + 1] = v & 0xff; } + }; + const ifdOff = read32(tiff + 4); + if (ifdOff + 2 <= bytes.length - tiff) { + const ifd = tiff + ifdOff; + const count = u16r(ifd); + for (let e = 0; e < count; e++) { + const entry = ifd + 2 + e * 12; + if (entry + 12 > bytes.length) break; + const tag = u16r(entry); + const type = u16r(entry + 2); + const cnt = read32(entry + 4); + if ((tag === 0x011a || tag === 0x011b) && type === 5 && cnt === 1) { + // RATIONAL: entry+8 holds offset to (num, den), relative to TIFF start + const rat = tiff + read32(entry + 8); + if (rat + 8 <= bytes.length) write32(rat, dpi); // keep denominator + } else if (tag === 0x0128 && type === 3 && cnt === 1) { + // ResolutionUnit (2 = inch): normalize to inch so the new density reads correctly + u16w(entry + 8, 2); + } + } + } + } + } + + i += len; + } + + if (sawJfif || sawExif) return bytes; + + // Neither segment exists — insert a minimal JFIF APP0 (density = dpi inch) after SOI. + const app0 = new Uint8Array([ + 0xff, 0xe0, 0x00, 0x10, + 0x4a, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, // version 1.01 + 0x01, // units: inch + dpiHi, dpiLo, dpiHi, dpiLo, + 0x00, 0x00, // no thumbnail + ]); + const out = new Uint8Array(bytes.length + app0.length); + out[0] = 0xff; out[1] = 0xd8; + out.set(app0, 2); + out.set(bytes.subarray(2), 2 + app0.length); + return out; +} diff --git a/docker/frontend/shared/utils/paramDefs.ts b/docker/frontend/shared/utils/paramDefs.ts new file mode 100644 index 0000000..464fa79 --- /dev/null +++ b/docker/frontend/shared/utils/paramDefs.ts @@ -0,0 +1,202 @@ +import { ColorAdjustments } from '../types'; + +// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS +// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which +// needs the open row's bounds to drive drag-on-the-image adjustment). The get +// /set accessors are stateless so one table serves every render. +export interface ContinuousParamDef { + key: string; + label: string; + min: number; + max: number; + step: number; + defaultValue: number; + display: (v: number) => string; + get: (a: ColorAdjustments) => number; + set: (v: number) => Partial; + // PRO-only knob: the LITE build greys the chip out (PRO corner tag) and + // cannot open its row, so the value stays at its default there. + pro?: boolean; +} + +const sign = (v: number) => (v > 0 ? `+${v}` : String(v)); +const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`; + +export const PARAM_DEFS: { + iq: ContinuousParamDef[]; + wb: ContinuousParamDef[]; + filters: ContinuousParamDef[]; +} = { + iq: [ + { + key: 'exposure', + label: 'EXPOSURE', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.exposure, + set: (v) => ({ exposure: v }), + }, + { + key: 'ev', + label: 'EV', + min: -3, + max: 3, + step: 0.5, + defaultValue: 0, + display: oneEv, + get: (a) => a.exposureCompensation ?? 0, + set: (v) => ({ exposureCompensation: v }), + }, + { + key: 'contrast', + label: 'CONTRAST', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.contrast, + set: (v) => ({ contrast: v }), + }, + { + key: 'color', + label: 'COLOR', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.saturation, + set: (v) => ({ saturation: v }), + }, + { + key: 'vibrance', + label: 'VIBRANCE', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.vibrance ?? 0, + set: (v) => ({ vibrance: v }), + }, + { + key: 'highlight', + label: 'HIGHLIGHT', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.highlight ?? 0, + set: (v) => ({ highlight: v }), + }, + { + key: 'shadow', + label: 'SHADOW', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.shadow ?? 0, + set: (v) => ({ shadow: v }), + }, + ], + wb: [ + { + key: 'temperature', + label: 'COLOR TEMP', + min: 2500, + max: 10000, + step: 100, + defaultValue: 5500, + display: (v) => `${v}K`, + get: (a) => a.temperature, + set: (v) => ({ temperature: v }), + }, + { + key: 'tint', + label: 'TINT', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.tint ?? 0, + set: (v) => ({ tint: v }), + }, + ], + filters: [ + { + key: 'denoise', + label: 'NOISE REDUCTION', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: String, + get: (a) => a.denoise, + set: (v) => ({ denoise: v }), + }, + { + key: 'clarity', + label: 'CLARITY', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: sign, + get: (a) => a.clarity, + set: (v) => ({ clarity: v }), + }, + { + key: 'sharpening', + label: 'SHARPENING', + min: -10, + max: 10, + step: 1, + defaultValue: 0, + display: String, + get: (a) => a.sharpening ?? 0, + set: (v) => ({ sharpening: v }), + }, + { + key: 'grain', + label: 'MONOCHROME GRAIN', + min: 0, + max: 10, + step: 1, + defaultValue: 0, + display: String, + get: (a) => a.grain, + set: (v) => ({ grain: v }), + }, + { + key: 'hdf', + label: 'HDF EFFECT', + pro: true, + min: 0, + max: 10, + step: 1, + defaultValue: 0, + display: String, + get: (a) => a.hdf ?? 0, + set: (v) => ({ hdf: v }), + }, + { + key: 'vignette', + label: 'VIGNETTING', + min: 0, + max: 10, + step: 1, + defaultValue: 0, + display: String, + get: (a) => a.vignette ?? 0, + set: (v) => ({ vignette: v }), + }, + ], +}; diff --git a/docker/frontend/shared/utils/skiaImage.ts b/docker/frontend/shared/utils/skiaImage.ts new file mode 100644 index 0000000..03a69ab --- /dev/null +++ b/docker/frontend/shared/utils/skiaImage.ts @@ -0,0 +1,89 @@ +import { Skia, type SkImage } from '@shopify/react-native-skia'; + +// Turn a decoded image 90deg clockwise into a fresh SkImage. Used by the FRAME +// tab so a library photo can go landscape <-> portrait before it enters a frame: +// every consumer reads width()/height(), so the pixel-dimension swap carries the +// new orientation through the polaroid window aspect, the cover crop and the +// watermark areas without a single extra layout branch. +// ponytail: offscreen surface, same fallback as exportEngine.createSurface; a +// matrix-based draw would avoid the copy if this ever shows up in a profile. +export function rotateSkImage90(image: SkImage | null): SkImage | null { + if (!image) return null; + const w = image.width(); + const h = image.height(); + if (!(w > 0 && h > 0)) return null; + // CPU-backed on purpose: a GPU (MakeOffscreen) snapshot keeps its pixels in a + // context texture that is gone by the time the draws a frame later — + // the preview then renders the photo completely black. Raster pixels survive + // the trip to the render thread. + const surface = Skia.Surface.Make(h, w); + if (!surface) return image; // keep the unrotated photo on allocation failure + try { + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + canvas.translate(h, 0); + canvas.rotate(90, 0, 0); + canvas.drawImage(image, 0, 0); + return surface.makeImageSnapshot() ?? image; + } finally { + // The raster snapshot refcounts the pixels, so the surface can go now + // instead of leaving one full-size copy behind per rotation. + surface.dispose(); + } +} + +// Fine rotation about the centre (the FRAME tab's STRAIGHTEN), keeping the +// original pixel dimensions: the content is scaled up just enough that the +// rotated rectangle still covers the frame, so no empty corner shows. The +// scale is the axis-aligned wrap of the counter-rotated frame — slightly +// conservative, which crops a hair more rather than leaking a corner. +// ponytail: one offscreen surface per call, same trade as rotateSkImage90. +export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage | null { + if (!image) return null; + const w = image.width(); + const h = image.height(); + if (!(w > 0 && h > 0)) return null; + if (!degrees) return image; + const rad = (degrees * Math.PI) / 180; + const cos = Math.abs(Math.cos(rad)); + const sin = Math.abs(Math.sin(rad)); + const k = Math.max((w * cos + h * sin) / w, (w * sin + h * cos) / h); + const surface = Skia.Surface.Make(w, h); + if (!surface) return image; // keep the unrotated photo on allocation failure + try { + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + canvas.translate(w / 2, h / 2); + canvas.rotate(degrees, 0, 0); + canvas.scale(k, k); + canvas.drawImage(image, -w / 2, -h / 2); + return surface.makeImageSnapshot() ?? image; + } finally { + surface.dispose(); // see rotateSkImage90 + } +} + +// The whole FRAME-tab photo orientation in one call: the quarter turn first +// (rotateSkImage90 swaps the pixel dimensions, which then propagates through +// every consumer unchanged), then the fine STRAIGHTEN on top of it. +export function applyPhotoRotation( + image: SkImage | null, + quarter = 0, + straighten = 0 +): SkImage | null { + let out = image; + const turns = Math.round(quarter / 90) % 4; + for (let i = 0; i < turns; i++) { + const next = rotateSkImage90(out); + // A 180/270 turn allocates one full-size copy per turn: the intermediate is + // dead as soon as the next one exists (never the caller's input image). + if (next && next !== out && i > 0) out.dispose(); + out = next ?? out; + } + if (straighten) { + const s = rotateSkImageBy(out, straighten); + if (s && s !== out && out !== image) out.dispose(); // see above + out = s ?? out; + } + return out; +} diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts new file mode 100644 index 0000000..2325f36 --- /dev/null +++ b/docker/frontend/shared/utils/toneShader.ts @@ -0,0 +1,254 @@ +import { BaseFilter, ColorAdjustments } from '../types'; + +// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). +// SkSL runtime effect over a child image shader. +// +// Lightness/chroma split: the curve moves the luma and the colour difference +// (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B +// by one gain keeps the *ratio* but crushes absolute chroma — that is what +// turned saturated blues black under -SH and bright colours grey under -HL. +// +// Wide, soft knees so the knobs reach like a tone curve instead of biting only +// at the very ends: HL rides only the top (0.65..1.00) so it leaves the greys +// and midtones alone (a 0.45 knee dragged a mid-grey down) while SH rides the +// lower half (0.00..0.55), and the 0.50 midpoint never moves. Earlier narrow +// knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo +// without true whites or blacks. +// +// Both knobs are pure additive shifts of the luma. That keeps the curve +// monotonic (steepest knee slope 4.29/unit * 0.22 = 0.94, and the two knees +// never overlap), so a brighter input can never come out darker. The earlier +// multiplicative form was NOT monotonic: with hl=-1 a grey 0.73 came out +// darker than 0.80. +// +// dr - DR strength 0..1: lifts shadows slightly and rolls highlights +// (Fuji extended DR); 0/auto/DR100 = no extra curve. +// hl - highlight -1..1: + lifts toward white, - rolls the bright side down. +// sh - shadow -1..1: + lifts the dark side, - deepens it. +// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader +// (rather than the colour matrix) because it needs per-pixel chroma: +// already-vivid pixels move least, so skins/skies deepen without the neon +// clip a plain Saturation boost causes. +// shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied +// to the shadows and/or the highlights only. A 4x5 colour matrix cannot do +// this — it is one linear map, so any cast it applies must also hit the +// midtones and the opposite end. Classic Neg wants green/cyan shadows with +// warm highlights at once, so the stock ships these values and the pass +// stays active for it even when every user knob is 0. All-zero still = no +// pass. +export const TONE_SKSL = ` +uniform shader src; +uniform float dr; +uniform float hl; +uniform float sh; +uniform float vib; +uniform float shTr; +uniform float shTg; +uniform float shTb; +uniform float hlTr; +uniform float hlTg; +uniform float hlTb; +uniform float cc; +uniform float ccb; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + vec3 rgb = clamp(c.rgb, 0.0, 1.0); + float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); + float hlMask = smoothstep(0.65, 1.00, t); + float shMask = 1.0 - smoothstep(0.00, 0.55, t); + // NOTE: never name a local 'out' — it is a reserved SkSL qualifier. + float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask; + // Dynamic range: gentle shadow lift + highlight roll (protect brights). + o += dr * 0.12 * shMask * (1.0 - t); + o -= dr * 0.18 * hlMask * t; + o = clamp(o, 0.0, 1.0); + // Lightness takes the curve, hue stays: the colour difference is gained + // only part-way so darkening cannot collapse a colour to black and lifting + // cannot blow a dark saturated colour out to white. + float cg = clamp(o / max(t, 0.0004), 0.55, 1.35); + rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0); + // Split tone (stock look): the shadows and the highlights may each carry + // their own tint, so the two ends of the curve can drift opposite ways + // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. + rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0); + // Color Chrome / Color Chrome FX Blue: the two stock-dialed colour effects + // DEEPEN what is already chromatic and leave neutrals exactly where they are + // (Fuji: "deeper tone in highly saturated colour"; FX Blue does it for the + // blue/cyan side only). Both therefore need per-pixel chroma — a 4x5 colour + // matrix is one linear map, so any gain it applies also moves greys, and a + // blue-only gain drags the whole white point. + float mxc = max(max(rgb.r, rgb.g), rgb.b); + float mnc = min(min(rgb.r, rgb.g), rgb.b); + // Chroma ratio with a small floor: a near-black pixel with a hair of cast + // has ratio 1.0 but no colour to deepen, and must stay put. + float ccChroma = (mxc - mnc) / max(mxc, 0.10); + // Color Chrome rides the chroma itself: a muted colour barely moves, a vivid + // one gains density. The 0.25 knee keeps skin, haze and pastels untouched. + float ccMask = cc * smoothstep(0.25, 0.85, ccChroma); + // FX Blue: only where blue clearly leads red AND green (so magenta/purple + // stay out), and richest in a bright blue — a dark blue has no tonality left + // to deepen. + float ccbBlue = clamp((rgb.b - rgb.r) * 2.0, 0.0, 1.0) * clamp((rgb.b - 0.5 * (rgb.r + rgb.g) + 0.05) * 3.0, 0.0, 1.0); + float ccbMask = ccb * ccbBlue * smoothstep(0.15, 0.60, ccChroma) * smoothstep(0.20, 0.70, t); + float deep = clamp(ccMask + ccbMask, 0.0, 1.0); + // Density = lightness down with the colour difference riding along, so hue is + // preserved and the colour cannot collapse toward black (same reason the tone + // curve above keeps chroma). A touch of chroma is given up as it deepens. + float l3 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + rgb = clamp(vec3(l3 * (1.0 - 0.28 * deep)) + (rgb - vec3(l3)) * (1.0 - 0.10 * deep), 0.0, 1.0); + // Vibrance: push the LESS-saturated pixels harder than the vivid ones. + float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + float mx = max(max(rgb.r, rgb.g), rgb.b); + float mn = min(min(rgb.r, rgb.g), rgb.b); + float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0; + float kv = 1.0 + vib * 0.75 * (1.0 - chroma); + return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a); +} +`; + +// Bright Pass Filter for the HDF EFFECT pass (HDF), SkSL over a child image +// shader — the pattern TONE_SKSL above already proved on device. +// +// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark +// *itself*: a saturated blue (B = 1.0) came out of it fully lit, so a dark blue +// shadow bloomed and a dark saturated colour smeared its hue into the darks. +// Photoshop's Bright Pass filters on the LUMINANCE instead: one knee decides how +// much light a pixel carries, and one gain scales all three channels, so below +// the knee the output is exactly 0.0 (Screen against black = no-op, the shadows +// are untouched) and above it every channel keeps its ratio — the hue cannot +// drift, only the brightness blooms. +// +// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 = +// 0), wide enough that a specular ramps in instead of clipping on/off. +export const GLOW_T0 = 0.55; +export const GLOW_T1 = 0.85; + +export const GLOW_SKSL = ` +uniform shader src; +uniform float t0; +uniform float t1; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722)); + return vec4(c.rgb * smoothstep(t0, t1, luma), c.a); +} +`; + +// Flat uniform buffer for `makeShaderWithChildren` — same order as GLOW_SKSL's +// declarations (t0, t1). +export function glowUniformArray(): number[] { + 'worklet'; + return [GLOW_T0, GLOW_T1]; +} + +// Same values for the declarative path, which indexes uniforms by +// NAME (a flat array is only valid for the JS makeShaderWithChildren API). +export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 }; + +// CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4 +// builds with MakeMatrixConvolution, re-expressed as a plain shader because RN +// Skia 2.6 exposes no convolution image filter to the declarative JSX writer. +// `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so +// the preview, the camera worklet and the file all sharpen at the same radius. +export const CLARITY_SKSL = ` +uniform shader src; +uniform float a; +uniform float2 px; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + vec4 s = src.eval(xy + float2(0.0, -px.y)) + + src.eval(xy + float2(0.0, px.y)) + + src.eval(xy + float2(-px.x, 0.0)) + + src.eval(xy + float2( px.x, 0.0)); + return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a); +} +`; + +// Named uniforms for , same names as CLARITY_SKSL declares. +export function clarityUniforms(a: number, pxX: number, pxY: number) { + return { a, px: [pxX, pxY] }; +} + +export interface ToneUniforms { + // All zero → no tone adjustment needed (caller can skip the shader pass). + dr: number; // 0..1 + hl: number; // -1..1 (adjustments.highlight / 10) + sh: number; // -1..1 (adjustments.shadow / 10) + vib: number; // -1..1 (adjustments.vibrance / 10) + shT: [number, number, number]; // shadow split-tone RGB bias, -1..1 + hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1 + cc: number; // 0..1 Color Chrome depth (0 = 'none') + ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none') +} + +// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix: +// Classic Neg splits its tone (green-cyan darks / warm brights) and Classic +// Chrome crushes the shadows hard while muting colour. Those two parts live +// here instead of in the 4x5 matrix, which cannot move one end of the curve +// without also moving the other. +const FILM_TONE: Partial>> = { + 'classic-chrome': { sh: -0.28 }, + 'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] }, + // Acros. A black-and-white stock IS its grey ramp, so this entry only shapes + // the two ENDS and leaves the middle an identity: a smooth shadow toe that + // reaches a true black (no film-base lift, no flat grey wash) and a highlight + // shoulder that stops just short of white instead of clipping a cloud to + // paper. Mid-tones see neither mask, so the ramp keeps every step the matrix + // handed over — which is what 'deep black' costs in a colour stock and does + // not have to cost here. + // Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at + // ~5% and -0.05 trims the top ~1%. + monochrome: { sh: -0.12, hl: -0.05 }, +}; + +export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms { + const drRaw = adj.dynamicRange ?? 'auto'; + const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300; + const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10)); + const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10)); + const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10)); + const film = (baseFilter && FILM_TONE[baseFilter]) || {}; + const shT: [number, number, number] = film.shT ?? [0, 0, 0]; + const hlT: [number, number, number] = film.hlT ?? [0, 0, 0]; + // Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a + // monochrome look, so both are forced off there: the effect is colour-only + // (the preview/export matrix skips them for monochrome for the same reason). + const colour = baseFilter !== 'monochrome'; + const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) => + !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45; + const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) => + !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5; + return { + dr, + hl: hl + (film.hl ?? 0), + sh: sh + (film.sh ?? 0), + vib, + shT, + hlT, + cc: chromeDepth(adj.colorChrome), + ccb: blueDepth(adj.colorChromeBlue), + }; +} + +// Flat uniform buffer for `makeShaderWithChildren` / `` — the +// order must match TONE_SKSL's declarations. +export function toneUniformArray(u: ToneUniforms): number[] { + return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb]; +} + +export function toneIsActive(u: ToneUniforms): boolean { + return ( + u.dr !== 0 || + u.hl !== 0 || + u.sh !== 0 || + u.vib !== 0 || + u.shT[0] !== 0 || + u.shT[1] !== 0 || + u.shT[2] !== 0 || + u.hlT[0] !== 0 || + u.hlT[1] !== 0 || + u.hlT[2] !== 0 || + u.cc !== 0 || + u.ccb !== 0 + ); +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx new file mode 100644 index 0000000..391b08c --- /dev/null +++ b/docker/frontend/src/App.tsx @@ -0,0 +1,547 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ToolRail, type TabId } from './ui/ToolRail'; +import { ChipRow, Section, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; +import { ImageStage } from './ui/ImageStage'; +import { TopBar } from './ui/TopBar'; +import { AuthModal } from './ui/AuthModal'; +import { useI18n } from './i18n/I18nProvider'; +import { loadEngine } from './engine/assets'; +import { renderPhoto, type RenderAssets } from './engine/exportEngine'; +import { download, fileToBytes, readGps, resizedJpeg } from './engine/imageOps'; +import { api, type SavedRecipe, type User } from './api'; +import { TIERS } from './config/tiers'; +import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; +import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; +import { writeJpegExif } from '../shared/utils/exifWrite'; +import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types'; +import type { MsgKey } from './i18n/vi'; + +// Preview renders on a downscaled copy; export always re-renders the original +// bytes, so nothing shown here is baked into the file. +const PREVIEW_MAX = 1600; +const PREVIEW_DEBOUNCE_MS = 120; + +const SECTION_TITLE: Record = { + presets: 'sec.filmSims', + favorited: 'sec.saved', + light: 'tab.light', + wb: 'tab.wb', + fx: 'tab.fx', + frame: 'sec.frame', +}; + +const FRAMES: { id: FrameId; label: MsgKey }[] = [ + { id: 'none', label: 'frame.none' }, + { id: 'polaroid', label: 'frame.polaroid' }, + { id: 'classic-white', label: 'frame.classic' }, + { id: 'cinematic', label: 'frame.cinematic' }, + { id: 'wallframe', label: 'frame.wall' }, +]; + +// ponytail: nothing here is a rectangle the user drags yet, so only the fixed +// ratios are listed — 'free' (DEFAULT_CROP_RECT + drag on the canvas) lands +// with the crop overlay in W3. The type already carries it. +const CROPS: Exclude[] = ['none', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; +const ROTATIONS = [0, 90, 180, 270] as const; +const TEMP_PRESETS: { label: string; kelvin: number }[] = [ + { label: 'AUTO', kelvin: 5500 }, + { label: 'DAYLIGHT', kelvin: 5600 }, + { label: 'DAYLIGHT -3R', kelvin: 5800 }, + { label: 'CLOUDY', kelvin: 6500 }, + { label: 'SHADE', kelvin: 7500 }, + { label: 'TUNGSTEN', kelvin: 3200 }, + { label: 'FLUOR', kelvin: 4000 }, +]; + +interface LoadedSource { + bytes: Uint8Array; + name: string; + url: string; +} + +export function Workspace() { + const { t } = useI18n(); + + const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + + const [source, setSource] = useState(null); + const [previewBytes, setPreviewBytes] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [rendered, setRendered] = useState<{ w: number; h: number; ms: number } | null>(null); + + const [recipe, setRecipe] = useState(() => ({ + ...DEFAULT_RECIPES[0], + id: 'web-current', + name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA', + })); + const [simId, setSimId] = useState(null); + const [frameId, setFrameId] = useState('none'); + const [crop, setCrop] = useState>('none'); + const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0); + const [openParam, setOpenParam] = useState(null); + const [tab, setTab] = useState('presets'); + const [peek, setPeek] = useState(false); + + const [gps, setGps] = useState(null); + const [gpsManual, setGpsManual] = useState({ lat: '', lng: '' }); + const [place, setPlace] = useState(''); + const [useGeotag, setUseGeotag] = useState(false); + const [markText, setMarkText] = useState(''); + const [markOn, setMarkOn] = useState(false); + + const [undo, setUndo] = useState([]); + const [exporting, setExporting] = useState(false); + const [user, setUser] = useState(null); + const [saved, setSaved] = useState([]); + const [authOpen, setAuthOpen] = useState(false); + + const renderToken = useRef(0); + + // --- boot ----------------------------------------------------------------- + useEffect(() => { + let alive = true; + loadEngine() + .then((loaded) => { + if (!alive) return; + setEngine(loaded); + if (!loaded.gpu) setError(t('err.noWebgl')); + }) + .catch((err) => alive && setError(String(err))); + api + .me() + .then((r) => alive && setUser(r.user)) + .catch(() => undefined); // signed out is a valid state — the demo needs no account + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!user) { + setSaved([]); + return; + } + api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([])); + }, [user]); + + // --- preview render ------------------------------------------------------- + useEffect(() => { + if (!engine || !previewBytes) return; + const token = ++renderToken.current; + const timer = setTimeout(async () => { + setBusy(t('act.exporting')); + const started = performance.now(); + try { + const out = await renderPhoto({ + source: previewBytes, + recipe, + frameId, + useGeotag, + gpsInfo: gps, + assets: engine.assets, + options: { + sharpen: true, + photoRotation: rotation, + cropWH: crop === 'none' ? undefined : CROP_W_H[crop], + watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 } : null, + gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null, + }, + }); + if (token !== renderToken.current) return; + if (!out) throw new Error('render returned null'); + const url = URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' })); + setPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return url; + }); + setRendered({ w: out.width, h: out.height, ms: Math.round(performance.now() - started) }); + } catch (err) { + if (token === renderToken.current) setError(t('err.generic', { msg: String(err) })); + } finally { + if (token === renderToken.current) setBusy(null); + } + }, PREVIEW_DEBOUNCE_MS); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, markOn, markText]); + + // --- input --------------------------------------------------------------- + const loadFile = useCallback( + async (file: File) => { + setError(null); + setBusy(t('auth.busy')); + try { + const bytes = await fileToBytes(file); + const preview = await resizedJpeg(bytes, PREVIEW_MAX); + const found = await readGps(bytes); + setSource((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return { bytes, name: file.name, url: URL.createObjectURL(file) }; + }); + setPreviewBytes(preview); + setGps(found); + setUseGeotag(!!found); + if (!found) setGpsManual({ lat: '', lng: '' }); + } catch { + setError(t('err.decode')); + } finally { + setBusy(null); + } + }, + [t] + ); + + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + const file = e.clipboardData?.files?.[0]; + if (file) void loadFile(file); + }; + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + }, [loadFile]); + + // --- recipe edits --------------------------------------------------------- + const remember = useCallback(() => setUndo((stack) => [...stack.slice(-19), recipe]), [recipe]); + + const applyRecipe = useCallback( + (next: Recipe) => { + remember(); + setRecipe(next); + setSimId(next.isCustom ? null : next.id); + }, + [remember] + ); + + const setAdjustment = useCallback( + (patch: Partial) => { + setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } })); + }, + [] + ); + + const reset = useCallback(() => { + remember(); + setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...DEFAULT_ADJUSTMENTS }, baseFilter: 'none', cinema: null })); + setSimId(null); + setFrameId('none'); + setCrop('none'); + setRotation(0); + setMarkOn(false); + setOpenParam(null); + }, [remember]); + + const undoLast = useCallback(() => { + setUndo((stack) => { + const last = stack[stack.length - 1]; + if (last) setRecipe(last); + return stack.slice(0, -1); + }); + }, []); + + const saveRecipe = useCallback(async () => { + if (!user) { + setAuthOpen(true); + return; + } + const name = window.prompt(t('save.name'), recipe.name); + if (!name) return; + try { + const created = await api.createRecipe(name, { ...recipe, name }); + setSaved((list) => [created.recipe, ...list]); + setRecipe((prev) => ({ ...prev, name })); + } catch (err) { + setError(t('err.generic', { msg: String(err) })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recipe, user, t]); + + const exportPhoto = useCallback(async () => { + if (!engine || !source) return; + setExporting(true); + setError(null); + try { + const tier = TIERS[user ? 'user' : 'guest']; + const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes; + const out = await renderPhoto({ + source: src, + recipe, + frameId, + useGeotag, + gpsInfo: gps, + assets: engine.assets, + options: { + dpi: 300, + sharpen: true, + photoRotation: rotation, + cropWH: crop === 'none' ? undefined : CROP_W_H[crop], + watermark: markOn && markText + ? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 } + : tier.watermark + ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } + : null, + gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null, + }, + }); + if (!out) throw new Error('render returned null'); + + // Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same + // order the app uses. The source photo donates its camera/lens tags. + const bytes = writeJpegExif( + out.bytes, + { + dateTime: new Date(), + gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null, + width: out.width, + height: out.height, + dpi: 300, + locationName: place || gps?.locality || null, + }, + source.bytes + ); + const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); + download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`); + } catch (err) { + setError(t('err.generic', { msg: String(err) })); + } finally { + setExporting(false); + } + }, [engine, source, recipe, frameId, useGeotag, gps, rotation, crop, markOn, markText, place, user, t]); + + // --- chips ---------------------------------------------------------------- + const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => + defs.map((def) => ({ + key: def.key, + label: def.label, + value: def.display(def.get(recipe.adjustments)), + active: openParam === def.key, + pro: def.pro && !user, + disabled: !!def.pro && !user, + onClick: () => setOpenParam(openParam === def.key ? null : def.key), + })); + + const slider: SliderDef | null = useMemo(() => { + if (!openParam) return null; + const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters]; + const def = all.find((d) => d.key === openParam); + if (!def) return null; + return { + key: def.key, + label: def.label, + value: def.get(recipe.adjustments), + min: def.min, + max: def.max, + step: def.step, + display: def.display(def.get(recipe.adjustments)), + onChange: (value) => setAdjustment(def.set(value)), + onClose: () => setOpenParam(null), + }; + }, [openParam, recipe.adjustments, setAdjustment]); + + const chips: ChipDef[] = useMemo(() => { + if (tab === 'presets') { + return [ + ...FILM_SIMS.map((sim) => ({ + key: sim.id, + label: sim.name, + active: simId === sim.id, + onClick: () => applyRecipe(filmSimToRecipe(sim)), + })), + ...DEFAULT_RECIPES.slice(0, 6).map((preset) => ({ + key: preset.id, + label: preset.name, + active: recipe.name === preset.name && !recipe.isCustom, + onClick: () => applyRecipe({ ...preset }), + })), + ]; + } + if (tab === 'favorited') { + return saved.map((entry) => ({ + key: String(entry.id), + label: entry.name, + active: false, + onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), + })); + } + if (tab === 'light') return paramChips(PARAM_DEFS.iq); + if (tab === 'wb') { + return [ + ...TEMP_PRESETS.map((preset) => ({ + key: preset.label, + label: preset.label, + active: Math.round(recipe.adjustments.temperature) === preset.kelvin, + onClick: () => setAdjustment({ temperature: preset.kelvin }), + })), + ...paramChips(PARAM_DEFS.wb), + ]; + } + if (tab === 'fx') return paramChips(PARAM_DEFS.filters); + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab, saved, simId, recipe, openParam, user, applyRecipe, setAdjustment]); + + const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready'); + + return ( +
+ 0} + onUndo={undoLast} + onReset={reset} + onExport={exportPhoto} + onSave={saveRecipe} + onAuth={() => setAuthOpen(true)} + onLogout={async () => { + await api.logout().catch(() => undefined); + setUser(null); + }} + /> + +
+ + +
+ {tab === 'frame' ? ( + <> +
+ ({ + key: frame.id, + label: t(frame.label), + active: frameId === frame.id, + onClick: () => { + remember(); + setFrameId(frame.id); + }, + }))} + /> +
+
+ ({ + key: ratio, + label: t(`crop.${ratio}` as 'crop.none'), + active: crop === ratio, + onClick: () => setCrop(ratio), + }))} + /> +
+
+ ({ + key: String(deg), + label: t(`rot.${deg}` as 'rot.0'), + active: rotation === deg, + onClick: () => setRotation(deg), + }))} + /> +
+
+ setMarkOn(!markOn), + }, + ]} + /> + {markOn ? ( + setMarkText(e.target.value)} + /> + ) : null} +
+
+ setUseGeotag(!useGeotag), + }, + ]} + /> + setPlace(e.target.value)} + /> +

{gps ? t('wm.hasExif') : t('wm.noExif')}

+ {!gps ? ( +
+ setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} + onBlur={() => { + const lat = Number(gpsManual.lat); + const lng = Number(gpsManual.lng); + if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { + setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' }); + setUseGeotag(true); + } + }} + /> + setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} + /> +
+ ) : null} +
+ + ) : ( + <> +
+ + {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} +
+ {slider ? : null} + + )} +
+ + void loadFile(file)} + /> +
+ + {authOpen ? ( + setAuthOpen(false)} + onDone={async () => { + setAuthOpen(false); + const me = await api.me().catch(() => null); + if (me) setUser(me.user); + }} + /> + ) : null} +
+ ); +} diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx new file mode 100644 index 0000000..214a6fa --- /dev/null +++ b/docker/frontend/src/Landing.tsx @@ -0,0 +1,84 @@ +import { useI18n, LANGS } from './i18n/I18nProvider'; +import { ACCENTS, useTheme } from './theme/ThemeProvider'; + +// Landing page. The store links are placeholders on purpose — the real URLs are +// still unknown (see WEB_PLAN.md, W6); they live in one place so filling them in +// later is a one-line change. +const STORE_LINKS = { + play: '#', + appstore: '#', +}; + +export function Landing() { + const { t, lang, setLang } = useI18n(); + const { mode, accent, setMode, setAccent } = useTheme(); + + return ( +
+
+ RecipesCam web + + + + {ACCENTS.map((entry) => ( + + ))} +
+ +
+

{t('land.title')}

+

{t('land.lede')}

+ + + +
+
+

{t('land.f1t')}

+

{t('land.f1d')}

+
+
+

{t('land.f2t')}

+

{t('land.f2d')}

+
+
+

{t('land.f3t')}

+

{t('land.f3d')}

+
+
+ + +
+
+ ); +} diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts new file mode 100644 index 0000000..b82b871 --- /dev/null +++ b/docker/frontend/src/api.ts @@ -0,0 +1,45 @@ +// Thin wrapper over the API container. Same origin in production (nginx proxies +// /api), the Vite dev server proxies it too — so no base URL, no CORS. +import type { Recipe } from '../shared/types'; + +export interface User { + id: number; + email: string; +} + +export interface SavedRecipe { + id: number; + name: string; + recipe: unknown; + createdAt: string; + updatedAt: string; +} + +async function call(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + credentials: 'same-origin', + headers: init?.body ? { 'content-type': 'application/json' } : undefined, + ...init, + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + const body = text ? JSON.parse(text) : {}; + if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); + return body as T; +} + +export const api = { + me: () => call<{ user: User }>('/auth/me'), + signup: (email: string, password: string) => + call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }), + login: (email: string, password: string) => + call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }), + logout: () => call('/auth/logout', { method: 'POST' }), + + listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'), + createRecipe: (name: string, recipe: Recipe) => + call<{ recipe: SavedRecipe }>('/recipes', { method: 'POST', body: JSON.stringify({ name, recipe }) }), + updateRecipe: (id: number, name: string, recipe: Recipe) => + call<{ recipe: SavedRecipe }>(`/recipes/${id}`, { method: 'PUT', body: JSON.stringify({ name, recipe }) }), + deleteRecipe: (id: number) => call(`/recipes/${id}`, { method: 'DELETE' }), +}; diff --git a/docker/frontend/src/config/tiers.ts b/docker/frontend/src/config/tiers.ts new file mode 100644 index 0000000..a5caf58 --- /dev/null +++ b/docker/frontend/src/config/tiers.ts @@ -0,0 +1,19 @@ +// One table for the guest/user split, so the policy can change without touching +// the UI. Guest = the landing + a capped, watermarked demo; user = full tools, +// full-resolution export, saved recipes. + +export type TierId = 'guest' | 'user'; + +export interface Tier { + id: TierId; + /** Longest edge of an exported photo, or null for the original size. */ + maxExportDim: number | null; + /** Text burned into every export, or null for a clean file. */ + watermark: string | null; + batchExport: boolean; +} + +export const TIERS: Record = { + guest: { id: 'guest', maxExportDim: 2048, watermark: 'RecipesCam demo', batchExport: false }, + user: { id: 'user', maxExportDim: null, watermark: null, batchExport: true }, +}; diff --git a/docker/frontend/src/engine/assets.ts b/docker/frontend/src/engine/assets.ts new file mode 100644 index 0000000..5a8a50d --- /dev/null +++ b/docker/frontend/src/engine/assets.ts @@ -0,0 +1,33 @@ +import { initSkia, hasGpu } from './skiaShim'; +import type { RenderAssets } from './exportEngine'; + +// One CanvasKit instance per page, one copy of the three render assets. The wasm +// file is served from /wasm/ (scripts/copy-wasm.mjs puts it there at build time); +// the fonts and the wall-frame artwork are in public/assets. +let assets: RenderAssets | null = null; + +async function buf(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`); + return res.arrayBuffer(); +} + +// The engine is only usable on a WebGL-backed surface (see skiaShim.ts), so the +// app has to know whether it got one: reporting "no WebGL" beats silently +// spending minutes per photo on the raster fallback. +export function probeGpu(): boolean { + return hasGpu(); +} + +export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> { + await initSkia({ locateFile: (file) => `/wasm/${file}` }); + if (!assets) { + const [textFont, emojiFont, wallframePng] = await Promise.all([ + buf('/assets/Cousine-Regular.ttf'), + buf('/assets/NotoEmoji-GPS.ttf'), + buf('/assets/wallframe.png'), + ]); + assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng) }; + } + return { assets, gpu: probeGpu() }; +} diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts new file mode 100644 index 0000000..f1ea761 --- /dev/null +++ b/docker/frontend/src/engine/exportEngine.ts @@ -0,0 +1,690 @@ +// Full-resolution render pipeline for the web build. +// +// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit, +// step for step, so the file a browser downloads matches the file the phone +// writes. What changed and why (W0 probe, measured — see skiaShim.ts header): +// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes +// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is +// gone (the browser downloads a Blob instead). +// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution +// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from +// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated +// as a shader pass. `convolve` replaces the pixels outright (the shader's +// output is opaque and covers the same rect), so no clearing pass is needed. +// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot +// be composited over the un-blurred content, so those passes snapshot, clear +// and redraw. Order matches RN's nested filter chain: denoise → clarity → +// mist → soften. +// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)` +// alone keeps the print size metadata. +// ponytail: EXIF says "W4 if the web export needs the camera/lens tags". + +import { Skia } from './skiaShim'; +import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types'; +import { ASPECT_RATIO_W_H } from '../../shared/types'; +import { getSkiaColorMatrix, applyExposureGain } from '../../shared/utils/colorUtils'; +import { + TONE_SKSL, + GLOW_SKSL, + CLARITY_SKSL, + getToneUniforms, + toneIsActive, + toneUniformArray, + glowUniformArray, +} from '../../shared/utils/toneShader'; +import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader'; +import { + drawFrameOnCanvas, + polaroidLayout, + POLAROID_CARD, + POLAROID_WIN_W, + wallframeLayout, + WALLFRAME_W, + WALLFRAME_H, + WALLFRAME_LAND_W, + WALLFRAME_LAND_H, +} from '../../shared/utils/frameUtils'; +import { patchJpegDpi } from '../../shared/utils/jpegDpi'; +import { applyPhotoRotation } from '../../shared/utils/skiaImage'; + +export interface RenderAssets { + textFont: ArrayBuffer; // assets/Cousine-Regular.ttf + emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf + wallframe: Uint8Array; // wallframe.png +} + +export interface RenderOptions { + dpi?: number; + sharpen?: boolean; + evFromCamera?: boolean; + aspect?: AspectRatio; + cropWH?: number; + cropRect?: CropRect | null; + watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null; + liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null; + watermarkRotation?: number; + gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null; + wallframeLandscape?: boolean; + frameWindowZoom?: { s: number; u: number; v: number } | null; + photoRotation?: 0 | 90 | 180 | 270; + photoStraighten?: number; +} + +export interface RenderInput { + source: Uint8Array; + recipe: Recipe; + frameId: FrameId; + useGeotag?: boolean; + gpsInfo?: GPSInfo | null; + options?: RenderOptions; + assets: RenderAssets; +} + +export interface RenderResult { + bytes: Uint8Array; + width: number; + height: number; +} + +type SkDisposable = { dispose(): void }; + +function disposeAll(items: SkDisposable[]): void { + for (let i = items.length - 1; i >= 0; i--) { + try { + items[i].dispose(); + } catch { + /* already released */ + } + } +} + +function release(owned: SkDisposable[], item: SkDisposable | null): void { + if (!item) return; + const i = owned.indexOf(item); + if (i >= 0) owned.splice(i, 1); + disposeAll([item]); +} + +function createSurface(width: number, height: number) { + return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height); +} + +// A WebGL surface batches draws; the readback paths (snapshot, encode) must see +// them submitted first. A no-op on the raster fallback. +function flush(surface: any): void { + if (typeof surface?.flush === 'function') surface.flush(); +} + +// --- shared runtime effects ------------------------------------------------- + +let sharpenEffect: any = null; +let toneEffect: any = null; +let cinemaEffect: any = null; +let glowEffect: any = null; + +function effects() { + if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL); + if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL); + if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL); + if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL); + return { sharpenEffect, toneEffect, cinemaEffect, glowEffect }; +} + +// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on +// a 1:1 export canvas, so the radius matches what the preview tuned. +function convolvePaint(srcImage: any, amount: number): any { + const { sharpenEffect: effect } = effects(); + if (!effect) return null; + const paint = Skia.Paint(); + const child = srcImage.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None + ); + const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]); + if (!shader) { + paint.dispose(); + child.dispose(); + return null; + } + paint.setShader(shader); + return { paint, shader, child }; +} + +// The convolution must sample the ALREADY GRADED pixels (RN runs it as the +// outermost filter of the draw), so the pass reads a snapshot of the surface +// and writes the sharpened result back over the same rect. +function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void { + flush(surface); + const snap = surface.makeImageSnapshot(); + if (!snap) return; + drawConvolved(canvas, snap, w, h, amount); + snap.dispose(); +} + +// Replaces the pixels under the rect with the convolved result. +function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void { + const built = convolvePaint(srcImage, amount); + if (!built) return; + canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint); + disposeAll([built.paint, built.shader, built.child]); +} + +function convolveImage(image: any, amount: number): any { + const w = image.width(); + const h = image.height(); + const surf = createSurface(w, h); + if (!surf) return image; + try { + drawConvolved(surf.getCanvas(), image, w, h, amount); + return surf.makeImageSnapshot() ?? image; + } finally { + surf.dispose(); + } +} + +function screenSharpenImage(image: any, amount = 0.5): any { + return convolveImage(image, amount); +} + +// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp +// pixels it is meant to replace. +function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void { + flush(surface); + const snap = surface.makeImageSnapshot(); + const paint = Skia.Paint(); + const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null); + paint.setImageFilter(filter); + canvas.clear(Skia.Color('transparent')); + canvas.drawImage(snap, 0, 0, paint); + disposeAll([paint, filter, snap]); +} + +// --- fonts / artwork -------------------------------------------------------- + +let faces: { typeface: any; iconTypeface: any } | null = null; +let wallframeImage: any = null; + +function loadFaces(assets: RenderAssets) { + if (faces) return faces; + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont); + const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont); + if (!typeface || !iconTypeface) return null; + faces = { typeface, iconTypeface }; + // Named-family lookup for the custom watermark's FONT option: register the + // bundled face so at least that one name resolves on web. + try { + Skia.FontMgr.System().registerFace(typeface, 'Cousine'); + } catch { + /* provider optional */ + } + return faces; +} + +function loadWallframe(assets: RenderAssets) { + if (wallframeImage) return wallframeImage; + wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe); + return wallframeImage; +} + +export async function renderPhoto(input: RenderInput): Promise { + const { recipe, frameId, options } = input; + const owned: SkDisposable[] = []; + const own = (obj: T | null | undefined): T => { + if (obj) owned.push(obj as unknown as SkDisposable); + return obj as T; + }; + try { + const skImage0 = Skia.Image.MakeImageFromEncoded(input.source); + if (!skImage0) { + console.error('Failed to parse source image'); + return null; + } + own(skImage0); + let skImage = skImage0; + + const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten); + if (rotated && rotated !== skImage) { + own(rotated); + release(owned, skImage); + skImage = rotated; + } + let width = skImage.width(); + let height = skImage.height(); + + // 1b. Composition / FRAME-tab crop — identical maths to the RN engine. + const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null; + const cropRect = options?.cropRect ?? null; + const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH; + if ((cropRect || ratioWH) && frameId !== 'wallframe') { + const curAspect = width / height; + let cropW = width; + let cropH = height; + let srcX = 0; + let srcY = 0; + if (cropRect) { + srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width))); + srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height))); + cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width))); + cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height))); + } else if (curAspect > (ratioWH as number)) { + cropW = Math.round(height * (ratioWH as number)); + srcX = Math.round((width - cropW) / 2); + } else { + cropH = Math.round(width / (ratioWH as number)); + srcY = Math.round((height - cropH) / 2); + } + const cropSurface = createSurface(cropW, cropH); + if (!cropSurface) return null; + own(cropSurface); + cropSurface.getCanvas().drawImageRect( + skImage, + Skia.XYWHRect(srcX, srcY, cropW, cropH), + Skia.XYWHRect(0, 0, cropW, cropH), + own(Skia.Paint()) + ); + const cropped = cropSurface.makeImageSnapshot(); + if (!cropped) return null; + own(cropped); + release(owned, skImage); + release(owned, cropSurface); + skImage = cropped; + width = cropW; + height = cropH; + } + + // 2. Render surface. + const surface = createSurface(width, height); + if (!surface) return null; + own(surface); + const canvas = surface.getCanvas(); + const paint = own(Skia.Paint()); + + const adjustments = recipe.adjustments; + + // 3. Colour matrix (+2^EV gain unless the camera already biased the shot). + const evStops = adjustments.exposureCompensation ?? 0; + const evMatrix = options?.evFromCamera + ? getSkiaColorMatrix(recipe.baseFilter, adjustments) + : applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops); + const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix)); + paint.setColorFilter(colorFilter); + + // 3b. Tone shader. + const tone = getToneUniforms(adjustments, recipe.baseFilter); + let toneShader: any = null; + if (toneIsActive(tone)) { + const { toneEffect: effect } = effects(); + if (effect) { + const imageShader = own( + skImage.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None + ) + ); + toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader])); + if (toneShader) paint.setShader(toneShader); + } + } + + // 3c. Cinema seasonal grade (cinema → tone → image). + const cinema = getCinemaUniforms(recipe.cinema); + let paintShader = toneShader; + if (cinema && cinemaIsActive(cinema.flat)) { + const { cinemaEffect: effect } = effects(); + if (effect) { + const child = + toneShader ?? + own(skImage.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None + )); + const cs = own(effect.makeShaderWithChildren(cinema.flat, [child])); + if (cs) { + paintShader = cs; + paint.setShader(paintShader); + } + } + } + + // 4. Denoise (blur only — CLARITY moved to its own pass below, see header). + if (adjustments.denoise > 0) { + const sigma = (adjustments.denoise / 10) * 0.6; + paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null))); + } + + // 5. Draw the graded photo. + if (paintShader) { + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint); + } else { + canvas.drawImage(skImage, 0, 0, paint); + } + + // 4b. CLARITY (positive) — convolution outside the denoise blur, the same + // nesting RN builds with MakeMatrixConvolution(input = previous filter). + if (adjustments.clarity > 0) { + convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8); + } else if (adjustments.clarity < 0) { + const mistSigma = Math.abs(adjustments.clarity / 10) * 4; + drawBlurred(canvas, surface, width, height, mistSigma); + } + // Negative SHARPENING takes edge enhancement back out — after the conv, so + // it cannot blur away what the CLARITY pass just added. + const sharpKnob = adjustments.sharpening ?? 0; + if (sharpKnob < 0) { + drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3); + } + + // 5b. HDF bright-pass bloom. + if ((adjustments.hdf ?? 0) > 0) { + const strength = (adjustments.hdf ?? 0) / 10; + const sigma = width * (0.004 + 0.015 * strength); + const { glowEffect: effect } = effects(); + const srcShader = + paintShader ?? + own(skImage.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None + )); + const glowShader = + effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null; + const glowPaint = own(Skia.Paint()); + glowPaint.setBlendMode(Skia.BlendMode.Screen); + glowPaint.setAlphaf(0.15 + 0.35 * strength); + if (glowShader != null) { + glowPaint.setShader(glowShader); + glowPaint.setImageFilter( + own(Skia.ImageFilter.MakeCompose( + own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)), + own(Skia.ImageFilter.MakeColorFilter(colorFilter, null)) + )) + ); + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint); + } else { + const hiMatrix = [ + 2.5, 0, 0, 0, -1.5, + 0, 2.5, 0, 0, -1.5, + 0, 0, 2.5, 0, -1.5, + 0, 0, 0, 1, 0, + ]; + glowPaint.setImageFilter( + own(Skia.ImageFilter.MakeCompose( + own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)), + own(Skia.ImageFilter.MakeColorFilter( + own(Skia.ColorFilter.MakeCompose( + own(Skia.ColorFilter.MakeMatrix(hiMatrix)), + colorFilter + )), + null + )) + )) + ); + if (paintShader) { + glowPaint.setShader(paintShader); + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint); + } else { + canvas.drawImage(skImage, 0, 0, glowPaint); + } + } + } + + // 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN). + const grainAmount = Math.min( + 10, + Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0)) + ); + if (grainAmount > 0) { + const grainPaint = own(Skia.Paint()); + grainPaint.setBlendMode(Skia.BlendMode.Overlay); + grainPaint.setAlphaf(grainAmount / 20); + const noiseEffect = own(Skia.RuntimeEffect.Make(` + uniform float u; + float grainHash(vec2 q) { + return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); + } + vec4 main(vec2 pos) { + vec2 p = pos.xy / max(u, 1.0); + vec2 i = floor(p); + vec2 f = p - i; + f = f * f * (3.0 - 2.0 * f); + float g00 = grainHash(i); + float g10 = grainHash(i + vec2(1.0, 0.0)); + float g01 = grainHash(i + vec2(0.0, 1.0)); + float g11 = grainHash(i + vec2(1.0, 1.0)); + return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0); + } + `)); + const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null; + if (noiseShader) { + grainPaint.setShader(noiseShader); + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint); + } + } + + // 6b. Vignette. + if ((adjustments.vignette ?? 0) > 0) { + const v = Math.min(10, adjustments.vignette ?? 0) / 10; + const vignetteShader = own(Skia.Shader.MakeRadialGradient( + Skia.Point(width / 2, height / 2), + Math.hypot(width, height) / 2, + [Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')], + [0, 0.45, 1], + Skia.TileMode.Clamp + )); + if (vignetteShader) { + const vignettePaint = own(Skia.Paint()); + vignettePaint.setShader(vignetteShader); + vignettePaint.setAlphaf(0.9 * v); + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint); + } + } + + release(owned, skImage); + + // 7. Frame. + let outputSurface = surface; + let stampCanvas = canvas; + let stampW = width; + let stampH = height; + let cardSource: any = null; + if (frameId === 'polaroid') { + flush(surface); + const photoImg = own(surface.makeImageSnapshot()); + cardSource = photoImg; + const outW = width; + const frameAspect = + ratioWH ?? + (options?.photoRotation === 90 || options?.photoRotation === 270 + ? height / width + : width / height); + const outH = Math.round( + width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + + (width * POLAROID_WIN_W) / frameAspect + ); + const cardSurface = createSurface(outW, outH); + if (!cardSurface) return null; + own(cardSurface); + const cardCanvas = cardSurface.getCanvas(); + const bgPaint = own(Skia.Paint()); + bgPaint.setColor(Skia.Color('#faf9f6')); + cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint); + const pl = polaroidLayout(outW, outH, frameAspect, 'contain'); + const win = pl.window; + const plainPaint = own(Skia.Paint()); + const pz = options?.frameWindowZoom ?? null; + const pwz = pz && pz.s > 1.001 ? pz : null; + const pVisW = pwz ? width / pwz.s : width; + const pVisH = pwz ? height / pwz.s : height; + const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0; + const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0; + const srcAspect = width / height; + let dstW = win.w; + let dstH = dstW / srcAspect; + if (dstH > win.h) { + dstH = win.h; + dstW = dstH * srcAspect; + } + cardCanvas.drawImageRect( + photoImg, + Skia.XYWHRect(pSx, pSy, pVisW, pVisH), + Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH), + plainPaint + ); + const seamPaint = own(Skia.Paint()); + seamPaint.setColor(Skia.Color('#e5e5e5')); + seamPaint.setStyle(1); + seamPaint.setStrokeWidth(1); + cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint); + outputSurface = cardSurface; + stampCanvas = cardCanvas; + stampW = outW; + stampH = outH; + } else if (frameId === 'wallframe') { + flush(surface); + const photoImg = own(surface.makeImageSnapshot()); + cardSource = photoImg; + const pw = photoImg.width(); + const ph = photoImg.height(); + const wallLand = options?.wallframeLandscape === true; + const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W; + const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H; + const cardSurface = createSurface(outW, outH); + if (!cardSurface) return null; + own(cardSurface); + const cardCanvas = cardSurface.getCanvas(); + const wfImage = loadWallframe(input.assets); + if (!wfImage) { + console.error('Failed to decode wallframe.png'); + return null; + } + const wl = wallframeLayout(outW, outH, 'fill', wallLand); + const win = wl.window; + const plainPaint = own(Skia.Paint()); + const scale = Math.max(win.w / pw, win.h / ph); + const srcW = win.w / scale; + const srcH = win.h / scale; + const planeX = (pw - srcW) / 2; + const planeY = (ph - srcH) / 2; + const wz = options?.frameWindowZoom ?? null; + const wZoom = wz && wz.s > 1.001 ? wz : null; + const wVisW = wZoom ? srcW / wZoom.s : srcW; + const wVisH = wZoom ? srcH / wZoom.s : srcH; + const wSx = wZoom + ? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW) + : planeX; + const wSy = wZoom + ? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH) + : planeY; + cardCanvas.drawImageRect( + photoImg, + Skia.XYWHRect(wSx, wSy, wVisW, wVisH), + Skia.XYWHRect(win.x, win.y, win.w, win.h), + plainPaint + ); + cardCanvas.save(); + if (wallLand) { + cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + } else { + cardCanvas.translate(outW, 0); + cardCanvas.rotate(90, 0, 0); + cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + } + cardCanvas.restore(); + outputSurface = cardSurface; + stampCanvas = cardCanvas; + stampW = outW; + stampH = outH; + } else { + drawFrameOnCanvas(canvas, width, height, frameId); + } + if (cardSource) { + release(owned, cardSource); + release(owned, surface); + } + + // 8. GPS + custom watermark. + const customWm = options?.watermark ?? null; + const liteWm = options?.liteMark ?? null; + if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) { + const loaded = loadFaces(input.assets); + if (loaded) { + const { typeface, iconTypeface } = loaded; + const fontSize = Math.round(stampW * 0.032); + const textPaint = own(Skia.Paint()); + textPaint.setColor(Skia.Color('#f59e0b')); + const iconPaint = own(Skia.Paint()); + iconPaint.setColor(Skia.Color('#ffffff')); + + if (input.useGeotag && input.gpsInfo) { + const locationName = input.gpsInfo.locality || 'STREET VIEW'; + const d = new Date(input.gpsInfo.timestamp); + const pad = (n: number) => String(n).padStart(2, '0'); + const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + const gpsPos = options?.gpsWatermark ?? null; + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1)); + const gpsFont = own(Skia.Font(typeface, gpsSize)); + const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize)); + const gpsGap = Math.round(gpsSize * 1.6); + const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW; + const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH; + const lines: { icon: string; text: string }[] = []; + if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName }); + if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr }); + textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b')); + const wmRot = options?.watermarkRotation ?? 0; + stampCanvas.save(); + if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY); + lines.forEach((line, i) => { + const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2)); + stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont); + stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont); + }); + stampCanvas.restore(); + } + + if (customWm || liteWm) { + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + const wmRot = options?.watermarkRotation ?? 0; + for (const mark of [customWm, liteWm]) { + if (!mark) continue; + const wmSize = Math.round(fontSize * (mark.size ?? 1)); + const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null; + const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize)); + const wmPaint = own(Skia.Paint()); + wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b')); + const tx = clamp01(mark.x) * stampW; + const ty = clamp01(mark.y) * stampH + Math.round(wmSize * 0.35); + stampCanvas.save(); + if (wmRot) stampCanvas.rotate(wmRot, tx, ty); + stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont); + stampCanvas.restore(); + } + } + } + } + + // 9. Snapshot, optional output sharpen, JPEG encode + DPI patch. + flush(outputSurface); + let resultImage = own(outputSurface.makeImageSnapshot()); + const sharpenAmount = + sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0; + if (sharpenAmount > 0) { + const sharpened = screenSharpenImage(resultImage, sharpenAmount); + if (sharpened && sharpened !== resultImage) { + own(sharpened); + release(owned, resultImage); + resultImage = sharpened; + } + } + let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95); + if (!bytes || bytes.length === 0) { + console.error('Failed to encode image to JPEG'); + return null; + } + if (options?.dpi && options.dpi > 0) { + bytes = patchJpegDpi(bytes, options.dpi); + } + return { bytes, width: resultImage.width(), height: resultImage.height() }; + } catch (error) { + console.error('Render failed:', error); + return null; + } finally { + disposeAll(owned); + } +} diff --git a/docker/frontend/src/engine/imageOps.ts b/docker/frontend/src/engine/imageOps.ts new file mode 100644 index 0000000..13b8b9f --- /dev/null +++ b/docker/frontend/src/engine/imageOps.ts @@ -0,0 +1,58 @@ +import exifr from 'exifr'; +import type { GPSInfo } from '../../shared/types'; + +// Browser-side input handling: turn a dropped File into the source bytes the +// engine wants, plus the two derived things the UI needs — a smaller copy for +// the live preview, and whatever GPS the photo already carries. + +export async function fileToBytes(file: File | Blob): Promise { + return new Uint8Array(await file.arrayBuffer()); +} + +// Preview renders run on a downscaled copy: the grade is pixel-wise, so grading +// a 1600px copy shows the same look, and it keeps the slider responsive. Export +// always runs on the original bytes. +export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0.92): Promise { + const bitmap = await createImageBitmap(new Blob([bytes as BlobPart])); + try { + const longest = Math.max(bitmap.width, bitmap.height); + if (longest <= maxDim) return bytes; + const scale = maxDim / longest; + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext('2d'); + if (!ctx) return bytes; + ctx.drawImage(bitmap, 0, 0, w, h); + const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); + return new Uint8Array(await blob.arrayBuffer()); + } finally { + bitmap.close(); + } +} + +// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect. +// Returns null when the photo has none — the caller then offers manual entry. +export async function readGps(bytes: Uint8Array): Promise { + try { + const gps = await exifr.gps(bytes); + if (!gps || typeof gps.latitude !== 'number' || typeof gps.longitude !== 'number') return null; + return { + latitude: gps.latitude, + longitude: gps.longitude, + timestamp: Date.now(), + source: 'exif', + }; + } catch { + return null; + } +} + +export function download(bytes: Uint8Array, filename: string): void { + const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'image/jpeg' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} diff --git a/docker/frontend/src/engine/skiaShim.ts b/docker/frontend/src/engine/skiaShim.ts new file mode 100644 index 0000000..e95999f --- /dev/null +++ b/docker/frontend/src/engine/skiaShim.ts @@ -0,0 +1,213 @@ +// 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 → one shared GrDirectContext +// + `MakeRenderTarget` for GPU, raster `MakeSurface` as the no-GL fallback +// (see gpuSurface below for why the context must be shared); +// - `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; +// `frameUtils.ts` imports `BlendMode` as a value, so it has to be a live +// binding too — filled in once CanvasKit is up, long before any draw. +export let BlendMode: 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); +} + +// ONE GrDirectContext for every surface. `MakeWebGLCanvasSurface` makes its own +// WebGL context per call, and a texture from one context cannot be sampled by a +// surface on another: the snapshot of surface A drawn onto surface B came back +// solid black (measured — 12MP JPEG + output sharpen, preview and export alike, +// GL 0.0 vs raster 107.0 mean). Sharing the context fixes it. +let grContext: any = null; + +function ensureGrContext(): any { + if (grContext) return grContext; + if (typeof OffscreenCanvas === 'undefined') return null; + try { + grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8))); + } catch { + grContext = null; + } + return grContext; +} + +function gpuSurface(w: number, h: number) { + const ctx = ensureGrContext(); + if (!ctx) return null; + try { + return CK.MakeRenderTarget(ctx, w, h) ?? null; + } catch { + return null; // no GL context available — caller falls back to raster + } +} + +// Callers (the app's engine banner) ask the same context the renderer will use, +// so "GPU ok" cannot disagree with what the pipeline actually gets. +export function hasGpu(): boolean { + return !!ensureGrContext(); +} + +export async function initSkia(options: InitSkiaOptions = {}): Promise { + if (Skia) return Skia; + const canvasKit = await (CanvasKitInit as any)( + options.locateFile ? { locateFile: options.locateFile } : undefined + ); + CK = canvasKit; + BlendMode = canvasKit.BlendMode; + patchPrototypes(canvasKit); + + Skia = { + // --- geometry / color --------------------------------------------------- + XYWHRect: (x: number, y: number, w: number, h: number) => canvasKit.XYWHRect(x, y, w, h), + Point: (x: number, y: number) => [x, y], + Color: (value: string) => + value === 'transparent' + ? canvasKit.Color(0, 0, 0, 0) + : canvasKit.parseColorString(value), + Color4f: (r: number, g: number, b: number, a: number) => canvasKit.Color4f(r, g, b, a), + + // --- factories ---------------------------------------------------------- + Paint: () => new canvasKit.Paint(), + Font: (typeface: any, size: number) => new canvasKit.Font(typeface, size), + Data: { + // CanvasKit exposes no Data class; callers pass bytes straight through. + fromBytes: (bytes: Uint8Array) => bytes, + }, + Image: { + MakeImageFromEncoded: (data: Uint8Array) => + canvasKit.MakeImageFromEncoded( + data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer) + ), + }, + Typeface: { + MakeFreeTypeFaceFromData: (data: ArrayBuffer) => + canvasKit.Typeface.MakeFreeTypeFaceFromData(data), + }, + ColorFilter: { + MakeMatrix: (m: number[]) => canvasKit.ColorFilter.MakeMatrix(m), + MakeCompose: (outer: any, inner: any) => canvasKit.ColorFilter.MakeCompose(outer, inner), + }, + ImageFilter: { + MakeBlur: (sx: number, sy: number, tile: any, input: any) => + canvasKit.ImageFilter.MakeBlur(sx, sy, tile, input ?? null), + MakeCompose: (outer: any, inner: any) => + canvasKit.ImageFilter.MakeCompose(outer ?? null, inner ?? null), + MakeColorFilter: (cf: any, input: any) => + canvasKit.ImageFilter.MakeColorFilter(cf, input ?? null), + }, + Shader: { + MakeRadialGradient: ( + center: number[], radius: number, colors: any[], pos: number[], mode: any + ) => canvasKit.Shader.MakeRadialGradient(center, radius, colors, pos, mode), + }, + RuntimeEffect: { + Make: (sksl: string) => canvasKit.RuntimeEffect.Make(sksl), + }, + Surface: { + // A WebGL-backed surface is not a nicety here, it is the difference + // between usable and unusable: the same 12MP pipeline that finishes in + // well under a second on a GL surface took 3.2s for ONE tone pass and + // 13.7s for the CLARITY tap in the Node CPU raster benchmark. Raster is + // the fallback for a context-less environment (Node spikes, tests). + Make: (w: number, h: number) => rasterSurface(w, h), + MakeOffscreen: (w: number, h: number) => gpuSurface(w, h) ?? rasterSurface(w, h), + }, + FontMgr: { + System: () => fontMgr, + }, + + // --- enums -------------------------------------------------------------- + BlendMode: canvasKit.BlendMode, + TileMode: canvasKit.TileMode, + FilterMode: canvasKit.FilterMode, + MipmapMode: canvasKit.MipmapMode, + FontStyle: canvasKit.FontStyle, + ImageFormat: canvasKit.ImageFormat, + ColorType: canvasKit.ColorType, + ColorSpace: canvasKit.ColorSpace, + AlphaType: canvasKit.AlphaType, + PaintStyle: canvasKit.PaintStyle, + }; + + fontMgr = makeFontMgr(canvasKit); + return Skia; +} + +// Named-family lookup for the custom watermark's FONT option. Only faces the +// bundle ships can be matched on web; anything else falls back to the caller's +// default typeface — the honest behaviour when there is no OS font service. +let fontMgr: any = null; + +function makeFontMgr(canvasKit: any) { + const provider = canvasKit.TypefaceFontProvider.Make(); + return { + registerFace(typeface: any, familyName: string) { + provider.registerFont(typeface, familyName); + }, + matchFamilyStyle(family: string) { + if (!family) return null; + try { + return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null; + } catch { + return null; + } + }, + }; +} diff --git a/docker/frontend/src/i18n/I18nProvider.tsx b/docker/frontend/src/i18n/I18nProvider.tsx new file mode 100644 index 0000000..b093a07 --- /dev/null +++ b/docker/frontend/src/i18n/I18nProvider.tsx @@ -0,0 +1,64 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { vi, type Dict, type MsgKey } from './vi'; +import { en } from './en'; + +// Two dictionaries, no i18n library: the whole set is a few KB of text and the +// `Dict` type makes a missing English key a build error. Reach for a library +// (or lazy-loading) when the dictionaries outgrow the bundle — not before. + +export type Lang = 'vi' | 'en'; + +const DICTS: Record = { vi, en }; +const KEY = 'rc.lang'; + +function readLang(): Lang { + const stored = localStorage.getItem(KEY); + if (stored === 'vi' || stored === 'en') return stored; + return (navigator.language || '').toLowerCase().startsWith('vi') ? 'vi' : 'en'; +} + +interface I18nValue { + lang: Lang; + setLang: (lang: Lang) => void; + t: (key: MsgKey, params?: Record) => string; +} + +const Ctx = createContext(null); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState(readLang); + + const setLang = useCallback((next: Lang) => { + localStorage.setItem(KEY, next); + setLangState(next); + }, []); + + useEffect(() => { + document.documentElement.lang = lang; + }, [lang]); + + const value = useMemo(() => ({ + lang, + setLang, + t: (key, params) => { + let text: string = DICTS[lang][key] ?? vi[key] ?? key; + if (params) { + for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v)); + } + return text; + }, + }), [lang, setLang]); + + return {children}; +} + +export function useI18n(): I18nValue { + const value = useContext(Ctx); + if (!value) throw new Error('useI18n used outside I18nProvider'); + return value; +} + +export const LANGS: { id: Lang; label: string }[] = [ + { id: 'vi', label: vi['lang.name'] }, + { id: 'en', label: en['lang.name'] }, +]; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts new file mode 100644 index 0000000..f7a96b1 --- /dev/null +++ b/docker/frontend/src/i18n/en.ts @@ -0,0 +1,124 @@ +import type { Dict } from './vi'; + +export const en: Dict = { + 'lang.name': 'English', + + 'nav.demo': 'TRY DEMO', + 'nav.login': 'LOG IN', + 'nav.signup': 'SIGN UP', + 'nav.logout': 'Log out', + 'nav.account': 'Account', + 'nav.theme': 'Theme', + 'nav.language': 'Language', + 'nav.back': 'Back to home', + 'nav.guest': 'Guest', + + 'act.undo': 'UNDO', + 'act.reset': 'RESET', + 'act.export': 'EXPORT', + 'act.exporting': 'Exporting…', + 'act.cancel': 'Cancel', + + 'land.title': 'The same colour recipes, now on your desktop', + 'land.lede': + 'RecipesCam turns a photo into a film-looking one with recipes. The web build runs the app\u2019s exact colour pipeline: same recipes, same frames, same GPS stamp.', + 'land.ctaDemo': 'OPEN THE DEMO', + 'land.ctaSignup': 'CREATE AN ACCOUNT', + 'land.f1t': 'Film recipes', + 'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… plus EXPOSURE, WB, CLARITY, HDF and VIGNETTING.', + 'land.f2t': 'Frames & watermark', + 'land.f2d': 'Polaroid, classic border, WALL FRAME, custom text and a GPS stamp read from the photo\u2019s EXIF.', + 'land.f3t': 'Processed on your own machine', + 'land.f3d': 'The photo never leaves the browser. The server only keeps accounts and the recipes you save.', + 'land.badgePlay': 'Get it on Google Play', + 'land.badgeApp': 'Download on the App Store', + 'land.storeSoon': 'Coming soon', + + 'drop.title': 'Drag & drop a photo here', + 'drop.hint': 'or click to pick one · paste from the clipboard with Ctrl+V', + 'drop.replace': 'Choose another photo', + 'drop.peeking': 'SHOWING THE ORIGINAL', + 'drop.peekHint': 'Hold right-click or the \\ key to compare with the original', + 'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.', + + 'tab.presets': 'PRESETS', + 'tab.favorited': 'FAVORITED', + 'tab.light': 'LIGHT', + 'tab.wb': 'WB', + 'tab.fx': 'FX', + 'tab.frame': 'FRAME', + + 'sec.filmSims': 'PHOTO STYLE', + 'sec.recipes': 'RECIPES', + 'sec.saved': 'SAVED', + 'sec.savedEmpty': 'No saved recipes yet. Log in and hit SAVE to sync them.', + 'sec.frame': 'FRAME', + 'sec.crop': 'CROP', + 'sec.rotate': 'ROTATE', + 'sec.watermark': 'WATERMARK', + 'sec.gps': 'GPS', + + 'wb.temp': 'COLOR TEMP', + + 'frame.none': 'NO FRAME', + 'frame.polaroid': 'RETRO INSTANT', + 'frame.classic': 'CLASSIC BORDER', + 'frame.cinematic': 'CINEMATIC', + 'frame.wall': 'WALL FRAME', + 'frame.wallLand': 'WALL LANDSCAPE', + + 'crop.none': 'NO CROP', + 'crop.1:1': '1:1', + 'crop.2:3': '2:3', + 'crop.3:2': '3:2', + 'crop.3:4': '3:4', + 'crop.4:3': '4:3', + 'crop.16:9': '16:9', + 'crop.free': 'FREE', + + 'rot.0': '0°', + 'rot.90': '90°', + 'rot.180': '180°', + 'rot.270': '270°', + + 'wm.text': 'TEXT', + 'wm.color': 'COLOR', + 'wm.size': 'SIZE', + 'wm.gps': 'GPS STAMP', + 'wm.place': 'PLACE NAME', + 'wm.placePh': 'e.g. HANOI', + 'wm.manual': 'ENTER COORDINATES', + 'wm.lat': 'Latitude', + 'wm.lng': 'Longitude', + 'wm.hasExif': 'GPS read from the photo.', + 'wm.noExif': 'This photo has no GPS — type it in if you want the stamp.', + + 'auth.loginTitle': 'Log in', + 'auth.signupTitle': 'Create an account', + 'auth.email': 'Email', + 'auth.password': 'Password', + 'auth.passwordHint': 'At least 8 characters', + 'auth.noAccount': 'No account yet? Sign up', + 'auth.haveAccount': 'Already have an account? Log in', + 'auth.continueDemo': 'Keep using the demo', + 'auth.busy': 'Working…', + 'auth.loggedInAs': 'Signed in as {email}', + + 'save.title': 'Save recipe', + 'save.name': 'Recipe name', + 'save.save': 'SAVE', + 'save.saved': 'Saved "{name}"', + 'save.needLogin': 'Log in to save a recipe.', + + 'err.noWebgl': + 'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).', + 'err.decode': 'Could not read this image. Try another JPG or PNG.', + 'err.generic': 'Something went wrong: {msg}', + 'err.offline': 'Could not reach the server.', + + 'meta.ready': 'Ready', + 'meta.rendered': 'Rendered in {ms}ms · {w}×{h}', + 'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)', +}; + +export default en; diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts new file mode 100644 index 0000000..7347d15 --- /dev/null +++ b/docker/frontend/src/i18n/vi.ts @@ -0,0 +1,131 @@ +// Vietnamese dictionary — the source of truth for key names (`en.ts` is typed +// as `Dict`, so a missing or misspelled key there is a typecheck error). +// Not translated anywhere, in any language: film-sim names, user recipe names, +// user watermark text — they are proper nouns, see WEB_PLAN.md. + +export const vi = { + 'lang.name': 'Tiếng Việt', + + 'nav.demo': 'DÙNG THỬ', + 'nav.login': 'ĐĂNG NHẬP', + 'nav.signup': 'ĐĂNG KÝ', + 'nav.logout': 'Đăng xuất', + 'nav.account': 'Tài khoản', + 'nav.theme': 'Giao diện', + 'nav.language': 'Ngôn ngữ', + 'nav.back': 'Về trang chủ', + 'nav.guest': 'Khách', + + 'act.undo': 'HOÀN TÁC', + 'act.reset': 'ĐẶT LẠI', + 'act.export': 'XUẤT ẢNH', + 'act.exporting': 'Đang xuất…', + 'act.cancel': 'Huỷ', + + 'land.title': 'Cùng công thức màu, giờ trên máy tính', + 'land.lede': + 'RecipesCam biến ảnh chụp thành ảnh có "chất" bằng công thức phim. Bản web chạy đúng pipeline màu của app: cùng công thức, cùng khung, cùng dấu GPS.', + 'land.ctaDemo': 'DÙNG THỬ NGAY', + 'land.ctaSignup': 'TẠO TÀI KHOẢN', + 'land.f1t': 'Công thức phim', + 'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… cộng bộ chỉnh tay EXPOSURE, WB, CLARITY, HDF, VIGNETTING.', + 'land.f2t': 'Khung & watermark', + 'land.f2d': 'Khung polaroid, viền classic, WALL FRAME, watermark chữ và dấu GPS đọc từ EXIF của ảnh.', + 'land.f3t': 'Xử lý ngay trên máy bạn', + 'land.f3d': 'Ảnh không bao giờ rời khỏi trình duyệt. Máy chủ chỉ giữ tài khoản và công thức bạn lưu.', + 'land.badgePlay': 'Tải trên Google Play', + 'land.badgeApp': 'Tải trên App Store', + 'land.storeSoon': 'Sắp có', + + 'drop.title': 'Kéo & thả ảnh vào đây', + 'drop.hint': 'hoặc bấm để chọn ảnh · dán từ clipboard bằng Ctrl+V', + 'drop.replace': 'Đổi ảnh khác', + 'drop.peeking': 'ĐANG XEM ẢNH GỐC', + 'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc', + 'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.', + + 'tab.presets': 'PRESETS', + 'tab.favorited': 'FAVORITED', + 'tab.light': 'LIGHT', + 'tab.wb': 'WB', + 'tab.fx': 'FX', + 'tab.frame': 'FRAME', + + 'sec.filmSims': 'PHOTO STYLE', + 'sec.recipes': 'RECIPES', + 'sec.saved': 'ĐÃ LƯU', + 'sec.savedEmpty': 'Chưa lưu công thức nào. Đăng nhập rồi bấm LƯU để đồng bộ.', + 'sec.frame': 'KHUNG', + 'sec.crop': 'CẮT', + 'sec.rotate': 'XOAY', + 'sec.watermark': 'WATERMARK', + 'sec.gps': 'GPS', + + 'wb.temp': 'COLOR TEMP', + + 'frame.none': 'KHÔNG KHUNG', + 'frame.polaroid': 'RETRO INSTANT', + 'frame.classic': 'VIỀN CLASSIC', + 'frame.cinematic': 'ĐIỆN ẢNH', + 'frame.wall': 'WALL FRAME', + 'frame.wallLand': 'WALL LANDSCAPE', + + 'crop.none': 'KHÔNG CẮT', + 'crop.1:1': '1:1', + 'crop.2:3': '2:3', + 'crop.3:2': '3:2', + 'crop.3:4': '3:4', + 'crop.4:3': '4:3', + 'crop.16:9': '16:9', + 'crop.free': 'TỰ DO', + + 'rot.0': '0°', + 'rot.90': '90°', + 'rot.180': '180°', + 'rot.270': '270°', + + 'wm.text': 'CHỮ', + 'wm.color': 'MÀU', + 'wm.size': 'CỠ', + 'wm.gps': 'DẤU GPS', + 'wm.place': 'TÊN ĐỊA ĐIỂM', + 'wm.placePh': 'VD: HÀ NỘI', + 'wm.manual': 'NHẬP TOẠ ĐỘ', + 'wm.lat': 'Vĩ độ', + 'wm.lng': 'Kinh độ', + 'wm.hasExif': 'Đã đọc GPS từ ảnh.', + 'wm.noExif': 'Ảnh không có GPS — nhập tay nếu muốn đóng dấu.', + + 'auth.loginTitle': 'Đăng nhập', + 'auth.signupTitle': 'Tạo tài khoản', + 'auth.email': 'Email', + 'auth.password': 'Mật khẩu', + 'auth.passwordHint': 'Ít nhất 8 ký tự', + 'auth.noAccount': 'Chưa có tài khoản? Đăng ký', + 'auth.haveAccount': 'Đã có tài khoản? Đăng nhập', + 'auth.continueDemo': 'Tiếp tục dùng thử', + 'auth.busy': 'Đang xử lý…', + 'auth.loggedInAs': 'Đã đăng nhập: {email}', + + 'save.title': 'Lưu công thức', + 'save.name': 'Tên công thức', + 'save.save': 'LƯU', + 'save.saved': 'Đã lưu "{name}"', + 'save.needLogin': 'Đăng nhập để lưu công thức.', + + 'err.noWebgl': + 'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).', + 'err.decode': 'Không đọc được ảnh này. Thử JPG hoặc PNG khác.', + 'err.generic': 'Có lỗi xảy ra: {msg}', + 'err.offline': 'Không gọi được máy chủ.', + + 'meta.ready': 'Sẵn sàng', + 'meta.rendered': 'Kết xuất {ms}ms · {w}×{h}', + 'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)', +} as const; + +// `vi` is `as const` so the key set is exact; `Dict` widens the values to +// string, which is what lets `en.ts` hold different text under the same keys +// while a missing or misspelled key stays a typecheck error. +export type Dict = { [K in keyof typeof vi]: string }; +export type MsgKey = keyof Dict; diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx new file mode 100644 index 0000000..04be10c --- /dev/null +++ b/docker/frontend/src/main.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client'; +import './styles/tokens.css'; +import './styles/app.css'; +import { ThemeProvider } from './theme/ThemeProvider'; +import { I18nProvider } from './i18n/I18nProvider'; +import { Landing } from './Landing'; +import { Workspace } from './App'; + +// Two routes, no router: the landing page and the workspace. nginx serves +// index.html for both (SPA fallback), so this is just a pathname check. +const isWorkspace = window.location.pathname.startsWith('/app'); +const root = document.getElementById('root'); +if (!root) throw new Error('#root missing'); + +// No StrictMode: it double-invokes effects, which would load CanvasKit twice and +// run the render pipeline twice on every mount. +createRoot(root).render( + + {isWorkspace ? : } + +); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css new file mode 100644 index 0000000..a11e328 --- /dev/null +++ b/docker/frontend/src/styles/app.css @@ -0,0 +1,208 @@ +/* Workspace layout: header strip on top, then rail | chips | stage. + Under 860px the rail becomes a bottom bar and the chips a horizontal strip, + which is the same shape the phone app uses. */ + +.shell { display: flex; flex-direction: column; height: 100%; } + +.header { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--bg-elev); + flex-wrap: wrap; +} +.brand { font-weight: 700; letter-spacing: 0.04em; } +.brand small { color: var(--text-dim); font-weight: 400; margin-left: 6px; letter-spacing: 0; } +.header .spacer { flex: 1 1 auto; } +.recipe-name { + font-family: var(--mono); + font-size: 12px; + color: var(--text-dim); + border: 1px solid var(--border-soft); + border-radius: 999px; + padding: 3px 10px; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-wrap { position: relative; } + +.workspace { display: flex; flex: 1 1 auto; min-height: 0; } + +/* --- tool rail ---------------------------------------------------------- */ +.rail { + width: var(--rail-w); + flex: 0 0 auto; + border-right: 1px solid var(--border-soft); + background: var(--bg-sunken); + display: flex; + flex-direction: column; + padding: 6px; + gap: 4px; + overflow-y: auto; +} +.rail button { + border: 1px solid transparent; + background: transparent; + border-radius: var(--radius-sm); + padding: 9px 4px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + font-size: 10px; + letter-spacing: 0.06em; + color: var(--text-dim); +} +.rail button .glyph { font-size: 17px; line-height: 1; } +.rail button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); } +.rail button:hover { color: var(--text); } + +/* --- chip column -------------------------------------------------------- */ +.chips { + width: var(--chip-w); + flex: 0 0 auto; + border-right: 1px solid var(--border-soft); + background: var(--bg); + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.chips h2 { margin: 0 0 2px; font-size: 11px; letter-spacing: 0.14em; color: var(--text-dim); font-weight: 600; } +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { + border: 1px solid var(--border); + background: var(--bg-elev); + border-radius: 999px; + padding: 5px 11px; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} +.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; } +.chip .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); } +.chip:disabled { opacity: 0.4; cursor: not-allowed; } + +.slider-row { border-top: 1px solid var(--border-soft); padding-top: 10px; display: flex; flex-direction: column; gap: 6px; } +.slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); } +.slider-row .head b { color: var(--accent); font-family: var(--mono); letter-spacing: 0; } +input[type="range"] { width: 100%; accent-color: var(--accent); } +.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; } + +/* --- stage -------------------------------------------------------------- */ +.stage { + flex: 1 1 auto; + min-width: 0; + background: var(--bg-sunken); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + position: relative; +} +.dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px 28px; + text-align: center; + color: var(--text-dim); + cursor: pointer; + background: var(--bg); + max-width: 520px; +} +.dropzone.over { border-color: var(--accent); color: var(--text); background: var(--accent-soft); } +.dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; } +.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; } +.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); } +.busy { + position: absolute; + inset: auto 0 12px 0; + margin: 0 auto; + width: fit-content; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 999px; + padding: 5px 12px; + font-size: 12px; + color: var(--text-dim); + box-shadow: var(--shadow); +} +.stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; } +.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; } + +/* --- landing ------------------------------------------------------------ */ +.landing { max-width: 960px; margin: 0 auto; padding: 56px 20px 72px; } +.landing h1 { font-size: clamp(30px, 5vw, 52px); line-height: 1.1; margin: 0 0 14px; } +.landing .lede { font-size: 17px; color: var(--text-dim); max-width: 620px; line-height: 1.6; } +.landing .cta { display: flex; gap: 10px; margin: 24px 0 44px; flex-wrap: wrap; } +.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; } +.card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--bg-elev); } +.card h3 { margin: 0 0 6px; font-size: 14px; } +.card p { margin: 0; color: var(--text-dim); line-height: 1.55; } +.badges { display: flex; gap: 10px; margin-top: 34px; flex-wrap: wrap; } +.badge { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 9px 14px; + text-decoration: none; + color: var(--text); + display: flex; + flex-direction: column; + line-height: 1.3; + background: var(--bg-elev); +} +.badge small { color: var(--text-dim); font-size: 10px; letter-spacing: 0.08em; } + +/* --- auth dialog -------------------------------------------------------- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 60; + padding: 16px; +} +.modal { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + width: 100%; + max-width: 360px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 10px; +} +.modal h2 { margin: 0; font-size: 17px; } + +/* --- responsive --------------------------------------------------------- */ +@media (max-width: 860px) { + .workspace { flex-direction: column; } + .rail { + order: 2; + width: auto; + flex-direction: row; + border-right: none; + border-top: 1px solid var(--border-soft); + overflow-x: auto; + padding: 4px; + } + .rail button { flex: 1 0 auto; min-width: 62px; } + .chips { order: 1; width: auto; border-right: none; border-bottom: 1px solid var(--border-soft); max-height: 42vh; } + .stage { order: 0; flex: 1 1 auto; } + .chip-row { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; } +} diff --git a/docker/frontend/src/styles/tokens.css b/docker/frontend/src/styles/tokens.css new file mode 100644 index 0000000..85c93e1 --- /dev/null +++ b/docker/frontend/src/styles/tokens.css @@ -0,0 +1,105 @@ +/* Theme tokens. Switching = two attributes on , set by ThemeProvider — + no React re-render, no per-component colour code. Adding an accent group is + one 3-line block below; see src/theme/themes.ts for the picker's list. */ + +:root { + --radius: 10px; + --radius-sm: 7px; + --rail-w: 84px; + --chip-w: 300px; + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* --- base themes --------------------------------------------------------- */ +[data-theme="light"] { + --bg: #ffffff; + --bg-sunken: #f6f7f9; + --bg-elev: #ffffff; + --border: #dcdfe4; + --border-soft: #eceef1; + --text: #14181d; + --text-dim: #5c6773; + --danger: #c2402f; + --accent-l: 42%; + --accent-fg: #ffffff; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.06); +} + +[data-theme="dark"] { + --bg: #000000; + --bg-sunken: #0b0b0c; + --bg-elev: #131315; + --border: #2a2a2e; + --border-soft: #1d1d20; + --text: #f2f3f5; + --text-dim: #9aa2ad; + --danger: #ff6b57; + --accent-l: 60%; + --accent-fg: #0a0a0a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.45); +} + +/* --- accent groups ------------------------------------------------------ */ +[data-accent="amber"] { --accent-h: 33; --accent-s: 92%; } +[data-accent="blue"] { --accent-h: 214; --accent-s: 88%; } +[data-accent="emerald"] { --accent-h: 158; --accent-s: 68%; } +[data-accent="rose"] { --accent-h: 343; --accent-s: 82%; } +[data-accent="violet"] { --accent-h: 265; --accent-s: 78%; } + +:root { + --accent: hsl(var(--accent-h) var(--accent-s) var(--accent-l)); + --accent-hover: hsl(var(--accent-h) var(--accent-s) calc(var(--accent-l) + 7%)); + --accent-soft: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.14); + --accent-line: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.45); +} + +/* --- base elements ------------------------------------------------------ */ +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} +button { font: inherit; color: inherit; } +a { color: var(--accent); } + +.btn { + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + padding: 7px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; +} +.btn:hover { border-color: var(--accent-line); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; } +.btn.primary:hover { background: var(--accent-hover); } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn.ghost { background: transparent; border-color: transparent; color: var(--text-dim); } +.btn.ghost:hover { color: var(--text); background: var(--bg-sunken); } + +.field { + width: 100%; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.popover { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 40; + min-width: 220px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 10px; +} diff --git a/docker/frontend/src/theme/ThemeProvider.tsx b/docker/frontend/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..a6f1b81 --- /dev/null +++ b/docker/frontend/src/theme/ThemeProvider.tsx @@ -0,0 +1,37 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { ACCENTS, applyTheme, readAccent, readTheme, type AccentId, type ThemeMode } from './themes'; + +interface ThemeValue { + mode: ThemeMode; + accent: AccentId; + setMode: (mode: ThemeMode) => void; + setAccent: (accent: AccentId) => void; +} + +const Ctx = createContext(null); + +// The tokens live in CSS; this provider only owns the two attributes on +// and the persisted choice. No component ever reads a colour from JS. +export function ThemeProvider({ children }: { children: ReactNode }) { + const [mode, setModeState] = useState(readTheme); + const [accent, setAccentState] = useState(readAccent); + + useEffect(() => { + applyTheme(mode, accent); + }, [mode, accent]); + + const setMode = useCallback((next: ThemeMode) => setModeState(next), []); + const setAccent = useCallback((next: AccentId) => setAccentState(next), []); + + const value = useMemo(() => ({ mode, accent, setMode, setAccent }), [mode, accent, setMode, setAccent]); + return {children}; +} + +export function useTheme(): ThemeValue { + const value = useContext(Ctx); + if (!value) throw new Error('useTheme used outside ThemeProvider'); + return value; +} + +export { ACCENTS }; +export type { AccentId, ThemeMode }; diff --git a/docker/frontend/src/theme/themes.ts b/docker/frontend/src/theme/themes.ts new file mode 100644 index 0000000..50b11c2 --- /dev/null +++ b/docker/frontend/src/theme/themes.ts @@ -0,0 +1,41 @@ +// Theme data only — the CSS in styles/tokens.css owns the values. Adding an +// accent group is one entry here plus one 1-line block in the stylesheet. + +export type ThemeMode = 'light' | 'dark'; +export type AccentId = 'amber' | 'blue' | 'emerald' | 'rose' | 'violet'; + +export const ACCENTS: { id: AccentId; label: string }[] = [ + { id: 'amber', label: 'Amber' }, + { id: 'blue', label: 'Blue' }, + { id: 'emerald', label: 'Emerald' }, + { id: 'rose', label: 'Rose' }, + { id: 'violet', label: 'Violet' }, +]; + +export const THEME_COLOR: Record = { + light: '#ffffff', + dark: '#0a0a0a', +}; + +const KEY_THEME = 'rc.theme'; +const KEY_ACCENT = 'rc.accent'; + +export function readTheme(): ThemeMode { + const stored = localStorage.getItem(KEY_THEME); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +export function readAccent(): AccentId { + const stored = localStorage.getItem(KEY_ACCENT); + return ACCENTS.some((a) => a.id === stored) ? (stored as AccentId) : 'amber'; +} + +export function applyTheme(mode: ThemeMode, accent: AccentId): void { + const root = document.documentElement; + root.dataset.theme = mode; + root.dataset.accent = accent; + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLOR[mode]); + localStorage.setItem(KEY_THEME, mode); + localStorage.setItem(KEY_ACCENT, accent); +} diff --git a/docker/frontend/src/ui/AuthModal.tsx b/docker/frontend/src/ui/AuthModal.tsx new file mode 100644 index 0000000..6406a3e --- /dev/null +++ b/docker/frontend/src/ui/AuthModal.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { useI18n } from '../i18n/I18nProvider'; +import { api } from '../api'; + +// Email + password only: the cookie is set by the API container, this dialog +// just drives it. The demo is always one click away — the app is usable signed +// out, and only export resolution / saving are gated (see config/tiers.ts). +export function AuthModal({ + onClose, + onDone, +}: { + onClose: () => void; + onDone: () => void; +}) { + const { t } = useI18n(); + const [mode, setMode] = useState<'login' | 'signup'>('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + await (mode === 'login' ? api.login(email, password) : api.signup(email, password)); + onDone(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+

{mode === 'login' ? t('auth.loginTitle') : t('auth.signupTitle')}

+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> +

{t('auth.passwordHint')}

+ {error ?

{error}

: null} + + + +
+
+ ); +} diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx new file mode 100644 index 0000000..34078ee --- /dev/null +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react'; + +// One column, one open slider row at a time — the same shape as the app's +// AdjustmentPanel: a chip row per group, and the tapped chip's row opens +// underneath it. +export interface ChipDef { + key: string; + label: string; + value?: string; + active?: boolean; + pro?: boolean; + disabled?: boolean; + onClick: () => void; +} + +export interface SliderDef { + key: string; + label: string; + value: number; + min: number; + max: number; + step: number; + display: string; + onChange: (value: number) => void; + onClose: () => void; +} + +export function ChipRow({ chips }: { chips: ChipDef[] }) { + return ( +
+ {chips.map((chip) => ( + + ))} +
+ ); +} + +export function SliderRow({ slider }: { slider: SliderDef }) { + return ( +
+
+ {slider.label} + {slider.display} +
+ slider.onChange(Number(e.target.value))} + /> +
+ +
+
+ ); +} + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx new file mode 100644 index 0000000..822e6f4 --- /dev/null +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useI18n } from '../i18n/I18nProvider'; + +// The centre column: drop target, preview canvas, and the PEEK comparison. +// Dragging a photo anywhere on the page works, not just on the frame — the +// whole stage is the drop zone. +export function ImageStage({ + previewUrl, + originalUrl, + busy, + error, + peek, + onPeek, + onFile, +}: { + previewUrl: string | null; + originalUrl: string | null; + busy: string | null; + error: string | null; + peek: boolean; + onPeek: (on: boolean) => void; + onFile: (file: File) => void; +}) { + const { t } = useI18n(); + const inputRef = useRef(null); + const [over, setOver] = useState(false); + + // \ toggles PEEK; the key is the app's own shortcut, kept identical on web. + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === '\\') onPeek(true); + }; + const up = (e: KeyboardEvent) => { + if (e.key === '\\') onPeek(false); + }; + window.addEventListener('keydown', down); + window.addEventListener('keyup', up); + return () => { + window.removeEventListener('keydown', down); + window.removeEventListener('keyup', up); + }; + }, [onPeek]); + + const stop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const drop = useCallback( + (e: React.DragEvent) => { + stop(e); + setOver(false); + const file = e.dataTransfer?.files?.[0]; + if (file) onFile(file); + }, + [onFile, stop] + ); + + return ( +
{ + stop(e); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={drop} + onContextMenu={(e) => e.preventDefault()} + onMouseDown={(e) => { + if (e.button === 2) onPeek(true); + }} + onMouseUp={() => onPeek(false)} + > + {!previewUrl ? ( +
inputRef.current?.click()} + onDragOver={(e) => { + stop(e); + setOver(true); + }} + onDrop={drop} + > + {t('drop.title')} + {t('drop.hint')} +

{t('drop.demoLimit', { px: 2048 })}

+
+ ) : ( +
+ preview + {peek ?
{t('drop.peeking')}
: null} +
+ )} + + {previewUrl ? ( +
+ + {t('drop.peekHint')} +
+ ) : null} + + {busy ?
{busy}
: null} + {error ?

{error}

: null} + + { + const file = e.target.files?.[0]; + if (file) onFile(file); + e.target.value = ''; + }} + /> +
+ ); +} diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx new file mode 100644 index 0000000..fc82dd0 --- /dev/null +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -0,0 +1,34 @@ +import type { MsgKey } from '../i18n/vi'; +import { useI18n } from '../i18n/I18nProvider'; + +// The app's six tabs, in the same order as the phone rail. The rail is vertical +// on desktop and becomes the bottom bar under 860px (see styles/app.css). +export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame'; + +export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ + { id: 'presets', label: 'tab.presets', glyph: '◉' }, + { id: 'favorited', label: 'tab.favorited', glyph: '★' }, + { id: 'light', label: 'tab.light', glyph: '☀' }, + { id: 'wb', label: 'tab.wb', glyph: '◐' }, + { id: 'fx', label: 'tab.fx', glyph: '✦' }, + { id: 'frame', label: 'tab.frame', glyph: '▣' }, +]; + +export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) { + const { t } = useI18n(); + return ( + + ); +} diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx new file mode 100644 index 0000000..6af5286 --- /dev/null +++ b/docker/frontend/src/ui/TopBar.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react'; +import { useI18n, LANGS } from '../i18n/I18nProvider'; +import { ACCENTS, useTheme } from '../theme/ThemeProvider'; +import type { User } from '../api'; + +// Header: the app's TopBar minus everything camera-only, plus the two controls +// the web build adds — theme (light/dark + accent group) and language. +export function TopBar({ + recipeName, + user, + exporting, + canUndo, + onUndo, + onReset, + onExport, + onSave, + onAuth, + onLogout, +}: { + recipeName: string; + user: User | null; + exporting: boolean; + canUndo: boolean; + onUndo: () => void; + onReset: () => void; + onExport: () => void; + onSave: () => void; + onAuth: () => void; + onLogout: () => void; +}) { + const { t, lang, setLang } = useI18n(); + const { mode, accent, setMode, setAccent } = useTheme(); + const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null); + const wrap = useRef(null); + + useEffect(() => { + if (!menu) return; + const away = (e: MouseEvent) => { + if (!wrap.current?.contains(e.target as Node)) setMenu(null); + }; + document.addEventListener('mousedown', away); + return () => document.removeEventListener('mousedown', away); + }, [menu]); + + return ( +
+ + RecipesCam web + + {recipeName} + + + + + + + +
+ + {menu === 'theme' ? ( +
+
+ + +
+
+ {ACCENTS.map((entry) => ( + + ))} +
+
+ ) : null} +
+ +
+ + {menu === 'lang' ? ( +
+ {LANGS.map((entry) => ( + + ))} +
+ ) : null} +
+ +
+ + {menu === 'account' ? ( +
+ {user ? ( + <> +

{t('auth.loggedInAs', { email: user.email })}

+ + + ) : ( + + )} +
+ ) : null} +
+
+ ); +} diff --git a/docker/frontend/tsconfig.json b/docker/frontend/tsconfig.json new file mode 100644 index 0000000..9ea4d1d --- /dev/null +++ b/docker/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@shopify/react-native-skia": ["./src/engine/skiaShim.ts"] + } + }, + "include": ["src", "shared", "scripts", "vite.config.ts"] +} diff --git a/docker/frontend/vite.config.ts b/docker/frontend/vite.config.ts new file mode 100644 index 0000000..d86eb27 --- /dev/null +++ b/docker/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { fileURLToPath, URL } from 'node:url'; + +// The app is shared source with the React Native app: `frameUtils.ts` and +// `skiaImage.ts` import `@shopify/react-native-skia`, and the alias below swaps +// that for the CanvasKit adapter. Same trick the W0 spike used, now for the app. +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@shopify/react-native-skia': fileURLToPath(new URL('./src/engine/skiaShim.ts', import.meta.url)), + }, + }, + build: { + target: 'es2022', + // canvaskit-wasm's glue JS is ~2.5MB on its own; the wasm is fetched at + // runtime from /wasm/, never bundled. + chunkSizeWarningLimit: 9000, + }, + server: { + port: 5173, + proxy: { '/api': process.env.API_ORIGIN ?? 'http://localhost:3000' }, + }, +});