diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a232761
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,26 @@
+# supercut director — LLM provider. Copy to .env and fill in.
+# Only `supercut generate` needs this; `record` and `render` work without a key.
+# generate sends crawled DOM text, optional screenshots, and optional repo notes
+# to the configured provider. Use trusted apps/providers only.
+
+# ── Provider selection ──────────────────────────────────────────────────
+# Optional when exactly one provider key is set. Required to disambiguate if
+# multiple provider keys exist.
+# SUPERCUT_PROVIDER=deepseek # deepseek | openrouter | custom
+
+# ── DeepSeek (text-only; DOM-only analysis, no screenshot QC) ───────────
+# Get a key: https://platform.deepseek.com/api_keys
+DEEPSEEK_API_KEY=
+# SUPERCUT_MODEL=deepseek-v4-pro # or deepseek-v4-flash
+# SUPERCUT_VISION=false # DeepSeek text-only; true is rejected
+
+# ── OpenRouter (can use vision-capable models) ──────────────────────────
+# OPENROUTER_API_KEY=
+# SUPERCUT_MODEL=anthropic/claude-sonnet-4.6
+# SUPERCUT_VISION=true
+
+# ── Custom OpenAI-compatible endpoint ───────────────────────────────────
+# SUPERCUT_API_KEY=
+# SUPERCUT_LLM_BASE_URL=https://your-compatible-endpoint.example/api/v1
+# SUPERCUT_MODEL=your-model-name # required for custom endpoints
+# SUPERCUT_VISION=false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2e00243
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,39 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ typecheck-and-unit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run typecheck
+ # whole unit suite (no explicit file list, so new test files aren't
+ # silently skipped); the e2e files run in the browser-e2e job below,
+ # which has chromium + ffmpeg installed.
+ - run: npx vitest run --exclude '**/*.e2e.test.ts'
+ - run: npm audit --audit-level=moderate
+
+ browser-e2e:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+ - run: sudo apt-get update && sudo apt-get install -y ffmpeg
+ - run: npm test -- --run test/record.e2e.test.ts test/generate.e2e.test.ts
diff --git a/.gitignore b/.gitignore
index 1f77076..b12ec7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,13 @@ out/
*.mp4
*.raw
test/.tmp/
+
+# secrets — never commit
+.env
+.env.local
+
+.worktrees/
+.next/
+examples/demo-app/node_modules/
+examples/demo-app/.next/
+.roast/
diff --git a/README.md b/README.md
index 71fed49..faffd22 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,174 @@
-# supercut
+
+
+
-> Point it at your app. Get the supercut.
+
+ Point an AI director at your live app. Get a cinematic 60-second launch video.
+
+ Real product footage — performed, shot, and edited automatically. No mockups, no timeline, no manual cuts.
+
-Institutional-grade, max-60-second launch videos generated from your **real**
-product — not an HTML mockup. A scripted browser performs your app on camera;
-a cinematic renderer adds the Screen Studio look (spring zoom-to-cursor,
-motion blur, padded background, music on the beat grid); an AI director writes
-the script and quality-checks the footage.
+
+
+
+
+
+
+
-**Status: pre-release, under active construction.** The design doc and build
-plan are complete; stages are landing in order. Nothing to install yet.
+
+
+
+ Generated by supercut from a live web app — zero manual editing. The cursor, the camera, the cuts: all automatic.
+
+---
+
+**You built something great. Now you need a launch video — and all you've got is a screen recording, iMovie, and a deadline.**
+
+`supercut` points an AI director at your *running* app. It reads your source, crawls the live UI, decides the 2–4 moments that actually sell the product, drives a real browser to perform them on camera, then renders the whole thing with the Screen-Studio look — spring zoom-to-cursor, motion blur, a padded background, and a clean 1080p60 export.
+
+> Not a screen recording. Not a fake UI mockup. **Your real product**, shot like a launch film — automatically.
+
+## ✨ What makes it different
+
+- **Real footage only.** It drives your actual app in a real browser. Nothing is faked or re-created.
+- **It understands the product.** It reads your routes/source *and* crawls the DOM, so it films the money moments — type a query → frame the result — instead of parking on the landing page.
+- **It frames the payoff.** The camera holds on the *result* an action produces (the graph, the dashboard, the detail panel), not the button you clicked.
+- **Works on any app.** Same pipeline for a light editorial dashboard or a dark single-page tool — copy and colors adapt per app.
+- **No API key required to run it.** `record` + `render` work fully offline; only the AI director (`generate`) calls an LLM.
+- **An open contract.** The recorder writes a documented event log; any recorder can feed the renderer.
+
+## 🎬 How it works
+
+```text
+ your app URL ──▶ ① analyze read the source + crawl the app → pick the money moments (LLM)
+ ② script write the filming recipe (LLM, schema-validated, no hallucinated selectors)
+ ③ record a deterministic browser performs it, captured frame-by-frame
+ ④ qc deterministic + optional vision checks, bounded re-takes
+ ⑤ render cinematic compositing ──▶ final.mp4 (≤60s, 1080p60)
+```
+
+Each stage hands off a plain-JSON artifact, so you can stop at any point, hand-edit, and resume.
+
+## 🚀 Quick start
+
+```bash
+git clone https://github.com/Co-Messi/supercut
+cd supercut
+npm install
+npm run build
+
+# point it at your running app — that's it
+node dist/cli/index.js generate --url http://127.0.0.1:3000 --yes
+```
+
+`generate` needs an LLM key (see [provider setup](#-llm-provider-setup)). No key? The non-AI path works standalone:
+
+```bash
+node dist/cli/index.js record --recipe examples/demo.recipe.json --out out/take
+node dist/cli/index.js render --take out/take --out out/final.mp4
+node dist/cli/index.js doctor # check Chromium + ffmpeg are installed
+```
+
+> Browser + video need Chromium and ffmpeg: `npx playwright install chromium` and an `ffmpeg` on your PATH.
+
+Help the director understand a deeper, multi-page app by pointing it at the source:
+
+```bash
+node dist/cli/index.js generate --url http://127.0.0.1:3000 --repo ./ --yes
+```
+
+### Private/local apps & untrusted targets
+
+Filming your own local dev app is the primary use case, so `generate` **allows**
+localhost / RFC1918 / link-local by default — no flag needed. If you point it at an
+**untrusted or public** URL, add `--block-private-network` to engage the SSRF guard
+(rejects localhost, RFC1918, link-local, and cloud-metadata addresses, and validates
+each redirect hop):
+
+```bash
+node dist/cli/index.js generate --url https://untrusted.example --block-private-network --yes
+```
+
+(`--allow-private-network` is a deprecated no-op kept for back-compat. Known limit: the
+guard does not defend against DNS-rebinding / resolve-time TOCTOU.)
+
+> ⚠️ **supercut drives and may MUTATE the target app** — it performs real clicks and
+> typing on whatever you point it at. Destructive controls (Delete, Pay, Checkout, …)
+> are excluded from filming by default; pass `--allow-destructive` to include them.
+> Never run it against production data or URLs/recipes you do not trust.
+
+## 🔌 LLM provider setup
+
+Copy `.env.example` to `.env` (or pass `--env-file `):
+
+```bash
+cp .env.example .env
+```
+
+DeepSeek is text-only here, so supercut disables screenshots and vision QC for it by default:
+
+```env
+SUPERCUT_PROVIDER=deepseek
+DEEPSEEK_API_KEY=...
+SUPERCUT_MODEL=deepseek-v4-pro
+```
+
+OpenRouter / custom OpenAI-compatible providers can use vision-capable models:
+
+```env
+SUPERCUT_PROVIDER=openrouter
+OPENROUTER_API_KEY=...
+SUPERCUT_MODEL=anthropic/claude-sonnet-4.6
+SUPERCUT_VISION=true
+```
+
+For `SUPERCUT_PROVIDER=custom`, set both `SUPERCUT_LLM_BASE_URL` and `SUPERCUT_MODEL`.
+If multiple provider keys are present, set `SUPERCUT_PROVIDER` explicitly — ambiguous
+config fails loudly rather than guessing.
+
+## 🔒 Privacy
+
+`generate` may send crawled DOM text, element labels/selectors, optional screenshots,
+and optional repo notes (`--repo`) to your configured LLM provider. It also writes
+frames, recipes, and director reports to `out/`. Review those before sharing — and use
+`record` + `render` for a fully no-LLM workflow.
+
+## 📜 Event-log contract
+
+The public boundary is plain JSON, so any recorder can feed the renderer:
+
+```text
+recipe.json ──▶ record ──▶ take directory
+ ├─ events.json (the event-log contract)
+ ├─ frames-index.json
+ └─ frames/*.png
+
+take directory ──▶ render ──▶ final.mp4
```
- your app URL ──▶ ① analyze pick the 3-4 money moments (LLM)
- ② script write the filming recipe, beat-aligned (LLM, schema-validated)
- ③ record a deterministic browser executor performs it (pure code)
- ④ qc vision checks the footage, refilms what's bad (bounded loop)
- ⑤ render cinematic compositing + music ──▶ launch.mp4 (≤60s, 1080p60)
+
+Schemas reject unsupported URL schemes, malformed events, non-monotonic timelines,
+oversized logs, and impossible camera boxes.
+
+## Project principles
+
+- Real product footage beats mockups.
+- The event log is a public contract.
+- The non-AI `record` / `render` paths stay useful without an API key.
+- Defaults fail loudly on unsafe or ambiguous config.
+
+## Contributing
+
+```bash
+npm run typecheck
+npm run test:fast
+npm run test:e2e # needs Chromium + ffmpeg
+npm audit --audit-level=moderate
```
-- Real footage only — no fake UI renders, ever
-- The event-log JSON between recorder and renderer is a public contract; any
- recorder can feed it
-- Stages ③ and ⑤ run standalone with zero API key (`supercut record`,
- `supercut render`)
-- MIT, CC0-only bundled music, macOS + Linux
+Keep PRs focused and add tests for behavior changes.
## License
-MIT
+[MIT](LICENSE)
diff --git a/assets/demo-meridian.gif b/assets/demo-meridian.gif
new file mode 100644
index 0000000..2e4a530
Binary files /dev/null and b/assets/demo-meridian.gif differ
diff --git a/assets/supercut-wordmark-final.png b/assets/supercut-wordmark-final.png
new file mode 100644
index 0000000..e3e2ab1
Binary files /dev/null and b/assets/supercut-wordmark-final.png differ
diff --git a/examples/pandora-demo.recipe.json b/examples/pandora-demo.recipe.json
new file mode 100644
index 0000000..afb2c75
--- /dev/null
+++ b/examples/pandora-demo.recipe.json
@@ -0,0 +1,29 @@
+{
+ "version": 0,
+ "app_url": "http://127.0.0.1:8455",
+ "music_track": "institutional-01",
+ "scenes": [
+ {
+ "name": "trace-one-company",
+ "priority": 1,
+ "entry": { "url": "http://127.0.0.1:8455/", "prelude": [] },
+ "depends_on": [],
+ "actions": [
+ { "kind": "click", "selector": "#ticker", "duration_ms": 1000 },
+ { "kind": "type", "selector": "#ticker", "text": "NVDA", "submit": true, "focus_selector": "#graph", "duration_ms": 1600 }
+ ],
+ "hold_ms": 2600
+ },
+ {
+ "name": "expose-the-illusion",
+ "priority": 2,
+ "entry": { "url": "http://127.0.0.1:8455/", "prelude": [] },
+ "depends_on": [],
+ "actions": [
+ { "kind": "click", "selector": "#ticker", "duration_ms": 1000 },
+ { "kind": "type", "selector": "#ticker", "text": "SEMICONDUCTOR", "submit": true, "focus_selector": "#graph", "duration_ms": 2400 }
+ ],
+ "hold_ms": 3000
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index cc75896..2d49317 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,16 +19,50 @@
"@types/node": "^20.17.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
- "vitest": "^2.1.0"
+ "vitest": "^4.1.8"
},
"engines": {
"node": ">=20"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
- "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -43,9 +77,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
- "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -60,9 +94,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
- "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -77,9 +111,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
- "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -94,9 +128,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
- "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -111,9 +145,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
- "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -128,9 +162,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
- "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -145,9 +179,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
- "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -162,9 +196,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
- "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -179,9 +213,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
- "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -196,9 +230,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
- "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -213,9 +247,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
- "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -230,9 +264,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
- "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -247,9 +281,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
- "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -264,9 +298,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
- "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -281,9 +315,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
- "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -298,9 +332,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
- "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -315,9 +349,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -332,9 +366,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
- "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -349,9 +383,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -366,9 +400,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
- "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -383,9 +417,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
- "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -400,9 +434,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
- "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -417,9 +451,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
- "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -434,9 +468,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
- "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -451,9 +485,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
- "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -474,24 +508,39 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
- "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
+ "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "android"
- ]
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.133.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
+ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
},
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
- "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
+ "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
@@ -500,12 +549,15 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
- "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
+ "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
@@ -514,12 +566,15 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
- "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
+ "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
@@ -528,26 +583,15 @@
"optional": true,
"os": [
"darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
- "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
- "cpu": [
- "arm64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
- "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
+ "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
@@ -556,26 +600,15 @@
"optional": true,
"os": [
"freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
- "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
- "cpu": [
- "arm"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
- "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
+ "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
@@ -584,26 +617,15 @@
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
- "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
- "cpu": [
- "arm64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
- "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
+ "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
@@ -612,54 +634,32 @@
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
- "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
- "cpu": [
- "loong64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
- "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
+ "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
- "loong64"
+ "arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
- "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
- "cpu": [
- "ppc64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
- "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
+ "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
@@ -668,40 +668,15 @@
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
- "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
- "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
- "cpu": [
- "riscv64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
- "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
+ "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
@@ -710,12 +685,15 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
- "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
+ "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
@@ -724,12 +702,15 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
- "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
+ "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
@@ -738,26 +719,15 @@
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
- "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
- "cpu": [
- "x64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
- "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
+ "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
@@ -766,40 +736,51 @@
"optional": true,
"os": [
"openharmony"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
- "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
+ "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
- "arm64"
+ "wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "win32"
- ]
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
- "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
+ "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
- "ia32"
+ "arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
- "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
+ "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
@@ -808,21 +789,53 @@
"optional": true,
"os": [
"win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
- "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
- "cpu": [
- "x64"
],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "win32"
- ]
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
@@ -842,38 +855,40 @@
}
},
"node_modules/@vitest/expect": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
- "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
+ "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "2.1.9",
- "@vitest/utils": "2.1.9",
- "chai": "^5.1.2",
- "tinyrainbow": "^1.2.0"
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
- "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
+ "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "2.1.9",
+ "@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.12"
+ "magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0"
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -885,70 +900,68 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
- "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
+ "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^1.2.0"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
- "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
+ "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "2.1.9",
- "pathe": "^1.1.2"
+ "@vitest/utils": "4.1.8",
+ "pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
- "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
+ "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "2.1.9",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2"
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
- "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
+ "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "tinyspy": "^3.0.2"
- },
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
- "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
+ "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "2.1.9",
- "loupe": "^3.1.2",
- "tinyrainbow": "^1.2.0"
+ "@vitest/pretty-format": "4.1.8",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -964,82 +977,44 @@
"node": ">=12"
}
},
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/chai": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
- "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
"engines": {
"node": ">=18"
}
},
- "node_modules/check-error": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
- "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 16"
- }
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "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",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
+ "license": "MIT"
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "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==",
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"engines": {
- "node": ">=6"
+ "node": ">=8"
}
},
"node_modules/es-module-lexer": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
- "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
- "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1050,32 +1025,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.28.0",
- "@esbuild/android-arm": "0.28.0",
- "@esbuild/android-arm64": "0.28.0",
- "@esbuild/android-x64": "0.28.0",
- "@esbuild/darwin-arm64": "0.28.0",
- "@esbuild/darwin-x64": "0.28.0",
- "@esbuild/freebsd-arm64": "0.28.0",
- "@esbuild/freebsd-x64": "0.28.0",
- "@esbuild/linux-arm": "0.28.0",
- "@esbuild/linux-arm64": "0.28.0",
- "@esbuild/linux-ia32": "0.28.0",
- "@esbuild/linux-loong64": "0.28.0",
- "@esbuild/linux-mips64el": "0.28.0",
- "@esbuild/linux-ppc64": "0.28.0",
- "@esbuild/linux-riscv64": "0.28.0",
- "@esbuild/linux-s390x": "0.28.0",
- "@esbuild/linux-x64": "0.28.0",
- "@esbuild/netbsd-arm64": "0.28.0",
- "@esbuild/netbsd-x64": "0.28.0",
- "@esbuild/openbsd-arm64": "0.28.0",
- "@esbuild/openbsd-x64": "0.28.0",
- "@esbuild/openharmony-arm64": "0.28.0",
- "@esbuild/sunos-x64": "0.28.0",
- "@esbuild/win32-arm64": "0.28.0",
- "@esbuild/win32-ia32": "0.28.0",
- "@esbuild/win32-x64": "0.28.0"
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
@@ -1098,6 +1073,24 @@
"node": ">=12.0.0"
}
},
+ "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.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -1112,66 +1105,317 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/loupe": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
- "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
- "license": "MIT"
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
+ "license": "MPL-2.0",
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "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.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
"engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/pathe": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
- "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "MIT"
- },
- "node_modules/pathval": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
- "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "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/obug": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
+ "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
"dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
"license": "MIT",
"engines": {
- "node": ">= 14.16"
+ "node": ">=12.20.0"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1179,6 +1423,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
@@ -1238,49 +1495,38 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/rollup": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
- "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
+ "node_modules/rolldown": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
+ "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/estree": "1.0.9"
+ "@oxc-project/types": "=0.133.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
- "rollup": "dist/bin/rollup"
+ "rolldown": "bin/cli.mjs"
},
"engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.61.1",
- "@rollup/rollup-android-arm64": "4.61.1",
- "@rollup/rollup-darwin-arm64": "4.61.1",
- "@rollup/rollup-darwin-x64": "4.61.1",
- "@rollup/rollup-freebsd-arm64": "4.61.1",
- "@rollup/rollup-freebsd-x64": "4.61.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
- "@rollup/rollup-linux-arm64-gnu": "4.61.1",
- "@rollup/rollup-linux-arm64-musl": "4.61.1",
- "@rollup/rollup-linux-loong64-gnu": "4.61.1",
- "@rollup/rollup-linux-loong64-musl": "4.61.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
- "@rollup/rollup-linux-ppc64-musl": "4.61.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
- "@rollup/rollup-linux-riscv64-musl": "4.61.1",
- "@rollup/rollup-linux-s390x-gnu": "4.61.1",
- "@rollup/rollup-linux-x64-gnu": "4.61.1",
- "@rollup/rollup-linux-x64-musl": "4.61.1",
- "@rollup/rollup-openbsd-x64": "4.61.1",
- "@rollup/rollup-openharmony-arm64": "4.61.1",
- "@rollup/rollup-win32-arm64-msvc": "4.61.1",
- "@rollup/rollup-win32-ia32-msvc": "4.61.1",
- "@rollup/rollup-win32-x64-gnu": "4.61.1",
- "@rollup/rollup-win32-x64-msvc": "4.61.1",
- "fsevents": "~2.3.2"
+ "@rolldown/binding-android-arm64": "1.0.3",
+ "@rolldown/binding-darwin-arm64": "1.0.3",
+ "@rolldown/binding-darwin-x64": "1.0.3",
+ "@rolldown/binding-freebsd-x64": "1.0.3",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.3",
+ "@rolldown/binding-linux-arm64-musl": "1.0.3",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.3",
+ "@rolldown/binding-linux-x64-gnu": "1.0.3",
+ "@rolldown/binding-linux-x64-musl": "1.0.3",
+ "@rolldown/binding-openharmony-arm64": "1.0.3",
+ "@rolldown/binding-wasm32-wasi": "1.0.3",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.3",
+ "@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/siginfo": {
@@ -1308,9 +1554,9 @@
"license": "MIT"
},
"node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
@@ -1322,42 +1568,50 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinypool": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
- "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": ">=18"
}
},
- "node_modules/tinyrainbow": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
- "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "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": ">=14.0.0"
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
@@ -1414,21 +1668,23 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "version": "8.0.16",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
+ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.15",
+ "rolldown": "1.0.3",
+ "tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -1437,23 +1693,33 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.18",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
- "less": {
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
"optional": true
},
- "lightningcss": {
+ "jiti": {
+ "optional": true
+ },
+ "less": {
"optional": true
},
"sass": {
@@ -1470,530 +1736,104 @@
},
"terser": {
"optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
}
}
},
- "node_modules/vite-node": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
- "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.3.7",
- "es-module-lexer": "^1.5.4",
- "pathe": "^1.1.2",
- "vite": "^5.0.0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
- "cpu": [
- "arm"
- ],
+ "node_modules/vite/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": [
- "android"
+ "darwin"
],
"engines": {
- "node": ">=12"
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/vite/node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/vitest": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
+ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
+ "dependencies": {
+ "@vitest/expect": "4.1.8",
+ "@vitest/mocker": "4.1.8",
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/runner": "4.1.8",
+ "@vitest/snapshot": "4.1.8",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
"engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/vite/node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
- }
- },
- "node_modules/vite/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/vitest": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
- "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "2.1.9",
- "@vitest/mocker": "2.1.9",
- "@vitest/pretty-format": "^2.1.9",
- "@vitest/runner": "2.1.9",
- "@vitest/snapshot": "2.1.9",
- "@vitest/spy": "2.1.9",
- "@vitest/utils": "2.1.9",
- "chai": "^5.1.2",
- "debug": "^4.3.7",
- "expect-type": "^1.1.0",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2",
- "std-env": "^3.8.0",
- "tinybench": "^2.9.0",
- "tinyexec": "^0.3.1",
- "tinypool": "^1.0.1",
- "tinyrainbow": "^1.2.0",
- "vite": "^5.0.0",
- "vite-node": "2.1.9",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
- "@types/node": "^18.0.0 || >=20.0.0",
- "@vitest/browser": "2.1.9",
- "@vitest/ui": "2.1.9",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.8",
+ "@vitest/browser-preview": "4.1.8",
+ "@vitest/browser-webdriverio": "4.1.8",
+ "@vitest/coverage-istanbul": "4.1.8",
+ "@vitest/coverage-v8": "4.1.8",
+ "@vitest/ui": "4.1.8",
"happy-dom": "*",
- "jsdom": "*"
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
+ "@opentelemetry/api": {
+ "optional": true
+ },
"@types/node": {
"optional": true
},
- "@vitest/browser": {
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
@@ -2004,6 +1844,9 @@
},
"jsdom": {
"optional": true
+ },
+ "vite": {
+ "optional": false
}
}
},
diff --git a/package.json b/package.json
index 951cfee..a610841 100644
--- a/package.json
+++ b/package.json
@@ -16,10 +16,13 @@
],
"scripts": {
"build": "tsc",
+ "prepublishOnly": "npm run build",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
- "dev": "tsx src/cli/index.ts"
+ "dev": "tsx src/cli/index.ts",
+ "test:fast": "vitest run test/cursor.test.ts test/director.test.ts test/director-validation.test.ts test/schema.test.ts test/config.test.ts test/url-policy.test.ts test/redaction.test.ts test/plan.test.ts",
+ "test:e2e": "vitest run test/record.e2e.test.ts test/generate.e2e.test.ts"
},
"dependencies": {
"playwright": "^1.53.0",
@@ -29,6 +32,6 @@
"@types/node": "^20.17.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
- "vitest": "^2.1.0"
+ "vitest": "^4.1.8"
}
}
diff --git a/src/capture/cursor.ts b/src/capture/cursor.ts
index 6055cdd..6941526 100644
--- a/src/capture/cursor.ts
+++ b/src/capture/cursor.ts
@@ -32,8 +32,7 @@ export function makeRng(seed: number): () => number {
/** Fitts's law movement time in ms, before clamping. */
export function fittsMs(distancePx: number, targetWidthPx: number): number {
- // tuned 2026-06-11 after Brayden's v0 verdict: "cursor moving is a little
- // bit too fast" — slower, deliberate presenter pace
+ // Slower, deliberate presenter pace rather than twitchy cursor motion.
const a = 220, b = 170;
return a + b * Math.log2(distancePx / Math.max(targetWidthPx, 8) + 1);
}
diff --git a/src/capture/executor.ts b/src/capture/executor.ts
index caf87e2..4522094 100644
--- a/src/capture/executor.ts
+++ b/src/capture/executor.ts
@@ -17,7 +17,7 @@
* are canonical (design doc, stage 3). On a local fixture nothing overruns,
* so the scheduled timeline is byte-identical across runs.
*
- * Capture path per spike verdict (spikes/RESULTS.md): CDP screencast PNG at
+ * Capture path: CDP screencast PNG at
* 2x DPR, ack-throttled, frames streamed straight to disk.
*/
import { mkdirSync, writeFileSync } from "node:fs";
@@ -26,6 +26,7 @@ import { join } from "node:path";
import { chromium, type CDPSession, type Page } from "playwright";
import type { EventLog, KnownEvent, Recipe, Scene, Action } from "../schema/index.js";
import { cursorPath, makeRng, type CursorPoint } from "./cursor.js";
+import { assertSafeNavigationUrl } from "../security/url-policy.js";
const VIEWPORT = { width: 1920, height: 1080 };
const DPR = 2;
@@ -43,6 +44,8 @@ export interface RecordOptions {
seed?: number;
/** Skip screencast (faster scheduling-only tests). */
captureFrames?: boolean;
+ /** allow localhost/RFC1918/cloud-metadata navigation; off by default for safety */
+ allowPrivateNetwork?: boolean;
}
export interface RecordResult {
@@ -65,11 +68,38 @@ function roundToFrame(ms: number): number {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+// Navigate robustly. Waiting for "load" hangs on apps that pull heavy subresources
+// from a CDN (e.g. the Pandora demo's d3 bundle) or hold an open connection — the
+// 10s budget blew on a page whose `load` only fired at ~12s, even though the DOM
+// was interactive almost immediately. So: resolve on "domcontentloaded" (DOM parsed
+// + scripts available), then give the full `load` a best-effort grace window but
+// never fail on it. SETTLE_MS after this lets first paints land. Returns the nav
+// response so callers can re-check the final URL against the SSRF policy.
+async function gotoReady(page: Page, url: string) {
+ const response = await page.goto(url, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("load", { timeout: 2_000 }).catch(() => {});
+ return response;
+}
+
+async function assertRecipeNavigationPolicy(recipe: Recipe, allowPrivateNetwork: boolean): Promise {
+ for (const scene of recipe.scenes) {
+ await assertSafeNavigationUrl(scene.entry.url, { allowPrivateNetwork });
+ for (const action of [...scene.entry.prelude, ...scene.actions]) {
+ if (action.kind === "goto" && action.url) {
+ await assertSafeNavigationUrl(action.url, { allowPrivateNetwork });
+ }
+ }
+ }
+}
+
export async function record(opts: RecordOptions): Promise {
const { recipe, outDir } = opts;
const captureFrames = opts.captureFrames ?? true;
+ const allowPrivateNetwork = opts.allowPrivateNetwork ?? false;
const rng = makeRng(opts.seed ?? 1);
+ await assertRecipeNavigationPolicy(recipe, allowPrivateNetwork);
+
mkdirSync(join(outDir, "frames"), { recursive: true });
// launch is the only setup outside try/finally; everything else (newPage,
@@ -81,6 +111,10 @@ export async function record(opts: RecordOptions): Promise {
const frameIndex: FrameIndexEntry[] = [];
let firstFrameStamp = -1;
let frameCounter = 0;
+ // true while an inter-scene navigation is in flight: the page is blank/white
+ // mid-reload, and capturing those frames makes the video FLASH at every scene
+ // change. Skip them — the renderer holds the last good frame across the gap.
+ let isNavigating = false;
let writeErrors = 0;
let lastWrite: Promise = Promise.resolve();
let signalFirstFrame: () => void = () => {};
@@ -114,6 +148,19 @@ export async function record(opts: RecordOptions): Promise {
async function targetBox(selector: string): Promise<{ x: number; y: number; w: number; h: number }> {
const loc = page.locator(selector).first();
await loc.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS });
+ // scroll the element INTO the viewport before targeting it. Without this,
+ // boundingBox returns document coordinates for below/above-fold elements
+ // (e.g. y=6549 or y=-3883), the cursor + camera then aim off-frame and the
+ // shot is pure background. Scrolling is also how a single-viewport recording
+ // reveals different parts of a long page. (Found on the first live run.)
+ const pre = await loc.boundingBox();
+ const alreadyInView =
+ !!pre && pre.y >= 0 && pre.y + pre.height <= VIEWPORT.height && pre.x >= 0;
+ await loc.scrollIntoViewIfNeeded({ timeout: ACTION_TIMEOUT_MS });
+ // settle ONLY when a scroll actually happened — an unconditional sleep adds
+ // wall-time to every action, tipping in-view actions into the overrun path
+ // and breaking the scheduled-timeline determinism contract on fixtures
+ if (!alreadyInView) await sleep(350);
const box = await loc.boundingBox();
if (!box) throw new Error(`selector "${selector}" has no bounding box`);
return { x: box.x, y: box.y, w: box.width, h: box.height };
@@ -126,7 +173,9 @@ export async function record(opts: RecordOptions): Promise {
switch (a.kind) {
case "goto": {
if (!a.url) throw new Error("goto action requires url");
- await page.goto(a.url, { timeout: ACTION_TIMEOUT_MS, waitUntil: "load" });
+ await assertSafeNavigationUrl(a.url, { allowPrivateNetwork });
+ const response = await gotoReady(page, a.url);
+ await assertSafeNavigationUrl(a.url, { allowPrivateNetwork, finalUrl: response?.url() ?? page.url() });
break;
}
case "wait":
@@ -177,6 +226,26 @@ export async function record(opts: RecordOptions): Promise {
bbox: [box.x, box.y, box.w, box.h], selector: a.selector,
textLen: [...text].length, // code points, matching the for...of insertion
});
+ if (a.submit) {
+ // Many query inputs only reveal their payoff on submit (a form's
+ // submit handler / an Enter keydown). Typing alone leaves the app in
+ // its idle state — the video would show a filled box and no result.
+ await page.keyboard.press("Enter");
+ }
+ }
+ // 4b — frame the result, not the cursor: if this action names a result
+ // region, resolve its box AFTER the action (and a settle for the payoff
+ // to render) and attach it to the event. The renderer prefers it over
+ // the interaction bbox, so the camera holds on the graph/results the
+ // action produced — not the input box. Unresolvable → silently skip.
+ if (a.focus_selector) {
+ await sleep(SETTLE_MS);
+ const fb = await page.locator(a.focus_selector).first().boundingBox().catch(() => null);
+ const ev = events[events.length - 1];
+ if (fb && fb.width > 4 && fb.height > 4 && ev &&
+ (ev.type === "click" || ev.type === "type" || ev.type === "hover")) {
+ ev.focus_bbox = [fb.x, fb.y, fb.width, fb.height];
+ }
}
break;
}
@@ -220,6 +289,11 @@ export async function record(opts: RecordOptions): Promise {
// write in flight) and a failed write can never be silently indexed
cdp.on("Page.screencastFrame", (ev) => {
lastWrite = (async () => {
+ // drop blank frames captured mid-navigation (the scene-change flash)
+ if (isNavigating) {
+ await cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {});
+ return;
+ }
const stampMs = (ev.metadata.timestamp ?? 0) * 1000;
if (firstFrameStamp < 0) {
firstFrameStamp = stampMs;
@@ -241,7 +315,9 @@ export async function record(opts: RecordOptions): Promise {
// navigate to first scene's entry before starting capture, so frame 0 is content
const firstScene = recipe.scenes[0];
if (!firstScene) throw new Error("recipe has no scenes");
- await page.goto(firstScene.entry.url, { timeout: ACTION_TIMEOUT_MS, waitUntil: "load" });
+ await assertSafeNavigationUrl(firstScene.entry.url, { allowPrivateNetwork });
+ const firstResponse = await gotoReady(page, firstScene.entry.url);
+ await assertSafeNavigationUrl(firstScene.entry.url, { allowPrivateNetwork, finalUrl: firstResponse?.url() ?? page.url() });
await sleep(SETTLE_MS); // `load` ≠ ready: let hydration/fonts/paints settle
if (captureFrames) {
@@ -277,9 +353,21 @@ export async function record(opts: RecordOptions): Promise {
try {
if (i > 0) {
- await page.goto(scene.entry.url, { timeout: ACTION_TIMEOUT_MS, waitUntil: "load" });
- await sleep(SETTLE_MS);
- // timestamp canon (PR #1 review): when nav finishes EARLY, dwell out
+ await assertSafeNavigationUrl(scene.entry.url, { allowPrivateNetwork });
+ // suppress capture across the reload so the blank page never lands in
+ // the footage (the scene-change flash); resume once it has painted.
+ // MUST reset in finally: if gotoReady/assert throws, leaving this true
+ // would make the screencast handler drop EVERY subsequent frame and
+ // freeze the rest of the video on the previous scene.
+ isNavigating = true;
+ try {
+ const response = await gotoReady(page, scene.entry.url);
+ await assertSafeNavigationUrl(scene.entry.url, { allowPrivateNetwork, finalUrl: response?.url() ?? page.url() });
+ await sleep(SETTLE_MS);
+ } finally {
+ isNavigating = false;
+ }
+ // Timestamp canon: when nav finishes early, dwell out
// the unused allowance in WALL time so pixels and schedule stay in
// lockstep — advancing only the clock made the footage run ~1s ahead
// of every logged event after a fast local navigation
@@ -294,8 +382,7 @@ export async function record(opts: RecordOptions): Promise {
}
for (const a of scene.entry.prelude) await runAction(a);
for (const a of scene.actions) await runAction(a);
- // hold the scene's final frame (PR #1 review: was validated + budgeted
- // by the schema but never executed)
+ // Hold the scene's final frame; it is validated and budgeted by the schema.
if (scene.hold_ms > 0) {
await sleep(scene.hold_ms);
clock += scene.hold_ms;
@@ -337,6 +424,10 @@ export async function record(opts: RecordOptions): Promise {
};
writeFileSync(join(outDir, "events.json"), JSON.stringify(eventLog, null, 2));
+ // CDP screencast timestamps can arrive/write with tiny ordering jitter across
+ // platforms. The renderer consumes by source timestamp, not filename order,
+ // so persist a monotonic index instead of failing later in render.
+ frameIndex.sort((a, b) => a.t_source - b.t_source);
writeFileSync(join(outDir, "frames-index.json"), JSON.stringify(frameIndex));
return { eventLog, frameCount: frameIndex.length, failedScenes, aborted, outDir };
diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts
index dcde2fc..a68a36c 100644
--- a/src/cli/doctor.ts
+++ b/src/cli/doctor.ts
@@ -65,7 +65,7 @@ const checks: Check[] = [
{
// render needs the FULL chromium channel (the headless shell has no
// WebCodecs) — a doctor that only checks the shell passes while render
- // cannot launch (review: P2)
+ // cannot launch
name: "full chromium (render)",
run: async () => {
try {
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 28073db..d0d6e09 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -34,10 +34,12 @@ async function main(): Promise {
recipe: { type: "string" },
out: { type: "string" },
seed: { type: "string" },
+ "block-private-network": { type: "boolean" },
+ "allow-private-network": { type: "boolean" }, // deprecated no-op
},
});
if (!values.recipe) {
- console.error("usage: supercut record --recipe [--out ] [--seed ]");
+ console.error("usage: supercut record --recipe [--out ] [--seed ] [--block-private-network]");
return 1;
}
const { readFileSync } = await import("node:fs");
@@ -48,7 +50,12 @@ async function main(): Promise {
const outDir = values.out ?? "out/take";
console.log(`recording ${recipe.scenes.length} scene(s) from ${recipe.app_url} → ${outDir}`);
const t0 = Date.now();
- const res = await record({ recipe, outDir, seed: values.seed ? Number(values.seed) : 1 });
+ const seed = values.seed === undefined ? 1 : Number(values.seed);
+ if (!Number.isInteger(seed) || seed < 0) {
+ console.error(`invalid --seed "${values.seed}" (expected a non-negative integer)`);
+ return 1;
+ }
+ const res = await record({ recipe, outDir, seed, allowPrivateNetwork: !values["block-private-network"] });
console.log(
`done in ${((Date.now() - t0) / 1000).toFixed(1)}s — ${res.frameCount} frames, ` +
`${res.eventLog.events.length} events` +
@@ -92,38 +99,85 @@ async function main(): Promise {
options: {
url: { type: "string" },
repo: { type: "string" },
+ app: { type: "string" },
out: { type: "string" },
bg: { type: "string" },
seed: { type: "string" },
model: { type: "string" },
"no-vision": { type: "boolean" },
+ "env-file": { type: "string" },
+ // private/localhost is ALLOWED BY DEFAULT — filming your own local
+ // dev app is the #1 use case. --block-private-network opts into the
+ // SSRF guard (for untrusted/public targets). --allow-private-network
+ // kept as a deprecated no-op for back-compat.
+ "block-private-network": { type: "boolean" },
+ "allow-private-network": { type: "boolean" },
+ // fail-safe OFF: destructive controls (Delete, Pay, …) are excluded
+ // from the inventory by default so the director can't script a real
+ // harmful action on the live app. Opt in only when you trust the target.
+ "allow-destructive": { type: "boolean" },
+ yes: { type: "boolean" },
},
});
if (!values.url) {
console.error(
"usage: supercut generate --url [--repo ] [--out ] " +
- "[--bg ] [--seed ] [--model ] [--no-vision]",
+ "[--bg ] [--seed ] [--model ] [--env-file ] [--block-private-network] [--allow-destructive] [--no-vision]",
);
return 1;
}
- const apiKey = process.env.OPENROUTER_API_KEY ?? process.env.SUPERCUT_API_KEY ?? "";
- if (!apiKey) {
+ const { loadDotEnv, resolveProvider } = await import("../director/config.js");
+ const { generate } = await import("../director/generate.js");
+ const envLoad = loadDotEnv(values["env-file"] ?? ".env");
+ // L2: a missing .env is fine (reason "not found"), but a file that EXISTED
+ // and failed to PARSE is a real error — surface it even without verbose so
+ // a malformed .env isn't silently swallowed (user otherwise sees only a
+ // downstream "no API key").
+ if (envLoad.reason === "not found") {
+ if (process.env.SUPERCUT_VERBOSE) console.error(`env: ${envLoad.path} ${envLoad.reason}`);
+ } else if (envLoad.reason) {
+ console.error(`env: failed to parse ${envLoad.path} — ${envLoad.reason}`);
+ }
+ const seed = values.seed === undefined ? undefined : Number(values.seed);
+ if (seed !== undefined && (!Number.isInteger(seed) || seed < 0)) {
+ console.error(`invalid --seed "${values.seed}" (expected a non-negative integer)`);
+ return 1;
+ }
+ // privacy notice (informational, NOT a gate — blocking the primary
+ // command on --yes was a usability regression). --yes silences it.
+ if (!values.yes) {
console.error(
- "generate needs an LLM: set OPENROUTER_API_KEY (one key, many models — https://openrouter.ai/keys).\n" +
+ "note: generate sends crawled DOM text" +
+ (values.repo ? " + repo notes" : "") +
+ " to your LLM provider; in vision mode, screenshots of your app are " +
+ "uploaded too. (record/render need no LLM.)",
+ );
+ }
+ let provider;
+ try {
+ provider = resolveProvider(process.env, { ...(values.model ? { model: values.model } : {}) });
+ } catch (err) {
+ console.error(
+ `${err instanceof Error ? err.message : err}\n` +
"No key? `supercut record` + `supercut render` work fully without one.",
);
return 1;
}
- const { OpenRouterClient } = await import("../director/llm.js");
- const { generate } = await import("../director/generate.js");
+ console.log(`director: ${provider.summary}`);
const res = await generate({
- llm: new OpenRouterClient({ apiKey, ...(values.model ? { model: values.model } : {}) }),
+ llm: provider.client,
url: values.url,
outDir: values.out ?? "out/generate",
+ // --no-vision forces off; otherwise follow the provider's capability
+ vision: values["no-vision"] ? false : provider.vision,
...(values.repo ? { repoPath: values.repo } : {}),
+ ...(values.app ? { appName: values.app } : {}),
...(values.bg ? { background: values.bg } : {}),
- ...(values.seed ? { seed: Number(values.seed) } : {}),
- ...(values["no-vision"] ? { noVision: true } : {}),
+ ...(seed !== undefined ? { seed } : {}),
+ // default ALLOW; only --block-private-network engages the SSRF guard
+ allowPrivateNetwork: !values["block-private-network"],
+ // default OFF; --allow-destructive opts into filming destructive controls
+ allowDestructive: !!values["allow-destructive"],
});
console.log(`\nsupercut: ${res.outFile} (${res.recipe.scenes.length} scenes, ${res.retakes} re-take(s))`);
return 0;
diff --git a/src/director/analyze.ts b/src/director/analyze.ts
index 4223d89..7df29c3 100644
--- a/src/director/analyze.ts
+++ b/src/director/analyze.ts
@@ -8,14 +8,27 @@
import { z } from "zod";
import { extractJson, type ChatPart, type LlmClient } from "./llm.js";
import type { PageDigest } from "./inventory.js";
+import { redactForPrompt } from "../security/redaction.js";
export const appAnalysis = z.object({
product_summary: z.string().min(10).max(600),
+ /** the brand/product name for the title + close cards (e.g. "Meridian") */
+ product_name: z.string().min(2).max(40),
+ /** the launch HOOK — the problem/promise the video opens on, in the
+ * customer's words, not a feature ("Three of your sites bleed cash. Which?").
+ * This is what removes ambiguity about what the video is selling. */
+ headline: z.string().min(8).max(80),
+ /** the closing line under the product name (e.g. "The operating record") */
+ tagline: z.string().min(4).max(60),
money_moments: z
.array(
z.object({
title: z.string().min(3).max(80),
why: z.string().min(5).max(300),
+ /** ONE benefit line shown over this beat — what the viewer GAINS here,
+ * imperative/outcome voice, NOT a feature label. "Record a location" is
+ * a label; "Drop in every site in seconds" is a caption. */
+ caption: z.string().min(4).max(52),
page_url: z.string(),
/** selectors (from the inventory) involved in showing this moment */
elements: z.array(z.string()).min(1).max(6),
@@ -27,15 +40,56 @@ export const appAnalysis = z.object({
export type AppAnalysis = z.infer;
+export function validateAnalysis(raw: unknown, digests: PageDigest[]): AppAnalysis {
+ const parsed = appAnalysis.parse(raw);
+ const byPage = new Map(digests.map((d) => [d.url, new Set(d.inventory.map((i) => i.selector))]));
+ // Models often answer with a relative path ("/setup") instead of the full
+ // crawled URL. Coerce by pathname match so a correct beat isn't rejected on a
+ // formatting nit — downstream (script.ts) needs the full crawled URL.
+ const byPathname = new Map();
+ for (const d of digests) {
+ try { byPathname.set(new URL(d.url).pathname.replace(/\/$/, "") || "/", d.url); } catch { /* skip */ }
+ }
+ for (const moment of parsed.money_moments) {
+ if (!byPage.has(moment.page_url)) {
+ let key = moment.page_url;
+ try { key = new URL(moment.page_url, digests[0]?.url ?? "http://localhost").pathname; } catch { /* keep */ }
+ const full = byPathname.get((key.replace(/\/$/, "") || "/"));
+ if (full) moment.page_url = full;
+ }
+ const selectors = byPage.get(moment.page_url);
+ if (!selectors) {
+ throw new Error(`money moment "${moment.title}" page_url "${moment.page_url}" is not a crawled page`);
+ }
+ for (const selector of moment.elements) {
+ if (!selectors.has(selector)) {
+ throw new Error(`money moment "${moment.title}" selector "${selector}" is not in the inventory for ${moment.page_url}`);
+ }
+ }
+ }
+ return parsed;
+}
+
function digestText(d: PageDigest): string {
const inv = d.inventory
- .map((i) => ` ${i.selector} [${i.tag}] "${i.text}"${i.href ? ` → ${i.href}` : ""}${i.hidden ? " (HIDDEN until revealed)" : ""}`)
+ .map((i) => ` ${i.selector} [${i.tag}] "${redactForPrompt(i.text)}"${i.href ? ` → ${redactForPrompt(i.href)}` : ""}${i.hidden ? " (HIDDEN until revealed)" : ""}`)
.join("\n");
return `PAGE ${d.url}\ntitle: ${d.title}\nheadings: ${d.headings.join(" | ")}\nelements:\n${inv}`;
}
-const SYSTEM = `You are the director of a 60-second product launch video. You study a web product and pick the 2-4 "money moments" — the interactions that make a viewer instantly understand why this product is good. Prefer moments with visible payoff (something appears, changes, or completes). Respond ONLY with a JSON object matching:
-{ "product_summary": string, "money_moments": [{ "title": string, "why": string, "page_url": string (one of the crawled page URLs), "elements": [selector strings COPIED EXACTLY from the element inventory] }] }`;
+const SYSTEM = `You are the director AND copywriter of a 60-second product launch video (Screen-Studio / ChatGPT-launch style), not a website tour. You study a web product and turn it into a PERSUASIVE STORY with a crystal-clear message: a viewer must understand within seconds what problem it solves and why it's good. Ambiguity is failure.
+
+Write the story as a problem → solution → payoff arc:
+- headline: the HOOK. Open on the customer's PAIN or the promise, in their words — not a feature. ("You run 12 sites. Three bleed cash — which?") This single line must make the whole video unambiguous.
+- money_moments (2-4), ordered as the storyboard:
+ 1. hook beat: the first move that starts solving the problem
+ 2. proof beat: the core workflow / differentiator
+ 3. payoff beat: the most visual result — the moment the value lands
+- For EACH beat write a "caption": ONE short benefit line (≤52 chars) in outcome voice — what the viewer GAINS, never a feature label. "Record a location" is a label (BAD). "Drop in every site in seconds" is a caption (GOOD). "See ranked revenue" is a label (BAD). "Your weakest sites, surfaced instantly" is a caption (GOOD).
+- product_name: the brand name for the title/close cards. tagline: the closing line under it.
+
+Prefer beats with visible payoff (something appears, changes, completes). The "title" field stays a short internal label; the "caption" is the on-screen copy and must be benefit-framed. Respond ONLY with a JSON object matching:
+{ "product_summary": string, "product_name": string, "headline": string, "tagline": string, "money_moments": [{ "title": string, "caption": string, "why": string, "page_url": string (one crawled URL), "elements": [selector strings COPIED EXACTLY from the inventory] }] }`;
export async function analyzeApp(
llm: LlmClient,
@@ -61,9 +115,11 @@ export async function analyzeApp(
const user: ChatPart[] = feedback
? [...parts, { type: "text", text: `Your previous response was invalid: ${feedback}. Return corrected JSON only.` }]
: parts;
- const raw = await llm.chat({ system: SYSTEM, user, json: true });
+ // generous budget: a richer source-seeded crawl (many pages) means a bigger
+ // prompt AND a bigger response; 4k truncated mid-JSON on real apps
+ const raw = await llm.chat({ system: SYSTEM, user, json: true, maxTokens: 8000 });
try {
- return appAnalysis.parse(extractJson(raw));
+ return validateAnalysis(extractJson(raw), digests);
} catch (err) {
feedback = err instanceof Error ? err.message.slice(0, 500) : String(err);
}
diff --git a/src/director/config.ts b/src/director/config.ts
new file mode 100644
index 0000000..4468b24
--- /dev/null
+++ b/src/director/config.ts
@@ -0,0 +1,164 @@
+/**
+ * Provider config — resolves which LLM the director uses from explicit env
+ * input (a loaded `.env` file or real env vars). OpenAI-compatible: works with
+ * OpenRouter, DeepSeek, or a custom endpoint.
+ */
+import { existsSync, readFileSync } from "node:fs";
+import { OpenAICompatibleClient, type LlmClient } from "./llm.js";
+
+export type ProviderName = "deepseek" | "openrouter" | "custom";
+
+export interface ProviderEnv {
+ SUPERCUT_PROVIDER?: string;
+ SUPERCUT_API_KEY?: string;
+ OPENROUTER_API_KEY?: string;
+ DEEPSEEK_API_KEY?: string;
+ SUPERCUT_LLM_BASE_URL?: string;
+ SUPERCUT_MODEL?: string;
+ SUPERCUT_VISION?: string;
+}
+
+export interface ProviderOverrides {
+ provider?: ProviderName;
+ model?: string;
+ baseUrl?: string;
+ vision?: boolean;
+}
+
+export interface ResolvedProvider {
+ client: LlmClient;
+ provider: ProviderName;
+ model: string;
+ baseUrl: string;
+ vision: boolean;
+ summary: string;
+}
+
+const DEEPSEEK_BASE = "https://api.deepseek.com";
+const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
+const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
+const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-pro";
+
+function parseProvider(value: string | undefined): ProviderName | undefined {
+ if (!value) return undefined;
+ const v = value.toLowerCase();
+ if (v === "deepseek" || v === "openrouter" || v === "custom") return v;
+ throw new Error(`unknown SUPERCUT_PROVIDER "${value}" (expected deepseek, openrouter, or custom)`);
+}
+
+function parseVision(value: string | undefined): boolean | undefined {
+ if (value === undefined) return undefined;
+ const v = value.toLowerCase();
+ if (v === "true" || v === "1" || v === "yes") return true;
+ if (v === "false" || v === "0" || v === "no") return false;
+ throw new Error(`invalid SUPERCUT_VISION "${value}" (expected true or false)`);
+}
+
+export function resolveProvider(
+ env: ProviderEnv = process.env,
+ overrides: ProviderOverrides = {},
+): ResolvedProvider {
+ const explicitProvider = overrides.provider ?? parseProvider(env.SUPERCUT_PROVIDER);
+ const providerKeys = [
+ env.DEEPSEEK_API_KEY ? "deepseek" : "",
+ env.OPENROUTER_API_KEY ? "openrouter" : "",
+ ].filter(Boolean);
+
+ if (!explicitProvider && providerKeys.length > 1 && !env.SUPERCUT_API_KEY) {
+ throw new Error("multiple provider keys found; set SUPERCUT_PROVIDER to deepseek or openrouter");
+ }
+
+ let provider: ProviderName;
+ if (explicitProvider) provider = explicitProvider;
+ else if (env.DEEPSEEK_API_KEY) provider = "deepseek";
+ else if (env.OPENROUTER_API_KEY) provider = "openrouter";
+ else if (env.SUPERCUT_API_KEY || overrides.baseUrl || env.SUPERCUT_LLM_BASE_URL) provider = "custom";
+ else {
+ throw new Error(
+ "no API key found. Set DEEPSEEK_API_KEY (or OPENROUTER_API_KEY / SUPERCUT_API_KEY) " +
+ "in a .env file. See .env.example.",
+ );
+ }
+
+ const apiKey =
+ provider === "deepseek" ? env.DEEPSEEK_API_KEY || env.SUPERCUT_API_KEY || "" :
+ provider === "openrouter" ? env.OPENROUTER_API_KEY || env.SUPERCUT_API_KEY || "" :
+ env.SUPERCUT_API_KEY || env.DEEPSEEK_API_KEY || env.OPENROUTER_API_KEY || "";
+ if (!apiKey) throw new Error(`no API key found for provider ${provider}`);
+
+ const baseUrl = overrides.baseUrl ?? env.SUPERCUT_LLM_BASE_URL ?? (
+ provider === "deepseek" ? DEEPSEEK_BASE : provider === "openrouter" ? OPENROUTER_BASE : ""
+ );
+ if (!baseUrl) throw new Error("SUPERCUT_LLM_BASE_URL is required when SUPERCUT_PROVIDER=custom");
+
+ const model = overrides.model ?? env.SUPERCUT_MODEL ?? (
+ provider === "deepseek" ? DEFAULT_DEEPSEEK_MODEL :
+ provider === "openrouter" ? DEFAULT_OPENROUTER_MODEL : ""
+ );
+ if (!model) throw new Error("SUPERCUT_MODEL is required when SUPERCUT_PROVIDER=custom");
+
+ const envVision = parseVision(env.SUPERCUT_VISION);
+ const vision = overrides.vision ?? envVision ?? (provider !== "deepseek");
+ if (provider === "deepseek" && vision) {
+ throw new Error("vision cannot be enabled for DeepSeek text-only models; use OpenRouter/custom vision model or SUPERCUT_VISION=false");
+ }
+
+ const client = new OpenAICompatibleClient({
+ apiKey,
+ model,
+ baseUrl,
+ providerLabel: provider,
+ vision,
+ });
+
+ return {
+ client,
+ provider,
+ model,
+ baseUrl,
+ vision,
+ summary: `${client.label} @ ${baseUrl} · vision ${vision ? "on" : "off (DOM-only)"}`,
+ };
+}
+
+export interface DotEnvLoadResult {
+ path: string;
+ loaded: boolean;
+ reason?: string;
+}
+
+/** Best-effort .env loader. Uses native process.loadEnvFile when present
+ * (Node 20.12+ / 21.7+), else a minimal parser so the package's `node >=20`
+ * engine range actually works on 20.0–20.11. Never silently pretends success. */
+export function loadDotEnv(path = ".env"): DotEnvLoadResult {
+ if (!existsSync(path)) return { path, loaded: false, reason: "not found" };
+ const native = (process as unknown as { loadEnvFile?: (p: string) => void }).loadEnvFile;
+ try {
+ if (typeof native === "function") {
+ native.call(process, path);
+ } else {
+ parseDotEnvInto(readFileSync(path, "utf8"), process.env);
+ }
+ return { path, loaded: true };
+ } catch (err) {
+ return { path, loaded: false, reason: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+/** Minimal KEY=VALUE .env parser (fallback for Node < 20.12). Skips blanks and
+ * `#` comments, strips matching surrounding quotes, never overrides an existing
+ * real environment variable. */
+function parseDotEnvInto(text: string, env: NodeJS.ProcessEnv): void {
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (!line || line.startsWith("#")) continue;
+ const eq = line.indexOf("=");
+ if (eq <= 0) continue;
+ const key = line.slice(0, eq).trim();
+ let val = line.slice(eq + 1).trim();
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+ val = val.slice(1, -1);
+ }
+ if (!(key in env)) env[key] = val;
+ }
+}
diff --git a/src/director/generate.ts b/src/director/generate.ts
index 488f17c..b785429 100644
--- a/src/director/generate.ts
+++ b/src/director/generate.ts
@@ -22,6 +22,9 @@ import { crawlApp, type PageDigest } from "./inventory.js";
import type { LlmClient } from "./llm.js";
import { applyVerdicts, deterministicChecks, visionQc, type SceneVerdict } from "./qc.js";
import { writeRecipe } from "./script.js";
+import { assertSafeNavigationUrl } from "../security/url-policy.js";
+import { redactForPrompt } from "../security/redaction.js";
+import { extractAppRoutes, routesToSeedAndNotes } from "./sourceRoutes.js";
const exec = promisify(execFile);
const MAX_RETAKES = 3;
@@ -30,11 +33,28 @@ export interface GenerateOptions {
llm: LlmClient;
url: string;
outDir: string;
+ /** path to the app's source. When given, supercut reads its routes/page
+ * components to understand the product and SEEDS the crawl with real routes
+ * so the director can drive into functional panels, not just the landing. */
repoPath?: string;
+ /** scope source-reading to one app in a monorepo (path-segment match) */
+ appName?: string;
background?: string;
seed?: number;
- /** skip vision QC (deterministic checks still run) */
+ /** model can see images: drives screenshot capture, analyze images, and the
+ * vision-QC pass. Off for text-only models (e.g. deepseek-chat) — the
+ * director then reads the DOM/inventory and QC uses deterministic checks. */
+ vision?: boolean;
+ /** @deprecated use vision:false */
noVision?: boolean;
+ /** allow localhost/RFC1918 navigation. Defaults to TRUE — filming your own
+ * local dev app is the primary use case. Pass false to engage the SSRF
+ * guard (untrusted/public targets). */
+ allowPrivateNetwork?: boolean;
+ /** opt-in: let the director see (and therefore script) destructive controls
+ * (Delete, Pay, …). OFF by default — fail-safe so a prompt-injected page
+ * can't steer a real harmful action on the live app. */
+ allowDestructive?: boolean;
log?: (msg: string) => void;
}
@@ -46,17 +66,39 @@ export interface GenerateResult {
verdictLog: SceneVerdict[][];
}
-async function preflight(url: string): Promise {
- // app reachable — error in seconds, never after 10 minutes of work
+async function preflight(url: string, allowPrivateNetwork: boolean): Promise {
+ // app reachable — error in seconds, never after 10 minutes of work.
+ // Follow redirects MANUALLY and validate EVERY hop BEFORE the request: a
+ // default `fetch` follows 3xx automatically, so a public URL that 302s to
+ // http://169.254.169.254/ (cloud metadata) or an RFC1918 host would already
+ // have made the internal request before any post-hoc check. SSRF-guard errors
+ // propagate as-is (a security failure, not "cannot reach"); only network
+ // errors get the friendly reachability message.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
try {
- const res = await fetch(url, { signal: ctrl.signal });
- if (res.status >= 500) throw new Error(`app at ${url} responded ${res.status}`);
- } catch (err) {
- throw new Error(
- `preflight: cannot reach ${url} — is the app running? (${err instanceof Error ? err.message : err})`,
- );
+ let current = url;
+ let status = 0;
+ for (let hop = 0; hop < 6; hop++) {
+ await assertSafeNavigationUrl(current, { allowPrivateNetwork });
+ let res: Response;
+ try {
+ res = await fetch(current, { signal: ctrl.signal, redirect: "manual" });
+ } catch (err) {
+ throw new Error(
+ `preflight: cannot reach ${current} — is the app running? (${err instanceof Error ? err.message : err})`,
+ );
+ }
+ status = res.status;
+ const loc = res.headers.get("location");
+ if (status >= 300 && status < 400 && loc) {
+ current = new URL(loc, current).href;
+ continue;
+ }
+ break;
+ }
+ if (status >= 500) throw new Error(`app at ${url} responded ${status}`);
+ if (status >= 300 && status < 400) throw new Error(`preflight: ${url} kept redirecting (loop?)`);
} finally {
clearTimeout(timer);
}
@@ -83,16 +125,56 @@ function repoNotes(repoPath: string): string | undefined {
export async function generate(opts: GenerateOptions): Promise {
const log = opts.log ?? ((m: string) => console.log(`[generate] ${m}`));
+ const vision = opts.vision !== undefined ? opts.vision : !(opts.noVision ?? false);
mkdirSync(opts.outDir, { recursive: true });
log("preflight…");
- await preflight(opts.url);
+ await preflight(opts.url, opts.allowPrivateNetwork ?? true);
- log("① analyze: crawling app…");
- const digests: PageDigest[] = await crawlApp(opts.url, { maxPages: 3 });
+ // read the app's source FIRST: derive real routes (seed the crawl so
+ // functional panels enter the inventory) + a product summary for the director
+ let seedUrls: string[] = [];
+ let sourceNotes: string | undefined;
+ if (opts.repoPath) {
+ const routes = extractAppRoutes(opts.repoPath, opts.appName ? { appName: opts.appName } : {});
+ if (routes.length > 0) {
+ const sn = routesToSeedAndNotes(routes, opts.url);
+ seedUrls = sn.seedUrls;
+ sourceNotes = sn.notes;
+ log(` source: ${routes.length} route(s) found, seeding ${seedUrls.length} into the crawl`);
+ } else {
+ log(` source: no routes detected at ${opts.repoPath} (crawling links only)`);
+ }
+ }
+
+ log(`① analyze: crawling app…${vision ? "" : " (DOM-only, text model)"}`);
+ // crawl the start page + every seeded route + a few link-discovered pages
+ const maxPages = Math.min(3 + seedUrls.length, 12);
+ const digests: PageDigest[] = await crawlApp(opts.url, {
+ maxPages,
+ screenshots: vision,
+ allowPrivateNetwork: opts.allowPrivateNetwork ?? true,
+ seedUrls,
+ allowDestructive: opts.allowDestructive ?? false,
+ });
log(` crawled ${digests.length} page(s), ${digests.reduce((n, d) => n + d.inventory.length, 0)} interactable elements`);
+ // LOUD, never silent: if we excluded destructive controls, say which — so a
+ // user whose hero action got filtered knows why and can opt back in.
+ const excluded = [...new Set(digests.flatMap((d) => d.excludedDestructive ?? []))];
+ if (excluded.length) {
+ log(` note: excluded ${excluded.length} destructive control(s) from filming — ${excluded.slice(0, 5).map((s) => `"${s}"`).join(", ")}${excluded.length > 5 ? "…" : ""}. Pass --allow-destructive to include them.`);
+ }
- const notes = opts.repoPath ? repoNotes(opts.repoPath) : undefined;
+ // analyze notes = source routes/summary + README/package.json. Both come
+ // from the app's source (string literals, README, package.json) — exactly
+ // where hardcoded tokens / internal URLs live — so redact them before egress,
+ // matching the redaction DOM text already gets (parity, no asymmetry).
+ const readme = opts.repoPath ? repoNotes(opts.repoPath) : undefined;
+ const notes =
+ [sourceNotes, readme]
+ .filter((s): s is string => Boolean(s))
+ .map(redactForPrompt)
+ .join("\n\n") || undefined;
const analysis = await analyzeApp(opts.llm, digests, notes);
log(` product: ${analysis.product_summary.slice(0, 100)}`);
for (const m of analysis.money_moments) log(` moment: ${m.title}`);
@@ -111,7 +193,7 @@ export async function generate(opts: GenerateOptions): Promise {
takeDir = join(opts.outDir, `take-${retakes}`);
rmSync(takeDir, { recursive: true, force: true });
log(`③ record: take ${retakes} (${recipe.scenes.length} scenes)…`);
- result = await record({ recipe, outDir: takeDir, seed: opts.seed ?? 1 });
+ result = await record({ recipe, outDir: takeDir, seed: opts.seed ?? 1, allowPrivateNetwork: opts.allowPrivateNetwork ?? true });
if (result.aborted) {
throw new Error(
`capture aborted: scenes failed [${result.failedScenes.join(", ")}] — app state may not match the recipe`,
@@ -120,7 +202,7 @@ export async function generate(opts: GenerateOptions): Promise {
log("④ qc: deterministic checks…");
const verdicts = deterministicChecks(result);
- if (!opts.noVision) {
+ if (vision) {
log("④ qc: vision pass…");
verdicts.push(...await visionQc(opts.llm, takeDir, result.eventLog));
}
@@ -137,7 +219,7 @@ export async function generate(opts: GenerateOptions): Promise {
if (retakes >= MAX_RETAKES) {
log(` re-take budget exhausted (${MAX_RETAKES}) — proceeding with the take as recorded`);
}
- // PR #2 review: do NOT adopt the patched recipe here. `takeDir` was
+ // Do NOT adopt the patched recipe here. `takeDir` was
// recorded from the CURRENT `recipe`; writing applied.recipe would make
// recipe.json/report describe scenes/holds that were never filmed (and
// for cuts, omit a scene that is still in the rendered video). The
@@ -154,6 +236,10 @@ export async function generate(opts: GenerateOptions): Promise {
log("⑤ render…");
const outFile = join(opts.outDir, "final.mp4");
+ // NO on-screen text. supercut is a pure product demo — the product is the
+ // whole story. The cinematic camera (zoom-to-action, frame-the-result) carries
+ // it; nothing is ever drawn over the app. (The director still writes copy in
+ // the report for reference, but it is deliberately NOT rendered.)
const renderRes = await renderTake({
takeDir,
outFile,
@@ -166,5 +252,9 @@ export async function generate(opts: GenerateOptions): Promise {
JSON.stringify({ analysis, recipe, retakes, verdictLog, llm: opts.llm.label }, null, 2),
);
+ // best-effort cost telemetry (reporting only — no budget cap)
+ const tokens = opts.llm.tokensUsed;
+ log(`LLM usage: ${tokens !== undefined ? `~${tokens} tokens` : "unavailable"}`);
+
return { outFile, recipe, analysis, retakes, verdictLog };
}
diff --git a/src/director/index.ts b/src/director/index.ts
index 335e052..70a351f 100644
--- a/src/director/index.ts
+++ b/src/director/index.ts
@@ -1,4 +1,5 @@
export * from "./llm.js";
+export * from "./config.js";
export * from "./inventory.js";
export * from "./analyze.js";
export * from "./script.js";
diff --git a/src/director/inventory.ts b/src/director/inventory.ts
index a9944e8..a67fda2 100644
--- a/src/director/inventory.ts
+++ b/src/director/inventory.ts
@@ -5,6 +5,7 @@
* construction: it fails the whitelist check and bounces back for retry.
*/
import { chromium, type Browser, type Page } from "playwright";
+import { assertSafeNavigationUrl } from "../security/url-policy.js";
export interface InventoryItem {
/** Playwright-compatible selector, verified to resolve on the page */
@@ -18,18 +19,110 @@ export interface InventoryItem {
hidden?: boolean;
}
+/** A large, stable container the camera can FRAME to show a result (a graph,
+ * a results list, a detail panel). Not part of the interactable whitelist —
+ * these are camera targets (focus_selector), not click targets. The payoff of
+ * most apps appears INSIDE one of these after an action, so framing it is how
+ * the video holds on the result instead of the input box that produced it. */
+export interface RegionItem {
+ selector: string;
+ tag: string;
+ text: string;
+ bbox: { x: number; y: number; w: number; h: number };
+}
+
export interface PageDigest {
url: string;
title: string;
headings: string[];
inventory: InventoryItem[];
+ /** framable result/content regions (focus_selector candidates) */
+ regions: RegionItem[];
+ /** labels of destructive controls excluded from the inventory (fail-safe) —
+ * surfaced so the exclusion is LOUD, never silent. Empty/absent when none. */
+ excludedDestructive?: string[];
/** viewport screenshot for the analyze stage's vision pass */
screenshotB64?: string;
}
const cssEscape = (s: string) => s.replace(/["\\]/g, "\\$&");
-async function digestPage(page: Page, withScreenshot: boolean): Promise {
+/**
+ * Fail-safe-by-default destructive-action lexicon. The director scripts clicks
+ * and typing on the LIVE app, so a prompt-injected page (or just an unlucky
+ * "payoff" beat) could fire a real, irreversible action. We exclude any element
+ * whose visible text / aria-label / value matches this from the inventory
+ * entirely — so the LLM never even SEES a destructive control and structurally
+ * cannot reference one (the script stage may only use inventory selectors).
+ * Opt back in with `allowDestructive`. Word-boundary anchored so legitimate
+ * non-destructive actions (Sign in, Submit, Add, Save, Open, View, Create,
+ * Next, Continue) do NOT match.
+ */
+// Deliberately NARROW: only genuinely irreversible / data-destroying /
+// money-committing verbs. We do NOT match common, reversible, or hero-action
+// words (send, remove, reset, disable, archive, unsubscribe, transfer) — those
+// are exactly the core interactions a launch video exists to show, and silently
+// dropping a chat app's "Send" or a list's "Remove" would gut the demo. The set
+// here is "things you'd almost never want filmed and that can't be undone."
+export const DESTRUCTIVE_RE =
+ /\b(delete|deactivate|wipe|erase|destroy|cancel\s+(subscription|account|plan)|pay|purchase|buy\s+now|checkout|place\s+order|withdraw|confirm\s+(payment|order)|revoke)\b/i;
+
+// links the crawler must NOT navigate to: file downloads (PDF/zip/images/docs),
+// and non-http protocols. Navigating to a PDF triggers a download that crashes
+// page.goto.
+const NON_HTML_EXT =
+ /\.(pdf|zip|tar|gz|dmg|exe|pkg|csv|xlsx?|docx?|pptx?|png|jpe?g|gif|svg|webp|mp4|mov|webm|mp3|wav|woff2?|ttf)$/i;
+
+function isCrawlable(u: URL): boolean {
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
+ if (NON_HTML_EXT.test(u.pathname)) return false;
+ return true;
+}
+
+/**
+ * Collect framable result/content regions: large, visible containers (a chart
+ * area, a results list, the main panel) the camera can hold on to show a
+ * payoff. These are NOT click targets — they widen the director's camera
+ * vocabulary so a scene can frame the RESULT, not the input that produced it.
+ */
+async function collectRegions(page: Page): Promise {
+ // id'd containers come first (stable selector); then structural landmarks and
+ // visual surfaces (svg/canvas) where charts/maps/graphs render.
+ const els = page.locator(
+ "main, [role=main], [role=region], section[id], [id] > svg, svg[id], canvas, " +
+ "div[id]",
+ );
+ const count = Math.min(await els.count(), 40);
+ const out: RegionItem[] = [];
+ const seen = new Set();
+ // a region must be a meaningful share of the viewport to be worth framing
+ const MIN_AREA = 1280 * 800 * 0.12;
+ for (let i = 0; i < count; i++) {
+ const el = els.nth(i);
+ const box = await el.boundingBox().catch(() => null);
+ if (!box || box.width * box.height < MIN_AREA) continue;
+ const tag = (await el.evaluate((n) => n.tagName).catch(() => "")).toLowerCase();
+ if (!tag) continue;
+ const id = await el.getAttribute("id").catch(() => null);
+ const role = await el.getAttribute("role").catch(() => null);
+ let selector: string;
+ if (id) selector = `#${id}`;
+ else if (tag === "main") selector = "main";
+ else if (role) selector = `[role="${cssEscape(role)}"]`;
+ else continue; // no stable handle — skip
+ if (seen.has(selector)) continue;
+ // must resolve uniquely so the camera frames the right box at capture time
+ const matches = await page.locator(selector).count().catch(() => 0);
+ if (matches !== 1) continue;
+ seen.add(selector);
+ const text = (await el.innerText().catch(() => "")).trim().replace(/\s+/g, " ").slice(0, 60);
+ out.push({ selector, tag, text, bbox: { x: box.x, y: box.y, w: box.width, h: box.height } });
+ }
+ // biggest first — the dominant content area is usually the intended payoff
+ return out.sort((a, b) => b.bbox.w * b.bbox.h - a.bbox.w * a.bbox.h).slice(0, 6);
+}
+
+async function digestPage(page: Page, withScreenshot: boolean, allowDestructive = false): Promise {
const title = await page.title();
const headings: string[] = [];
@@ -41,6 +134,7 @@ async function digestPage(page: Page, withScreenshot: boolean): Promise();
const els = page.locator(
"a[href], button, input, textarea, select, [role=button], [role=tab], " +
@@ -61,6 +155,7 @@ async function digestPage(page: Page, withScreenshot: boolean): Promise null);
const aria = await el.getAttribute("aria-label").catch(() => null);
const placeholder = await el.getAttribute("placeholder").catch(() => null);
+ const value = await el.getAttribute("value").catch(() => null);
const href = (await el.getAttribute("href").catch(() => null)) ?? undefined;
const text = (
(await el.innerText().catch(() => "")) ||
@@ -68,6 +163,14 @@ async function digestPage(page: Page, withScreenshot: boolean): Promise s && DESTRUCTIVE_RE.test(s))) {
+ if (text) excludedDestructive.push(text);
+ continue;
+ }
+
let selector: string;
if (id) selector = `#${id}`;
else if (aria) selector = `[aria-label="${cssEscape(aria)}"]`;
@@ -81,7 +184,7 @@ async function digestPage(page: Page, withScreenshot: boolean): Promise 1) {
if (!box) continue; // can't disambiguate a hidden duplicate — skip, don't guess
- // pick the CLOSEST nth-match (PR #2 review: a strict ±2px test can miss
+ // Pick the closest nth-match; a strict ±2px test can miss
// on sub-pixel rendering and silently fall back to nth=1 = wrong element).
// Cap the accepted distance so we never inventory a wildly-off element.
const MAX_OFFSET_PX = 20;
@@ -109,13 +212,19 @@ async function digestPage(page: Page, withScreenshot: boolean): Promise null);
if (shot) screenshotB64 = shot.toString("base64");
}
- return { url: page.url(), title, headings, inventory, ...(screenshotB64 ? { screenshotB64 } : {}) };
+ return {
+ url: page.url(), title, headings, inventory, regions,
+ ...(excludedDestructive.length ? { excludedDestructive } : {}),
+ ...(screenshotB64 ? { screenshotB64 } : {}),
+ };
}
/**
@@ -124,11 +233,25 @@ async function digestPage(page: Page, withScreenshot: boolean): Promise {
const maxPages = opts.maxPages ?? 3;
const screenshots = opts.screenshots ?? true;
+ const allowDestructive = opts.allowDestructive ?? false;
const origin = new URL(appUrl).origin;
+ const allowPrivateNetwork = opts.allowPrivateNetwork ?? false;
+ await assertSafeNavigationUrl(appUrl, { allowPrivateNetwork });
const browser: Browser = await chromium.launch({ headless: true });
try {
@@ -136,10 +259,27 @@ export async function crawlApp(
const digests: PageDigest[] = [];
const visited = new Set();
- const queue = [appUrl];
+ // block downloads outright so a stray file link can't hang/crash the crawl
+ const ctx = page.context();
+ await ctx.route("**/*", async (route) => {
+ const u = route.request().url();
+ try {
+ if (route.request().isNavigationRequest() && NON_HTML_EXT.test(new URL(u).pathname)) {
+ return route.abort();
+ }
+ } catch { /* fall through */ }
+ return route.continue();
+ });
+
+ // start page first, then source-derived routes (same-origin only), then
+ // link-discovered pages. Seeds ensure functional panels get crawled even
+ // when no points to them.
+ const queue = [appUrl, ...(opts.seedUrls ?? []).filter((u) => {
+ try { return new URL(u).origin === origin; } catch { return false; }
+ })];
while (queue.length > 0 && digests.length < maxPages) {
const target = queue.shift()!;
- // pathname + search (PR #2 review): pathname-only collapses query-routed
+ // Pathname + search: pathname-only collapses query-routed
// pages (/search?q=a vs ?q=b) and SPA filter/detail views, so the crawler
// would skip real money-moment pages. Hash is excluded (same document).
const u = new URL(target);
@@ -147,16 +287,26 @@ export async function crawlApp(
if (visited.has(key)) continue;
visited.add(key);
- await page.goto(target, { timeout: 15_000, waitUntil: "load" });
- await page.waitForTimeout(400); // settle: load ≠ ready
- const digest = await digestPage(page, screenshots);
+ // a single bad page (download, timeout, redirect off-origin) must not
+ // kill the whole crawl — skip it and keep going
+ try {
+ await assertSafeNavigationUrl(target, { allowPrivateNetwork });
+ const response = await page.goto(target, { timeout: 15_000, waitUntil: "load" });
+ await assertSafeNavigationUrl(target, { allowPrivateNetwork, finalUrl: response?.url() ?? page.url() });
+ await page.waitForTimeout(400); // settle: load ≠ ready
+ } catch (err) {
+ if (digests.length === 0 && queue.length === 0) throw err; // start page must load
+ continue;
+ }
+ const digest = await digestPage(page, screenshots, allowDestructive);
digests.push(digest);
for (const item of digest.inventory) {
if (!item.href) continue;
try {
const linked = new URL(item.href, target);
- if (linked.origin === origin && !visited.has(linked.pathname + linked.search)) {
+ if (linked.origin === origin && isCrawlable(linked) && !visited.has(linked.pathname + linked.search)) {
+ await assertSafeNavigationUrl(linked.href, { allowPrivateNetwork });
queue.push(linked.href);
}
} catch {
diff --git a/src/director/llm.ts b/src/director/llm.ts
index 1c36aaf..26e2605 100644
--- a/src/director/llm.ts
+++ b/src/director/llm.ts
@@ -1,6 +1,7 @@
/**
- * LLM access for the director stages — OpenRouter-first (one key, many
- * models; Engineering Decision #4), plain fetch, zero SDK dependencies.
+ * LLM access for the director stages — OpenAI-compatible, plain fetch, zero
+ * SDK dependencies. Works with OpenRouter, DeepSeek, or a custom compatible
+ * endpoint selected in config.ts.
*
* Every AI touchpoint in supercut goes through this interface, so tests can
* inject a stub and the whole generate pipeline runs without any API key.
@@ -21,32 +22,49 @@ export interface ChatOptions {
export interface LlmClient {
chat(opts: ChatOptions): Promise;
readonly label: string;
+ /** running total of tokens billed across this client's calls, when the
+ * provider reports usage. Optional: stubs and providers that omit usage
+ * leave it undefined (callers report "usage: unavailable"). */
+ readonly tokensUsed?: number | undefined;
}
-export interface OpenRouterConfig {
+export interface OpenAICompatibleConfig {
apiKey: string;
- /** override with SUPERCUT_MODEL; needs vision for analyze + QC */
- model?: string;
- baseUrl?: string;
+ model: string;
+ baseUrl: string;
+ providerLabel: string;
+ /** whether this provider/model accepts image parts */
+ vision: boolean;
}
-const DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
-
-export class OpenRouterClient implements LlmClient {
+export class OpenAICompatibleClient implements LlmClient {
private readonly apiKey: string;
private readonly model: string;
private readonly baseUrl: string;
+ private readonly vision: boolean;
readonly label: string;
+ /** best-effort token accounting: sum of provider-reported usage across calls.
+ * Stays undefined until the FIRST response that carries a usage block, so a
+ * provider that never reports usage leaves it undefined (→ "unavailable"). */
+ private _tokensUsed: number | undefined = undefined;
+ get tokensUsed(): number | undefined {
+ return this._tokensUsed;
+ }
- constructor(cfg: OpenRouterConfig) {
- if (!cfg.apiKey) throw new Error("OpenRouter API key is empty");
+ constructor(cfg: OpenAICompatibleConfig) {
+ if (!cfg.apiKey) throw new Error(`${cfg.providerLabel} API key is empty`);
this.apiKey = cfg.apiKey;
- this.model = cfg.model ?? process.env.SUPERCUT_MODEL ?? DEFAULT_MODEL;
- this.baseUrl = cfg.baseUrl ?? "https://openrouter.ai/api/v1";
- this.label = `openrouter:${this.model}`;
+ this.model = cfg.model;
+ this.baseUrl = cfg.baseUrl.replace(/\/$/, "");
+ this.vision = cfg.vision;
+ this.label = `${cfg.providerLabel}:${this.model}`;
}
async chat(opts: ChatOptions): Promise {
+ if (!this.vision && opts.user.some((p) => p.type === "image")) {
+ throw new Error(`${this.label} is text-only; refusing to send image parts`);
+ }
+
const content = opts.user.map((p) =>
p.type === "text"
? { type: "text" as const, text: p.text }
@@ -63,29 +81,46 @@ export class OpenRouterClient implements LlmClient {
};
let lastErr = "";
- for (let attempt = 0; attempt < 3; attempt++) {
- const res = await fetch(`${this.baseUrl}/chat/completions`, {
- method: "POST",
- headers: {
- authorization: `Bearer ${this.apiKey}`,
- "content-type": "application/json",
- "x-title": "supercut",
- },
- body: JSON.stringify(body),
- });
+ for (let attempt = 0; attempt < 4; attempt++) {
+ let res: Response;
+ try {
+ res = await fetch(`${this.baseUrl}/chat/completions`, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${this.apiKey}`,
+ "content-type": "application/json",
+ "x-title": "supercut",
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(240_000),
+ });
+ } catch (err) {
+ const cause = (err as { cause?: { code?: string; message?: string } })?.cause;
+ lastErr = `network: ${cause?.code ?? ""} ${cause?.message ?? (err instanceof Error ? err.message : String(err))}`.trim();
+ await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
+ continue;
+ }
if (res.ok) {
const data = (await res.json()) as {
- choices?: { message?: { content?: string } }[];
+ choices?: { message?: { content?: string; reasoning_content?: string } }[];
+ usage?: { total_tokens?: number; prompt_tokens?: number; completion_tokens?: number };
};
- const text = data.choices?.[0]?.message?.content;
+ // best-effort cost telemetry: prefer total_tokens, else sum prompt+completion
+ const u = data.usage;
+ const billed =
+ u?.total_tokens ??
+ (u?.prompt_tokens !== undefined || u?.completion_tokens !== undefined
+ ? (u?.prompt_tokens ?? 0) + (u?.completion_tokens ?? 0)
+ : undefined);
+ if (billed !== undefined) this._tokensUsed = (this._tokensUsed ?? 0) + billed;
+ const msg = data.choices?.[0]?.message;
+ const text = msg?.content || msg?.reasoning_content;
if (!text) throw new Error(`LLM returned an empty response (${this.label})`);
return text;
}
const snippet = (await res.text()).slice(0, 300);
- // auth/config errors fail FAST and clear (fail-fast preflight rule);
- // only rate limits and server errors retry
if (res.status === 401 || res.status === 403) {
- throw new Error(`LLM auth failed (${res.status}) — check OPENROUTER_API_KEY. ${snippet}`);
+ throw new Error(`LLM auth failed (${res.status}, ${this.label}) — check your API key. ${snippet}`);
}
if (res.status !== 429 && res.status < 500) {
throw new Error(`LLM request rejected (${res.status}, ${this.label}): ${snippet}`);
@@ -93,10 +128,14 @@ export class OpenRouterClient implements LlmClient {
lastErr = `${res.status}: ${snippet}`;
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
}
- throw new Error(`LLM unavailable after 3 attempts (${this.label}): ${lastErr}`);
+ throw new Error(`LLM unavailable after 4 attempts (${this.label}): ${lastErr}`);
}
}
+/** Backwards-compatible export name for older internal imports. */
+export const OpenRouterClient = OpenAICompatibleClient;
+export type OpenRouterConfig = OpenAICompatibleConfig;
+
/**
* Pull the first JSON object out of a model response — tolerates ```json
* fences and prose around the object, balanced-brace scan.
diff --git a/src/director/qc.ts b/src/director/qc.ts
index b2c12ec..7f49e02 100644
--- a/src/director/qc.ts
+++ b/src/director/qc.ts
@@ -43,8 +43,8 @@ export function deterministicChecks(result: RecordResult): SceneVerdict[] {
}
// dead air: >4s between consecutive interaction events inside a scene.
- // PR #2 review: this is INFORMATIONAL only (verdict "ok" + reason). hold_ms
- // adds time at the END of a scene — it cannot compress a MID-scene gap, so
+ // Informational only: hold_ms adds time at the END of a scene and cannot
+ // compress a MID-scene gap, so
// patching it was a no-op that just lengthened the scene. Mid-scene dead air
// comes from observed overrun on a slow app, and no frozen-surface lever
// (hold/zoom/cut) fixes it, nor does re-recording. We surface it in the
diff --git a/src/director/script.ts b/src/director/script.ts
index 07bdc8b..21f2cc3 100644
--- a/src/director/script.ts
+++ b/src/director/script.ts
@@ -12,6 +12,7 @@ import { parseRecipe, type Recipe } from "../schema/index.js";
import { extractJson, type ChatPart, type LlmClient } from "./llm.js";
import type { AppAnalysis } from "./analyze.js";
import type { PageDigest } from "./inventory.js";
+import { redactForPrompt } from "../security/redaction.js";
const SYSTEM = `You write filming scripts ("recipes") for supercut, which records a REAL web app with a browser robot and renders a cinematic 60-second launch video. Respond ONLY with a JSON recipe:
@@ -24,7 +25,7 @@ const SYSTEM = `You write filming scripts ("recipes") for supercut, which record
"priority": 1..N (1 = most important, cut last),
"entry": { "url": one of the crawled page URLs, "prelude": [] },
"depends_on": [],
- "actions": [{ "kind": "click"|"type"|"hover"|"scroll"|"wait", "selector": string, "text": string (type only), "duration_ms": int }],
+ "actions": [{ "kind": "click"|"type"|"hover"|"scroll"|"wait", "selector": string, "text": string (type only), "submit": boolean (type only), "focus_selector": string (optional), "duration_ms": int }],
"hold_ms": int
}]
}
@@ -32,10 +33,15 @@ const SYSTEM = `You write filming scripts ("recipes") for supercut, which record
HARD RULES:
- selectors: COPY EXACTLY from the provided element inventory. Never invent or modify one.
- entry.url: only crawled page URLs.
-- 2-4 scenes, 2-4 actions each, action duration_ms 1200-4000, hold_ms 400-1200.
+- Create EXACTLY one scene per STORYBOARD beat, in the same order. Do not add a generic site-tour scene.
+- Each scene's entry.url must equal that beat's page_url and must include at least one of that beat's money selectors.
+- Do not use mid-scene "goto" actions; each scene starts from its entry.url so selector validation and capture stay coherent.
+- SHOW THE PAYOFF. A product video that types into a box but never reveals the result is worthless. When a "type" goes into a search/query/command field that runs on Enter, set "submit": true so the app actually produces its output (results, a graph, a detail view).
+- FRAME THE RESULT. When an action produces a visible result, set "focus_selector" to the FRAMABLE REGION where that result appears (from the page's regions list). The camera then holds on the payoff (the graph/results), not the input box. Use a region selector ONLY in focus_selector, never as an action "selector".
+- 2-4 scenes, 2-4 actions each, action duration_ms 1200-4000, hold_ms 600-1400.
- total of all durations + holds ≤ 50000 (one minute video with headroom).
-- "type" actions need realistic short text (an email, a search term — match the field).
-- Order scenes as a story: hook → depth → payoff. End on the most visual screen.
+- "type" actions need realistic short text (an email, a search term — match the field). For a search/query field, PREFER a value the app itself suggests — a placeholder example, an example hint near the field, or a visible chip/tag label — so the query is one the product recognizes and actually returns a result for. Do not invent an exotic value the demo may not have data for.
+- Order scenes as a Screen-Studio story: hook → proof/depth → payoff. End on the most visual screen.
- depends_on only when a later scene NEEDS an earlier scene's state.
- (HIDDEN until revealed) elements: only use them AFTER an earlier action in the SAME scene reveals them (e.g. click the button that opens the form, then type into its field).`;
@@ -50,7 +56,7 @@ export async function writeRecipe(
digests: PageDigest[],
appUrl: string,
): Promise {
- // per-page whitelist (PR #2 review): a global set would let a /dash selector
+ // Per-page whitelist: a global set would let a /dash selector
// pass validation in a / scene, then capture waits forever for an element
// that page can never show. Validate each scene's selectors against the
// inventory of ITS entry.url page. (v1 caveat: a mid-scene `goto` to another
@@ -58,13 +64,28 @@ export async function writeRecipe(
const pageUrls = new Set(digests.map((d) => d.url));
const byPage = new Map>();
for (const d of digests) byPage.set(d.url, new Set(d.inventory.map((i) => i.selector)));
+ // framable result regions per page — valid ONLY as focus_selector (camera
+ // target), never as an action selector (they aren't click targets)
+ const byPageRegions = new Map>();
+ for (const d of digests) byPageRegions.set(d.url, new Set((d.regions ?? []).map((r) => r.selector)));
+ const storyboard = analysis.money_moments.map((m, index) => ({
+ index: index + 1,
+ title: m.title,
+ pageUrl: m.page_url,
+ selectors: new Set(m.elements),
+ }));
const inventoryText = digests
- .map(
- (d) =>
- `PAGE ${d.url}\n` +
- d.inventory.map((i) => ` ${i.selector} [${i.tag}] "${i.text}"${i.hidden ? " (HIDDEN until revealed)" : ""}`).join("\n"),
- )
+ .map((d) => {
+ const els = d.inventory
+ .map((i) => ` ${i.selector} [${i.tag}] "${redactForPrompt(i.text)}"${i.hidden ? " (HIDDEN until revealed)" : ""}`)
+ .join("\n");
+ const regions = (d.regions ?? []).length
+ ? `\n FRAMABLE REGIONS (focus_selector only — hold the camera here to show a result):\n` +
+ d.regions.map((r) => ` ${r.selector} [${r.tag}] "${redactForPrompt(r.text)}"`).join("\n")
+ : "";
+ return `PAGE ${d.url}\n${els}${regions}`;
+ })
.join("\n\n");
const base: ChatPart[] = [
@@ -75,6 +96,10 @@ export async function writeRecipe(
analysis.money_moments
.map((m) => `- ${m.title} (${m.page_url}): ${m.why} — elements: ${m.elements.join(", ")}`)
.join("\n") +
+ `\n\nSTORYBOARD (mandatory; output exactly these beats in this order, one scene per beat):\n` +
+ analysis.money_moments
+ .map((m, i) => `${i + 1}. ${i === 0 ? "HOOK" : i === analysis.money_moments.length - 1 ? "PAYOFF" : "PROOF"} — ${m.title} @ ${m.page_url}; scene must use one of: ${m.elements.join(", ")}`)
+ .join("\n") +
`\n\nELEMENT INVENTORY (the ONLY selectors you may use):\n${inventoryText}`,
},
];
@@ -84,24 +109,57 @@ export async function writeRecipe(
const user: ChatPart[] = feedback
? [...base, { type: "text", text: `Your previous recipe was rejected: ${feedback}\nReturn a corrected JSON recipe only.` }]
: base;
- const raw = await llm.chat({ system: SYSTEM, user, json: true, maxTokens: 6000 });
+ const raw = await llm.chat({ system: SYSTEM, user, json: true, maxTokens: 8000 });
try {
const recipe = parseRecipe(extractJson(raw));
+ if (recipe.scenes.length !== storyboard.length) {
+ throw new Error(
+ `recipe has ${recipe.scenes.length} scene(s), but storyboard requires exactly ${storyboard.length} scene(s) ` +
+ `(one per money moment, in order)`,
+ );
+ }
// whitelist gates — the anti-hallucination contract
- for (const scene of recipe.scenes) {
+ for (const [i, scene] of recipe.scenes.entries()) {
+ const beat = storyboard[i]!;
+ if (scene.entry.url !== beat.pageUrl) {
+ throw new Error(
+ `scene ${i + 1} "${scene.name}" entry.url "${scene.entry.url}" does not match storyboard beat ` +
+ `"${beat.title}" page_url "${beat.pageUrl}"`,
+ );
+ }
if (!pageUrls.has(scene.entry.url)) {
throw new Error(`scene "${scene.name}" entry.url "${scene.entry.url}" is not a crawled page (allowed: ${[...pageUrls].join(", ")})`);
}
const pageSelectors = byPage.get(scene.entry.url)!;
+ const pageRegions = byPageRegions.get(scene.entry.url) ?? new Set();
+ let usesMoneySelector = false;
for (const a of [...scene.entry.prelude, ...scene.actions]) {
+ if (a.kind === "goto") {
+ throw new Error(`scene "${scene.name}" uses a mid-scene goto; use a new scene entry.url instead`);
+ }
if (a.selector && !pageSelectors.has(a.selector)) {
throw new Error(
`selector "${a.selector}" in scene "${scene.name}" is not on its entry page ${scene.entry.url} — ` +
`use only selectors listed under that page in the inventory`,
);
}
+ // focus_selector is a camera hint: it must be a real crawled selector
+ // (a framable region, or any interactable) on this page — never invented.
+ if (a.focus_selector && !pageRegions.has(a.focus_selector) && !pageSelectors.has(a.focus_selector)) {
+ throw new Error(
+ `focus_selector "${a.focus_selector}" in scene "${scene.name}" is not a framable region or ` +
+ `inventory selector on ${scene.entry.url} — use one listed under FRAMABLE REGIONS for that page`,
+ );
+ }
+ if (a.selector && beat.selectors.has(a.selector)) usesMoneySelector = true;
+ }
+ if (!usesMoneySelector) {
+ throw new Error(
+ `scene ${i + 1} "${scene.name}" does not film storyboard beat "${beat.title}" — ` +
+ `include at least one of: ${[...beat.selectors].join(", ")}`,
+ );
}
}
return { recipe, attempts: attempt };
diff --git a/src/director/sourceRoutes.ts b/src/director/sourceRoutes.ts
new file mode 100644
index 0000000..a403c66
--- /dev/null
+++ b/src/director/sourceRoutes.ts
@@ -0,0 +1,184 @@
+/**
+ * Source-code comprehension — read the app's routes and page components to
+ * understand what the product actually IS, then seed the crawl with those
+ * routes so the director can drive INTO real panels (not just the landing).
+ *
+ * Why this exists: the crawler only sees the app's *initial* DOM, so the
+ * director never discovers functional pages reachable by buttons/SPA nav and
+ * tours the surface ("stayed on the home page, didn't go into the panel"). The
+ * code is the ground truth of what every screen shows — reading it is cheaper
+ * and deeper than vision, and it tells us which routes exist so we can crawl
+ * them and get their real selectors into the inventory.
+ *
+ * Supports Next.js app-router (`app/**\/page.{tsx,jsx,ts,js}`) and pages-router
+ * (`pages/**\/*.{tsx,jsx}`) first; other frameworks degrade to "no routes
+ * found" and the crawl proceeds link-only as before.
+ */
+import { readdirSync, readFileSync, type Dirent } from "node:fs";
+import { join, sep } from "node:path";
+
+export interface SourceRoute {
+ /** URL path, e.g. "/dashboard" (route groups stripped, dynamic kept verbatim) */
+ route: string;
+ /** absolute file path of the page component */
+ file: string;
+ /** true for dynamic routes like /items/[id] — NOT seeded into the crawl (no
+ * concrete value), but still listed in the product summary */
+ dynamic: boolean;
+ /** extracted human-visible text (headings, labels, copy) for the LLM summary */
+ summary: string;
+}
+
+const SKIP_DIRS = new Set([
+ "node_modules", ".next", ".git", "dist", "build", "out", ".turbo",
+ "coverage", ".vercel", ".cache", "__tests__", "test", "tests",
+]);
+const PAGE_FILE = /^(page|index)\.(tsx|jsx|ts|js)$/;
+const PAGES_FILE = /\.(tsx|jsx)$/;
+
+function walk(dir: string, out: string[] = [], depth = 0): string[] {
+ if (depth > 10) return out;
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
+ } catch {
+ return out;
+ }
+ for (const e of entries) {
+ // skip symlinks entirely (never recurse into or read them): a `--repo`
+ // symlink to ~/.ssh, /etc, etc. would otherwise be walked and its file
+ // contents shipped into the LLM prompt via extractSummary.
+ if (e.isSymbolicLink()) continue;
+ if (e.isDirectory()) {
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
+ walk(join(dir, e.name), out, depth + 1);
+ } else {
+ out.push(join(dir, e.name));
+ }
+ }
+ return out;
+}
+
+/** app-router: path segments after `app/` → route. `(group)` stripped, route
+ * is dynamic if any segment is `[param]`. */
+function appRouterRoute(file: string): { route: string; dynamic: boolean } | null {
+ const parts = file.split(sep);
+ // find the LAST "app" segment (handles src/app and apps/x/app)
+ let appIdx = -1;
+ for (let i = parts.length - 1; i >= 0; i--) {
+ if (parts[i] === "app") { appIdx = i; break; }
+ }
+ if (appIdx < 0) return null;
+ const segs = parts.slice(appIdx + 1, parts.length - 1); // exclude app/ and the page file
+ const routeSegs = segs.filter((s) => !(s.startsWith("(") && s.endsWith(")"))); // drop route groups
+ const route = "/" + routeSegs.join("/");
+ const dynamic = routeSegs.some((s) => s.includes("[") || s.includes("]"));
+ return { route: route === "/" ? "/" : route.replace(/\/$/, ""), dynamic };
+}
+
+/** pages-router: path after `pages/` minus extension; index → parent. */
+function pagesRouterRoute(file: string): { route: string; dynamic: boolean } | null {
+ const parts = file.split(sep);
+ let pagesIdx = -1;
+ for (let i = parts.length - 1; i >= 0; i--) {
+ if (parts[i] === "pages") { pagesIdx = i; break; }
+ }
+ if (pagesIdx < 0) return null;
+ const segs = parts.slice(pagesIdx + 1);
+ const last = segs[segs.length - 1]!;
+ if (last.startsWith("_")) return null; // _app, _document
+ if (segs.includes("api")) return null; // API routes, not pages
+ segs[segs.length - 1] = last.replace(PAGES_FILE, "");
+ if (segs[segs.length - 1] === "index") segs.pop();
+ const route = "/" + segs.join("/");
+ const dynamic = segs.some((s) => s.includes("[") || s.includes("]"));
+ return { route: route === "/" ? "/" : route.replace(/\/$/, ""), dynamic };
+}
+
+/** Pull human-visible text out of a page component: JSX text + string literals,
+ * deduped, capped. Heuristic but enough to tell the LLM what the page is. */
+function extractSummary(file: string): string {
+ let src: string;
+ try {
+ src = readFileSync(file, "utf8");
+ } catch {
+ return "";
+ }
+ const phrases = new Set();
+ // JSX text between > and < (no braces/tags)
+ for (const m of src.matchAll(/>\s*([A-Z][^<>{}\n]{3,60})\s* {
+ if (opts.appName && !f.split(sep).includes(opts.appName)) return false;
+ const base = f.split(sep).pop()!;
+ const inPages = f.split(sep).includes("pages");
+ return PAGE_FILE.test(base) || (inPages && PAGES_FILE.test(base));
+ });
+
+ const byRoute = new Map();
+ for (const file of files) {
+ const base = file.split(sep).pop()!;
+ const derived = PAGE_FILE.test(base) && file.split(sep).includes("app")
+ ? appRouterRoute(file)
+ : pagesRouterRoute(file);
+ if (!derived) continue;
+ if (byRoute.has(derived.route)) continue; // first wins (handles dup layouts)
+ byRoute.set(derived.route, {
+ route: derived.route,
+ file,
+ dynamic: derived.dynamic,
+ summary: extractSummary(file),
+ });
+ }
+
+ // home route first, then shallow-to-deep, capped
+ return [...byRoute.values()]
+ .sort((a, b) => a.route.split("/").length - b.route.split("/").length || a.route.localeCompare(b.route))
+ .slice(0, maxRoutes);
+}
+
+/** Build seed URLs (concrete routes only) + a compact product-source summary
+ * for the analyze prompt. */
+export function routesToSeedAndNotes(
+ routes: SourceRoute[],
+ baseUrl: string,
+): { seedUrls: string[]; notes: string } {
+ const origin = new URL(baseUrl).origin;
+ const seedUrls: string[] = [];
+ const lines: string[] = [];
+ for (const r of routes) {
+ if (!r.dynamic) {
+ try {
+ seedUrls.push(new URL(r.route, origin).href);
+ } catch {
+ /* skip */
+ }
+ }
+ lines.push(` ${r.route}${r.dynamic ? " (dynamic)" : ""}${r.summary ? ` — ${r.summary}` : ""}`);
+ }
+ const notes =
+ `APP ROUTES (from source — these are the real pages this product has):\n` +
+ lines.join("\n");
+ return { seedUrls, notes };
+}
diff --git a/src/render/host-page.ts b/src/render/host-page.ts
index a0f6f46..cfed009 100644
--- a/src/render/host-page.ts
+++ b/src/render/host-page.ts
@@ -1,7 +1,7 @@
/**
* The render host page — a dumb, fast executor served on localhost
* (WebCodecs needs a secure context; the headless SHELL has no WebCodecs at
- * all, so this page runs in full Chromium — see spikes/RESULTS.md).
+ * all, so this page runs in full Chromium).
*
* It fetches the precomputed render-plan.json (all the smart math already
* done in tested TS) and only does mechanical work per output frame:
@@ -10,7 +10,7 @@
* source frame → cursor] → VideoFrame → H.264 (annexb) → POST /result
*
* Plain JS in a template string: it is served as a real page, so no TS/esbuild
- * helper traps (the tsx __name lesson from the spikes).
+ * helper traps.
*/
export const HOST_PAGE = `
supercut render host
@@ -20,8 +20,9 @@ const log = (m) => console.log("[render] " + m);
async function main() {
const TOKEN = new URLSearchParams(location.search).get("t") || "";
+ const authed = (u) => u + (u.includes("?") ? "&" : "?") + "t=" + encodeURIComponent(TOKEN);
const fetchOk = async (u) => {
- const r = await fetch(u);
+ const r = await fetch(authed(u));
if (!r.ok) throw new Error("fetch " + u + " failed: HTTP " + r.status);
return r;
};
@@ -46,17 +47,23 @@ async function main() {
const ctx = canvas.getContext("2d");
// motion-blur accumulator: 'lighter' (additive) at 1/8 alpha per subframe is
// a TRUE average — 8 × src-over at 1/8 alpha only reaches ~66% opacity and
- // washes the content dark (found in first render QC, 2026-06-11)
+ // washes the content dark.
const accumCanvas = new OffscreenCanvas(W, H);
const actx = accumCanvas.getContext("2d");
// --- encoder: H.264 annexb so Node can mux the raw stream with ffmpeg -c copy ---
const chunks = [];
+ const MAX_ENCODED_BYTES = 1.5e9;
+ let totalEncodedBytes = 0;
let encodeError = null;
const encoder = new VideoEncoder({
output: (chunk) => {
const buf = new Uint8Array(chunk.byteLength);
chunk.copyTo(buf);
+ totalEncodedBytes += buf.length;
+ if (totalEncodedBytes > MAX_ENCODED_BYTES) {
+ throw new Error("encoded result exceeds 1.5GB cap");
+ }
chunks.push(buf);
},
error: (e) => { encodeError = e; },
@@ -65,11 +72,15 @@ async function main() {
codec: "avc1.640028",
width: W, height: H,
framerate: fps,
- bitrate: 10_000_000,
+ // 10 Mbps washed out thin serif strokes (lowercase 's' vanished from caption
+ // text while chunkier glyphs survived). Crisp 1080p60 text needs more head-
+ // room; 40 Mbps holds fine detail without bloating a ≤60s clip.
+ bitrate: 40_000_000,
+ bitrateMode: "constant",
avc: { format: "annexb" },
};
- // probe BEFORE configuring: a clear one-line failure beats a cryptic
- // mid-render encoder error (adversarial review: no capability probing)
+ // Probe BEFORE configuring: a clear one-line failure beats a cryptic
+ // mid-render encoder error.
const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (!support.supported) {
throw new Error("H.264 (avc1.640028) encoding not supported by this Chromium — cannot render");
@@ -97,8 +108,7 @@ async function main() {
c.closePath();
}
- // macOS pointer, redrawn properly (v0's hand-sketched arrow read as cheap —
- // Brayden: "the cursor is a bit cringe"). Accurate proportions, rounded
+ // macOS pointer with accurate proportions, rounded
// joins, soft drop shadow, micro-squeeze on click. Tip at (0,0).
function drawCursor(c, x, y, pulse) {
c.save();
@@ -164,7 +174,7 @@ async function main() {
// adaptive blur: pass count scales with corner displacement across the
// shutter so ghost spacing stays ≲1px at any camera speed (the residual
- // "weird border" rings Brayden still saw on v5 were 8 discrete copies of
+ // Border rings come from 8 discrete copies of
// fast frames + 8 stacked shadows)
const [z0, ox0, oy0] = camAt(0);
const [z1, ox1, oy1] = camAt(1);
@@ -223,7 +233,7 @@ async function main() {
}
// window shadow: drawn ONCE per frame at mid-shutter — it is already a
// 72px blur, so motion-blurring it is invisible, but stacking copies of
- // it was the big concentric banding (QC round: v5 residual rings)
+ // it creates concentric banding.
{
const [z, offX, offY] = camAt(0.5);
ctx.setTransform(z, 0, 0, z, offX, offY);
@@ -238,7 +248,7 @@ async function main() {
}
ctx.drawImage(accumCanvas, 0, 0);
// vignette pulls the eye to the window — fades out as the camera zooms in
- // (a fixed vignette grays the corners of bright content at zoom, QC round 3)
+ // (a fixed vignette grays the corners of bright content at zoom)
const zNow = camera[(f * SUB + (SUB - 1)) * 3];
const vigA = Math.max(0, Math.min(1, (1.55 - zNow) / 0.55)) * background.vignette;
if (vigA > 0.01) {
@@ -250,7 +260,7 @@ async function main() {
}
// 3) cursor: drawn SHARP on the final composite (dark pixels vanish in the
- // additive blur layer — found in QC round 2). It still tracks the camera:
+ // additive blur layer). It still tracks the camera:
// position + scale from the last subframe's transform.
{
const base = (f * SUB + (SUB - 1)) * 3;
@@ -260,7 +270,7 @@ async function main() {
ctx.save();
ctx.translate(z * cur[0] + offX, z * cur[1] + offY);
// damped scale (sqrt z): full proportional growth read as distracting
- // (PR #1 review) but a fully fixed cursor detaches from the content —
+ // but a fully fixed cursor detaches from the content —
// sqrt keeps it cohesive while barely growing (~1.2x at max zoom)
const cs = Math.sqrt(z);
ctx.scale(cs, cs);
@@ -272,7 +282,7 @@ async function main() {
encoder.encode(vf, { keyFrame: f % 120 === 0 });
vf.close();
if (encodeError) throw encodeError;
- // real backpressure: DRAIN the queue, don't nap once and hope (review: P2)
+ // Real backpressure: drain the queue, do not nap once and hope.
while (encoder.encodeQueueSize > 4) {
await new Promise((r) => setTimeout(r, 8));
if (encodeError) throw encodeError;
diff --git a/src/render/index.ts b/src/render/index.ts
index 13b1da4..b8a0852 100644
--- a/src/render/index.ts
+++ b/src/render/index.ts
@@ -9,9 +9,11 @@
* └─ ffmpeg as MUXER ONLY (-c copy) → final .mp4
*/
import { execFile } from "node:child_process";
-import { randomBytes } from "node:crypto";
-import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { randomBytes, timingSafeEqual } from "node:crypto";
+import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
import { createServer } from "node:http";
+import { Transform } from "node:stream";
+import { pipeline } from "node:stream/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -27,7 +29,7 @@ export interface RenderOptions {
outFile: string;
/** palette name (aurora|midnight|dusk|paper) or a path to a wallpaper image */
background?: string;
- /** ms; encoding 60s of footage measured ~36s in the spike — 5 min is generous */
+ /** ms; encoding 60s of footage is expected to finish well within 5 min */
timeoutMs?: number;
}
@@ -43,7 +45,7 @@ export async function renderTake(opts: RenderOptions): Promise {
const timeoutMs = opts.timeoutMs ?? 300_000;
const t0 = Date.now();
- // fail BEFORE the expensive work: output dir + take shape (review: P2)
+ // Fail before expensive work: output dir + take shape.
mkdirSync(dirname(outFile), { recursive: true });
const log = parseEventLog(JSON.parse(readFileSync(join(takeDir, "events.json"), "utf8")));
const rawIndex = JSON.parse(readFileSync(join(takeDir, "frames-index.json"), "utf8"));
@@ -56,10 +58,11 @@ export async function renderTake(opts: RenderOptions): Promise {
if (!existsSync(bgSpec)) {
const assetsDir = fileURLToPath(new URL("../../assets", import.meta.url));
if (existsSync(assetsDir)) {
- const needle = bgSpec.toLowerCase().replace(/[^a-z0-9]/g, "");
- const hit = readdirSync(assetsDir).find((f) =>
- f.toLowerCase().replace(/[^a-z0-9]/g, "").includes(needle),
- );
+ const requested = bgSpec.toLowerCase().replace(/\.[a-z0-9]+$/, "");
+ const hit = readdirSync(assetsDir).find((f) => {
+ const lower = f.toLowerCase();
+ return lower === bgSpec.toLowerCase() || lower.replace(/\.[a-z0-9]+$/, "") === requested;
+ });
if (hit) bgSpec = join(assetsDir, hit);
}
}
@@ -69,22 +72,64 @@ export async function renderTake(opts: RenderOptions): Promise {
? { kind: "image", base: "#101010", blobs: [], light: true, vignette: 0.16 }
: bgSpec,
});
+
const planJson = JSON.stringify(plan);
+ // M5: clock-vs-frame skew warning (non-fatal). Event `t` rides a wall-time
+ // accumulator; frame `t_source` rides CDP screencast timestamps. If the latest
+ // event time runs well past the last frame's source time, the camera (driven
+ // by event `t`) may be ahead of the footage (indexed by `t_source`). We do not
+ // change the timing model — just surface the skew so the operator isn't blind.
+ {
+ const lastFrameT = frameIndex.length ? frameIndex[frameIndex.length - 1]!.t_source : 0;
+ let maxEventT = 0;
+ for (const e of log.events) maxEventT = Math.max(maxEventT, e.t);
+ const skew = maxEventT - lastFrameT;
+ if (skew > 500) {
+ console.error(
+ `[render] WARNING: event timeline leads footage by ${Math.round(skew)}ms ` +
+ `(last event t=${Math.round(maxEventT)}ms, last frame t_source=${Math.round(lastFrameT)}ms) — ` +
+ `camera may be slightly ahead of the footage (clock/frame skew).`,
+ );
+ }
+ }
+
const token = randomBytes(16).toString("hex");
- let resultBuf: Buffer | null = null;
+ const rawPath = join(takeDir, "encoded.h264");
+ let encodedBytes = 0;
+ let resultReady = false;
+ let rejectResult!: (err: Error) => void;
let resolveResult!: () => void;
- const resultReceived = new Promise((r) => (resolveResult = r));
+ const resultReceived = new Promise((r, rej) => { resolveResult = r; rejectResult = rej; });
const server = createServer((req, res) => {
- const url = (req.url ?? "/").split("?")[0]!;
+ const rawUrl = req.url ?? "/";
+ const parsedUrl = new URL(rawUrl, "http://127.0.0.1");
+ const url = parsedUrl.pathname;
+ // constant-time compare (length-checked: timingSafeEqual throws on a length
+ // mismatch). Negligible value here — 128-bit per-run token, loopback-only —
+ // but trivially correct.
+ const tokenMatches = (got: string | string[] | undefined | null): boolean => {
+ if (typeof got !== "string" || got.length !== token.length) return false;
+ return timingSafeEqual(Buffer.from(got), Buffer.from(token));
+ };
+ const authorized =
+ tokenMatches(parsedUrl.searchParams.get("t")) || tokenMatches(req.headers["x-render-token"]);
+ const requireToken = (): boolean => {
+ if (authorized) return true;
+ res.writeHead(403);
+ res.end();
+ return false;
+ };
if (url === "/" || url === "/host.html") {
res.writeHead(200, { "content-type": "text/html" });
res.end(HOST_PAGE);
} else if (url === "/take/render-plan.json") {
+ if (!requireToken()) return;
res.writeHead(200, { "content-type": "application/json" });
res.end(planJson);
} else if (url.startsWith("/take/frames/")) {
+ if (!requireToken()) return;
try {
const name = url.slice("/take/frames/".length).replace(/[^0-9a-zA-Z._-]/g, "");
const buf = readFileSync(join(takeDir, "frames", name));
@@ -95,6 +140,7 @@ export async function renderTake(opts: RenderOptions): Promise {
res.end();
}
} else if (url === "/take/bg" && bgIsImage) {
+ if (!requireToken()) return;
const ext = bgSpec.toLowerCase();
const mime = ext.endsWith(".png") ? "image/png" : ext.endsWith(".webp") ? "image/webp" : "image/jpeg";
res.writeHead(200, { "content-type": mime });
@@ -102,28 +148,33 @@ export async function renderTake(opts: RenderOptions): Promise {
} else if (url === "/result" && req.method === "POST") {
// only OUR page may deliver the result (token minted per render),
// and a runaway encoder can't OOM Node (size cap)
- if (req.headers["x-render-token"] !== token) {
- res.writeHead(403);
- res.end();
- return;
- }
+ if (!requireToken()) return;
const MAX_RESULT_BYTES = 1.5e9;
let received = 0;
- const parts: Buffer[] = [];
- req.on("data", (c: Buffer) => {
- received += c.length;
- if (received > MAX_RESULT_BYTES) {
- req.destroy(new Error("encoded result exceeds 1.5GB cap"));
- return;
+ const sizeLimiter = new Transform({
+ transform(chunk: Buffer, _encoding, callback) {
+ received += chunk.length;
+ if (received > MAX_RESULT_BYTES) {
+ callback(new Error("encoded result exceeds 1.5GB cap"));
+ return;
+ }
+ callback(null, chunk);
}
- parts.push(c);
- });
- req.on("end", () => {
- resultBuf = Buffer.concat(parts);
- res.writeHead(200);
- res.end("ok");
- resolveResult();
});
+ pipeline(req, sizeLimiter, createWriteStream(rawPath))
+ .then(() => {
+ encodedBytes = received;
+ resultReady = true;
+ res.writeHead(200);
+ res.end("ok");
+ resolveResult();
+ })
+ .catch((err) => {
+ const e = err instanceof Error ? err : new Error(String(err));
+ res.writeHead(500);
+ res.end(e.message);
+ rejectResult(e);
+ });
} else {
res.writeHead(404);
res.end();
@@ -132,7 +183,7 @@ export async function renderTake(opts: RenderOptions): Promise {
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = (server.address() as { port: number }).port;
- // full Chromium: the stripped headless shell has no WebCodecs (spike gotcha #2)
+ // Full Chromium: the stripped headless shell has no WebCodecs.
const browser = await chromium.launch({ headless: true, channel: "chromium" });
try {
const page = await browser.newPage();
@@ -156,7 +207,7 @@ export async function renderTake(opts: RenderOptions): Promise {
for (;;) {
await new Promise((r) => setTimeout(r, 500));
if (fatal) throw new Error(fatal);
- if (resultBuf) return;
+ if (resultReady) return;
}
})(),
]);
@@ -165,16 +216,13 @@ export async function renderTake(opts: RenderOptions): Promise {
await new Promise((r) => server.close(() => r()));
}
- if (!resultBuf || (resultBuf as Buffer).length === 0) {
+ if (!resultReady || encodedBytes === 0) {
throw new Error("render produced no encoded output");
}
- const encoded: Buffer = resultBuf;
// mux raw annexb H.264 → MP4. ffmpeg is a muxer here, never an effects engine.
- const rawPath = join(takeDir, "encoded.h264");
- writeFileSync(rawPath, encoded);
// -r BEFORE -i: raw annexb has no timestamps; this assigns them at 60fps.
- // (-framerate alone misparses → 120fps/wrong duration, found 2026-06-11.)
+ // (-framerate alone can misparse to the wrong duration.)
await exec("ffmpeg", [
"-y",
"-f", "h264",
@@ -188,7 +236,7 @@ export async function renderTake(opts: RenderOptions): Promise {
return {
outFile,
frames: plan.frames,
- encodedBytes: encoded.length,
+ encodedBytes,
wallMs: Date.now() - t0,
};
}
diff --git a/src/render/plan.ts b/src/render/plan.ts
index 347b7a5..ae148e2 100644
--- a/src/render/plan.ts
+++ b/src/render/plan.ts
@@ -57,8 +57,8 @@ export interface BackgroundStyle {
/**
* Curated palettes. "aurora" is the default — the soft blurred
- * pastel-mesh look of OpenAI-style launch videos (Brayden's reference,
- * 2026-06-11). Apple wallpapers can't be bundled (copyright); users get the
+ * pastel-mesh look of modern launch videos. Apple wallpapers cannot be
+ * bundled (copyright); users get the
* same vibe via --bg .
*/
export const PALETTES: Record = {
@@ -145,10 +145,21 @@ interface CameraSegment {
const ZOOM_TARGET = 1.48;
const ZOOM_LEAD_MS = 600; // camera starts moving before the click lands
const ZOOM_DWELL_MS = 1500; // stays on target after the event
+/** a framed RESULT (focus_bbox) is the payoff — hold on it longer than a plain
+ * interaction so the viewer reads the graph/results before the camera moves */
+const FOCUS_DWELL_MS = 4200;
+/** a result region should FILL the frame, not be punched-into and cropped:
+ * fit it to this fraction of the viewport (the rest is breathing room) */
+const FOCUS_FILL = 0.88;
/** segments closer than this bridge into ONE held zoom — the camera glides
- * between targets instead of pumping out/in per click (Brayden: "everything
- * is just moving too much... the screen is kind of shaking", 2026-06-11) */
+ * between targets instead of pumping out/in per click. */
const MERGE_GAP_MS = 2600;
+/** between two scenes (a gap too wide to fully merge) the camera relaxes to this
+ * gentle floor instead of snapping all the way back to z=1 — so it glides scene
+ * to scene rather than pumping fully out then punching back in (which read as a
+ * hard cut). Only engaged STRICTLY between segments: before the first and after
+ * the last it still settles wide to z=1. */
+const GLIDE_Z = 1.1;
const TAIL_MS = 600;
const PULSE_MS = 350;
/** critically damped spring: ~settles in ≈ 4/OMEGA seconds — 6.5 is a calm,
@@ -183,7 +194,7 @@ export function buildRenderPlan(
): RenderPlan {
if (frameIndex.length === 0) throw new Error("render plan: empty frame index");
// frame index is external input — one malformed entry can otherwise request
- // absurd allocations or break the nearest-hold walk (review: P1 bounds)
+ // absurd allocations or break the nearest-hold walk.
let prevT = -1;
for (const [i, e] of frameIndex.entries()) {
if (typeof e?.file !== "string" || e.file.length === 0 || typeof e?.t_source !== "number") {
@@ -209,7 +220,13 @@ export function buildRenderPlan(
// ---- duration ----
let lastT = frameIndex[frameIndex.length - 1]!.t_source;
for (const e of log.events) {
- const dwell = e.type === "click" || e.type === "hover" || e.type === "type" ? ZOOM_DWELL_MS : 0;
+ // a focused payoff holds for FOCUS_DWELL_MS (the camera segment below uses
+ // it); reserve the SAME dwell here or the render can end mid-hold and cut the
+ // result framing short on a final focused beat.
+ let dwell = 0;
+ if (e.type === "click" || e.type === "hover" || e.type === "type") {
+ dwell = e.focus_bbox ? FOCUS_DWELL_MS : ZOOM_DWELL_MS;
+ }
lastT = Math.max(lastT, e.t + dwell);
if (e.type === "cursor_path") {
const last = e.points[e.points.length - 1];
@@ -218,7 +235,7 @@ export function buildRenderPlan(
}
const frames = Math.ceil((lastT + TAIL_MS) / frameMs);
// hard ceiling: product max is 60s; 2 min of slack covers overruns — beyond
- // that a corrupt timestamp is asking us to allocate the moon (review: P1)
+ // that a corrupt timestamp is asking us to allocate the moon.
const MAX_TAKE_MS = 120_000;
if (lastT > MAX_TAKE_MS) {
throw new Error(
@@ -239,12 +256,30 @@ export function buildRenderPlan(
const segments: CameraSegment[] = [];
for (const e of log.events) {
if (e.type !== "click" && e.type !== "hover" && e.type !== "type") continue;
- const [bx, by, bw, bh] = e.bbox;
- const focus = toCanvas(layout, bx + bw / 2, by + bh / 2);
+ // 4b: prefer the result region (focus_bbox) when the action named one — the
+ // camera holds on the payoff (graph/results), not the input that made it.
+ const framed = e.focus_bbox ?? e.bbox;
+ const [bx, by, bw, bh] = framed;
+ // defense in depth: clamp the focus point to the viewport so a stray
+ // off-frame bbox can never fly the camera off into empty background
+ // (the capture stage now scrolls targets in-view, but never trust a bbox)
+ const cssX = Math.min(Math.max(bx + bw / 2, 0), layout.viewport.width);
+ const cssY = Math.min(Math.max(by + bh / 2, 0), layout.viewport.height);
+ const focus = toCanvas(layout, cssX, cssY);
+ // a small control gets the fixed punch-in; a large result region gets a
+ // FIT zoom so it fills the frame (FOCUS_FILL) instead of being cropped.
+ let z = ZOOM_TARGET;
+ let dwell = ZOOM_DWELL_MS;
+ if (e.focus_bbox) {
+ const fitW = (FOCUS_FILL * layout.viewport.width) / Math.max(bw, 1);
+ const fitH = (FOCUS_FILL * layout.viewport.height) / Math.max(bh, 1);
+ z = Math.max(1, Math.min(ZOOM_TARGET, fitW, fitH));
+ dwell = FOCUS_DWELL_MS;
+ }
segments.push({
start: e.t - ZOOM_LEAD_MS,
- end: e.t + ZOOM_DWELL_MS,
- z: ZOOM_TARGET,
+ end: e.t + dwell,
+ z,
fx: focus.x,
fy: focus.y,
});
@@ -261,17 +296,25 @@ export function buildRenderPlan(
const targetAt = (t: number): { z: number; fx: number; fy: number } => {
let active: CameraSegment | undefined;
+ let prevEnded: CameraSegment | undefined; // most recent segment already over
+ let nextStarts = false; // a segment still lies ahead
for (const s of segments) {
if (t >= s.start && t <= s.end) active = s; // later-starting segment wins
- if (s.start > t) break;
+ else if (s.end < t) prevEnded = s;
+ if (s.start > t) { nextStarts = true; break; }
}
- return active ?? { z: 1, fx: center.x, fy: center.y };
+ if (active) return active;
+ // strictly between two scenes: glide at a gentle floor on the last focus so
+ // the camera doesn't pump fully out and hard-cut into the next scene.
+ if (prevEnded && nextStarts) return { z: GLIDE_Z, fx: prevEnded.fx, fy: prevEnded.fy };
+ // before the first event / after the last: settle wide.
+ return { z: 1, fx: center.x, fy: center.y };
};
// ---- spring integration at subframe resolution ----
// 180° shutter: integrate 2×SUBFRAMES steps per frame but RECORD only the
// first half — blur spans half the frame interval, halving ghost spacing
- // (the "onion ring" edge artifact Brayden spotted, 2026-06-11)
+ // (prevents onion-ring edge artifacts)
const STEPS = SUBFRAMES * 2;
const dt = frameMs / 1000 / STEPS;
const state = { z: 1, fx: center.x, fy: center.y, vz: 0, vfx: 0, vfy: 0 };
diff --git a/src/schema/event-log.ts b/src/schema/event-log.ts
index 107644a..63f91f8 100644
--- a/src/schema/event-log.ts
+++ b/src/schema/event-log.ts
@@ -2,73 +2,71 @@ import { z } from "zod";
/**
* Event-Log Schema v0 — the public contract.
- *
- * Emitted by any recorder (supercut's Playwright executor, or third-party)
- * alongside raw footage. The renderer consumes ONLY this file plus the video.
- *
- * recorder ──▶ footage.raw + events.json ──▶ renderer ──▶ final.mp4
- *
- * Coordinates are CSS (logical) pixels in viewport space; the renderer
- * multiplies by `viewport.dpr` to sample raw frames.
- *
- * `t` is the SCHEDULED timestamp (ms since first frame, frame 0 = t 0) and is
- * deterministic by construction. `observed_t` is optional wall-clock metadata,
- * excluded from determinism comparisons.
- *
- * Unknown event types MUST be ignored by consumers (forward compatibility) —
- * use `parseEventLog`, which strips unknown-type events instead of failing.
*/
-const bbox = z.tuple([z.number(), z.number(), z.number(), z.number()]); // x, y, w, h
-const point = z.tuple([z.number(), z.number()]);
+export const MAX_EVENTS = 5_000;
+export const MAX_CURSOR_POINTS = 20_000;
+
+const finite = z.number().finite();
+const bbox = z.tuple([finite, finite, finite.positive(), finite.positive()]); // x, y, w, h
+const point = z.tuple([finite, finite]);
const baseEvent = {
- t: z.number().nonnegative(),
- observed_t: z.number().nonnegative().optional(),
+ t: finite.nonnegative(),
+ observed_t: finite.nonnegative().optional(),
};
+/** Optional camera target: the result region this action produced (a graph, a
+ * results panel). When present, the renderer frames THIS instead of the
+ * interaction bbox — the cursor stays on the control, the camera holds on the
+ * payoff. Resolved at capture time, so it reflects the post-action layout. */
+const focusBbox = { focus_bbox: bbox.optional() };
+
export const clickEvent = z.object({
...baseEvent,
type: z.literal("click"),
bbox,
+ ...focusBbox,
selector: z.string(),
point,
-});
+}).strict();
export const typeEvent = z.object({
...baseEvent,
type: z.literal("type"),
bbox,
+ ...focusBbox,
selector: z.string(),
textLen: z.number().int().nonnegative(),
-});
+}).strict();
export const scrollEvent = z.object({
...baseEvent,
type: z.literal("scroll"),
from: point,
to: point,
-});
+}).strict();
export const hoverEvent = z.object({
...baseEvent,
type: z.literal("hover"),
bbox,
+ ...focusBbox,
selector: z.string(),
-});
+}).strict();
export const sceneEvent = z.object({
...baseEvent,
type: z.literal("scene"),
name: z.string(),
priority: z.number().int().min(1),
-});
+}).strict();
export const cursorPathEvent = z.object({
...baseEvent,
type: z.literal("cursor_path"),
- points: z.array(z.tuple([z.number(), z.number(), z.number()])), // [t, x, y]
-});
+ points: z.array(z.tuple([finite.nonnegative(), finite, finite])).max(MAX_CURSOR_POINTS, "too many cursor points"),
+}).strict();
export const knownEvent = z.discriminatedUnion("type", [
clickEvent,
@@ -84,11 +82,11 @@ export const eventLog = z.object({
viewport: z.object({
width: z.number().int().positive(),
height: z.number().int().positive(),
- dpr: z.number().positive(),
- }),
- fps: z.number().int().positive(),
- events: z.array(knownEvent),
-});
+ dpr: finite.positive(),
+ }).strict(),
+ fps: z.number().int().positive().max(240),
+ events: z.array(knownEvent).max(MAX_EVENTS, "too many events"),
+}).strict();
export type EventLog = z.infer;
export type KnownEvent = z.infer;
@@ -102,13 +100,33 @@ const KNOWN_EVENT_TYPES = new Set([
"cursor_path",
]);
+function enforceMonotonic(events: KnownEvent[]): void {
+ let prev = -1;
+ for (const e of events) {
+ // cursor_path is global metadata emitted at t=0 after all scene/action
+ // events by the built-in recorder; validate its internal point timeline but
+ // do not make the container event participate in event-order monotonicity.
+ if (e.type !== "cursor_path") {
+ if (e.t < prev) throw new Error(`event timestamps must be monotonic; ${e.type} at ${e.t} came after ${prev}`);
+ prev = e.t;
+ }
+ if (e.type === "cursor_path") {
+ let pointPrev = -1;
+ for (const [t] of e.points) {
+ if (t < pointPrev) throw new Error("cursor_path points must be monotonic");
+ pointPrev = t;
+ }
+ }
+ }
+}
+
/**
* Parse an event log from untrusted JSON. Unknown event types are silently
* dropped (forward compatibility); malformed KNOWN events still fail loudly.
*/
export function parseEventLog(raw: unknown): EventLog {
const envelope = z
- .object({ events: z.array(z.object({ type: z.string() }).passthrough()) })
+ .object({ events: z.array(z.object({ type: z.string() }).passthrough()).max(MAX_EVENTS, "too many events") })
.passthrough()
.parse(raw);
@@ -117,5 +135,7 @@ export function parseEventLog(raw: unknown): EventLog {
events: envelope.events.filter((e) => KNOWN_EVENT_TYPES.has(e.type)),
};
- return eventLog.parse(filtered);
+ const parsed = eventLog.parse(filtered);
+ enforceMonotonic(parsed.events);
+ return parsed;
}
diff --git a/src/schema/recipe.ts b/src/schema/recipe.ts
index 588ed6a..a50f503 100644
--- a/src/schema/recipe.ts
+++ b/src/schema/recipe.ts
@@ -22,6 +22,9 @@ export const MAX_BUDGET_MS = 60_000;
export const MIN_ACTION_MS = 200;
/** recipes drive a real local browser — never allow file:/javascript:/etc. */
+const finite = z.number().finite();
+const positiveFinite = finite.positive();
+
const httpUrl = z
.string()
.url()
@@ -35,13 +38,23 @@ export const action = z
selector: z.string().min(1).optional(),
url: httpUrl.optional(),
text: z.string().optional(),
+ /** type only: press Enter after typing. Many query/search inputs reveal
+ * their payoff (results, a graph, a detail panel) only on submit — without
+ * this the robot types into a box and the product never actually runs. */
+ submit: z.boolean().optional(),
+ /** Camera target: a result region (from the page's framable regions) that
+ * this action produces. The renderer holds the camera HERE instead of on
+ * the interaction bbox — cursor on the control, frame on the payoff.
+ * Resolved at capture time; ignored if it doesn't resolve. */
+ focus_selector: z.string().min(1).optional(),
/** Scheduled duration for this action, ms. The scheduler may re-place
* actions on the beat grid but never invents durations. */
duration_ms: z.number().int().min(MIN_ACTION_MS),
/** Where the camera should look during this action (CSS px bbox).
* PATCHABLE by QC. */
- zoom: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
+ zoom: z.tuple([finite.nonnegative(), finite.nonnegative(), positiveFinite, positiveFinite]).optional(),
})
+ .strict()
.superRefine((a, ctx) => {
// per-kind requirements — fail at parse time, never mid-capture
if ((a.kind === "click" || a.kind === "hover" || a.kind === "type") && !a.selector) {
@@ -62,19 +75,19 @@ export const scene = z.object({
entry: z.object({
url: httpUrl,
prelude: z.array(action).default([]),
- }),
+ }).strict(),
depends_on: z.array(z.string()).default([]),
actions: z.array(action).min(1),
/** Extra hold on the scene's last frame, ms. PATCHABLE by QC. */
- hold_ms: z.number().int().nonnegative().default(0),
-});
+ hold_ms: z.number().int().nonnegative().max(MAX_BUDGET_MS).default(0),
+}).strict();
export const recipe = z.object({
version: z.literal(0),
app_url: httpUrl,
music_track: z.string().min(1),
scenes: z.array(scene).min(1),
-});
+}).strict();
export type Recipe = z.infer;
export type Scene = z.infer;
diff --git a/src/security/redaction.ts b/src/security/redaction.ts
new file mode 100644
index 0000000..c247e83
--- /dev/null
+++ b/src/security/redaction.ts
@@ -0,0 +1,13 @@
+/** Conservative prompt redaction for common secrets and direct identifiers. */
+const EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
+const SECRET_ASSIGNMENT = /\b(api[_-]?key|token|password|secret|bearer)\s*[:=]\s*([^\s,;"']+)/gi;
+const OPENAI_STYLE_KEY = /\bsk-[A-Za-z0-9_-]{10,}\b/g;
+const LONG_HEX = /\b[a-f0-9]{32,}\b/gi;
+
+export function redactForPrompt(text: string): string {
+ return text
+ .replace(EMAIL, "[REDACTED_EMAIL]")
+ .replace(OPENAI_STYLE_KEY, "[REDACTED_KEY]")
+ .replace(LONG_HEX, "[REDACTED_TOKEN]")
+ .replace(SECRET_ASSIGNMENT, (_m, key: string) => `${key}=[REDACTED]`);
+}
diff --git a/src/security/url-policy.ts b/src/security/url-policy.ts
new file mode 100644
index 0000000..afab760
--- /dev/null
+++ b/src/security/url-policy.ts
@@ -0,0 +1,115 @@
+import { lookup } from "node:dns/promises";
+import { isIP } from "node:net";
+
+export interface NavigationPolicyOptions {
+ allowPrivateNetwork?: boolean;
+ /** optional final URL after following redirects; checked with stricter redirect error */
+ finalUrl?: string;
+}
+
+function ipToLong(ip: string): number | null {
+ if (isIP(ip) !== 4) return null;
+ return ip.split(".").reduce((n, part) => (n << 8) + Number(part), 0) >>> 0;
+}
+
+function inCidr(ip: string, base: string, bits: number): boolean {
+ const n = ipToLong(ip);
+ const b = ipToLong(base);
+ if (n === null || b === null) return false;
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+ return (n & mask) === (b & mask);
+}
+
+/**
+ * Canonicalize alternate IP encodings to dotted-decimal IPv4 so the private-host
+ * check can't be bypassed by writing the loopback/metadata address a different
+ * way. Covers: bare decimal int (`2130706433`), hex (`0x7f000001`), and
+ * IPv4-mapped IPv6 (`::ffff:127.0.0.1` / `::ffff:7f00:1`). WHATWG `new URL()`
+ * already folds the decimal/hex forms before we ever see them, but normalizing
+ * here makes the guard self-contained and covers the mapped-IPv6 case the URL
+ * parser leaves intact. Returns the original host when it isn't an alt-encoding.
+ *
+ * NOTE: this does NOT defend against DNS-rebinding (a hostname that resolves
+ * public at check time and private at connect time — resolve-time TOCTOU).
+ * Resolve-and-pin (connect to the exact IP we validated) is out of scope here;
+ * the guard validates the host string and the post-redirect final URL only.
+ */
+function normalizeHostToIPv4(h: string): string {
+ // whole-host bare decimal integer, e.g. "2130706433" → "127.0.0.1"
+ if (/^\d+$/.test(h)) {
+ const n = Number(h);
+ if (Number.isInteger(n) && n >= 0 && n <= 0xffffffff) {
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join(".");
+ }
+ return h;
+ }
+ // whole-host hex integer, e.g. "0x7f000001" → "127.0.0.1"
+ if (/^0x[0-9a-f]+$/i.test(h)) {
+ const n = Number(h);
+ if (Number.isInteger(n) && n >= 0 && n <= 0xffffffff) {
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join(".");
+ }
+ return h;
+ }
+ // IPv4-mapped IPv6: "::ffff:127.0.0.1" (dotted tail) or "::ffff:7f00:1" (hex
+ // tail). Brackets are already stripped by the caller.
+ const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(h);
+ if (mappedDotted && isIP(mappedDotted[1]!) === 4) return mappedDotted[1]!;
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
+ if (mappedHex) {
+ const hi = parseInt(mappedHex[1]!, 16);
+ const lo = parseInt(mappedHex[2]!, 16);
+ return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join(".");
+ }
+ return h;
+}
+
+function isPrivateHostname(hostname: string): boolean {
+ let h = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "");
+ // normalize numeric/hex/mapped-IPv6 encodings to canonical IPv4 BEFORE the
+ // private check, so alt-encodings of loopback/metadata can't slip through.
+ h = normalizeHostToIPv4(h);
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
+ if (h === "0.0.0.0") return true;
+ if (isIP(h) === 6) return h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80:");
+ if (isIP(h) === 4) {
+ return (
+ inCidr(h, "10.0.0.0", 8) ||
+ inCidr(h, "127.0.0.0", 8) ||
+ inCidr(h, "169.254.0.0", 16) ||
+ inCidr(h, "172.16.0.0", 12) ||
+ inCidr(h, "192.168.0.0", 16)
+ );
+ }
+ return false;
+}
+
+async function resolvesPrivate(hostname: string): Promise {
+ if (isPrivateHostname(hostname)) return true;
+ try {
+ const addrs = await lookup(hostname, { all: true, verbatim: true });
+ return addrs.some((a) => isPrivateHostname(a.address));
+ } catch {
+ return false;
+ }
+}
+
+async function checkOne(raw: string, opts: NavigationPolicyOptions, redirect: boolean): Promise {
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ throw new Error(`invalid navigation URL: ${raw}`);
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error(`navigation URL must be http(s): ${raw}`);
+ }
+ if (!opts.allowPrivateNetwork && await resolvesPrivate(url.hostname)) {
+ throw new Error(`${redirect ? "redirect target" : "navigation URL"} is on a private network: ${raw}`);
+ }
+}
+
+export async function assertSafeNavigationUrl(raw: string, opts: NavigationPolicyOptions = {}): Promise {
+ await checkOne(raw, opts, false);
+ if (opts.finalUrl && opts.finalUrl !== raw) await checkOne(opts.finalUrl, opts, true);
+}
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..cd058bb
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { resolveProvider, type ProviderEnv } from "../src/director/config.js";
+
+function resolved(env: ProviderEnv) {
+ return resolveProvider(env);
+}
+
+describe("provider resolution", () => {
+ const oldEnv = { ...process.env };
+ afterEach(() => {
+ process.env = { ...oldEnv };
+ });
+
+ it("resolves DeepSeek explicitly even when a custom DeepSeek base URL is set", () => {
+ const p = resolved({
+ DEEPSEEK_API_KEY: "deepseek-key",
+ SUPERCUT_LLM_BASE_URL: "https://api.deepseek.com",
+ });
+
+ expect(p.provider).toBe("deepseek");
+ expect(p.vision).toBe(false);
+ expect(p.model).toBe("deepseek-v4-pro");
+ expect(p.summary).toContain("deepseek:deepseek-v4-pro @ https://api.deepseek.com");
+ });
+
+ it("fails loudly when provider-specific keys are mixed without an explicit provider", () => {
+ expect(() => resolved({ DEEPSEEK_API_KEY: "deepseek", OPENROUTER_API_KEY: "or" })).toThrow(
+ /multiple provider keys/i,
+ );
+ });
+
+ it("lets SUPERCUT_PROVIDER disambiguate mixed keys", () => {
+ const p = resolved({
+ SUPERCUT_PROVIDER: "openrouter",
+ DEEPSEEK_API_KEY: "deepseek",
+ OPENROUTER_API_KEY: "or",
+ SUPERCUT_MODEL: "anthropic/claude-sonnet-4.6",
+ });
+
+ expect(p.provider).toBe("openrouter");
+ expect(p.vision).toBe(true);
+ expect(p.model).toBe("anthropic/claude-sonnet-4.6");
+ });
+
+ it("rejects forcing vision on for DeepSeek text-only models", () => {
+ expect(() => resolved({ DEEPSEEK_API_KEY: "deepseek", SUPERCUT_VISION: "true" })).toThrow(/vision.*deepseek/i);
+ });
+
+ it("does not mutate process.env when an explicit model override is passed", () => {
+ process.env.SUPERCUT_MODEL = "original";
+ const p = resolveProvider({ DEEPSEEK_API_KEY: "deepseek" }, { model: "deepseek-v4-flash" });
+ expect(p.model).toBe("deepseek-v4-flash");
+ expect(process.env.SUPERCUT_MODEL).toBe("original");
+ });
+
+ it("requires an explicit model for custom OpenAI-compatible endpoints", () => {
+ expect(() =>
+ resolved({
+ SUPERCUT_PROVIDER: "custom",
+ SUPERCUT_API_KEY: "custom-key",
+ SUPERCUT_LLM_BASE_URL: "https://llm.example.com/v1",
+ }),
+ ).toThrow(/SUPERCUT_MODEL.*custom/i);
+
+ const p = resolved({
+ SUPERCUT_PROVIDER: "custom",
+ SUPERCUT_API_KEY: "custom-key",
+ SUPERCUT_LLM_BASE_URL: "https://llm.example.com/v1",
+ SUPERCUT_MODEL: "local-model",
+ });
+ expect(p.model).toBe("local-model");
+ });
+});
diff --git a/test/director-validation.test.ts b/test/director-validation.test.ts
new file mode 100644
index 0000000..c141bdb
--- /dev/null
+++ b/test/director-validation.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { validateAnalysis } from "../src/director/analyze.js";
+import type { PageDigest } from "../src/director/inventory.js";
+
+const digests: PageDigest[] = [
+ {
+ url: "http://127.0.0.1:9999/",
+ title: "Home",
+ headings: ["Home"],
+ inventory: [
+ { selector: "#cta", tag: "button", text: "Start", bbox: { x: 1, y: 2, w: 3, h: 4 } },
+ ],
+ },
+];
+
+describe("analysis validation", () => {
+ it("rejects money moments for non-crawled pages", () => {
+ expect(() =>
+ validateAnalysis(
+ {
+ product_summary: "A useful product with dashboard analytics.",
+ product_name: "Acme",
+ headline: "See your dashboard the moment you arrive",
+ tagline: "Analytics, instantly",
+ money_moments: [
+ { title: "Fake", caption: "Off-page", why: "not crawled", page_url: "http://127.0.0.1:9999/admin", elements: ["#cta"] },
+ { title: "Start", caption: "Get going", why: "real moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] },
+ ],
+ },
+ digests,
+ ),
+ ).toThrow(/not a crawled page/i);
+ });
+
+ it("rejects selectors not inventoried on the referenced page", () => {
+ expect(() =>
+ validateAnalysis(
+ {
+ product_summary: "A useful product with dashboard analytics.",
+ product_name: "Acme",
+ headline: "See your dashboard the moment you arrive",
+ tagline: "Analytics, instantly",
+ money_moments: [
+ { title: "Fake", caption: "Bad selector", why: "fake selector", page_url: "http://127.0.0.1:9999/", elements: ["#missing"] },
+ { title: "Start", caption: "Get going", why: "real moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] },
+ ],
+ },
+ digests,
+ ),
+ ).toThrow(/not in the inventory/i);
+ });
+});
diff --git a/test/director.test.ts b/test/director.test.ts
index 60f0f48..8390781 100644
--- a/test/director.test.ts
+++ b/test/director.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { extractJson, type ChatOptions, type LlmClient } from "../src/director/llm.js";
+import { DESTRUCTIVE_RE } from "../src/director/inventory.js";
import { writeRecipe } from "../src/director/script.js";
import { applyVerdicts, deterministicChecks } from "../src/director/qc.js";
import type { AppAnalysis } from "../src/director/analyze.js";
@@ -42,9 +43,12 @@ const digests: PageDigest[] = [
const analysis: AppAnalysis = {
product_summary: "A metrics dashboard for teams that want simple numbers.",
+ product_name: "Lumon",
+ headline: "Your metrics, the moment you sign up",
+ tagline: "Numbers without the setup",
money_moments: [
- { title: "Instant signup", why: "shows zero friction", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] },
- { title: "Typed email", why: "form payoff", page_url: "http://127.0.0.1:9999/", elements: ["#email"] },
+ { title: "Instant signup", caption: "Sign up in one click", why: "shows zero friction", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] },
+ { title: "Typed email", caption: "Your dashboard, instantly", why: "form payoff", page_url: "http://127.0.0.1:9999/", elements: ["#email"] },
],
};
@@ -62,6 +66,14 @@ function validRecipeJson(selector: string): string {
actions: [{ kind: "click", selector, duration_ms: 1500 }],
hold_ms: 400,
},
+ {
+ name: "email-payoff",
+ priority: 2,
+ entry: { url: "http://127.0.0.1:9999/", prelude: [] },
+ depends_on: [],
+ actions: [{ kind: "type", selector: "#email", text: "founder@example.com", duration_ms: 1500 }],
+ hold_ms: 600,
+ },
],
});
}
@@ -105,6 +117,40 @@ describe("script stage — the anti-hallucination gates", () => {
expect(attempts).toBe(2);
});
+ it("rejects recipes that skip storyboard beats", async () => {
+ const oneScene = JSON.parse(validRecipeJson("#cta")) as { scenes: unknown[] };
+ oneScene.scenes = oneScene.scenes.slice(0, 1);
+ const llm = new StubLlm([JSON.stringify(oneScene), validRecipeJson("#cta")]);
+ const { attempts } = await writeRecipe(llm, analysis, digests, "http://127.0.0.1:9999");
+ expect(attempts).toBe(2);
+ const retryText = llm.prompts[1]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" ");
+ expect(retryText).toContain("one per money moment");
+ });
+
+ it("rejects scenes that ignore the ordered money moment selector", async () => {
+ const wrongBeat = JSON.parse(validRecipeJson("#cta")) as {
+ scenes: { actions: { selector: string; kind: string; text?: string }[] }[];
+ };
+ wrongBeat.scenes[1]!.actions[0] = { kind: "click", selector: "#cta", duration_ms: 1500 };
+ const llm = new StubLlm([JSON.stringify(wrongBeat), validRecipeJson("#cta")]);
+ const { attempts } = await writeRecipe(llm, analysis, digests, "http://127.0.0.1:9999");
+ expect(attempts).toBe(2);
+ const retryText = llm.prompts[1]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" ");
+ expect(retryText).toContain("does not film storyboard beat");
+ });
+
+ it("rejects mid-scene goto actions that make the footage a random tour", async () => {
+ const withGoto = JSON.parse(validRecipeJson("#cta")) as {
+ scenes: { actions: { kind: string; url?: string; duration_ms: number; selector?: string; text?: string }[] }[];
+ };
+ withGoto.scenes[0]!.actions.unshift({ kind: "goto", url: "http://127.0.0.1:9999/dash", duration_ms: 1200 });
+ const llm = new StubLlm([JSON.stringify(withGoto), validRecipeJson("#cta")]);
+ const { attempts } = await writeRecipe(llm, analysis, digests, "http://127.0.0.1:9999");
+ expect(attempts).toBe(2);
+ const retryText = llm.prompts[1]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" ");
+ expect(retryText).toContain("mid-scene goto");
+ });
+
it("gives up loudly after 4 failed attempts", async () => {
const bad = validRecipeJson("#nope");
const llm = new StubLlm([bad, bad, bad, bad]);
@@ -128,6 +174,73 @@ describe("script stage — the anti-hallucination gates", () => {
});
});
+describe("destructive-action guard (H1)", () => {
+ it("matches destructive / irreversible / financial controls", () => {
+ for (const label of [
+ "Delete account",
+ "Delete",
+ "Deactivate",
+ "Wipe data",
+ "Erase everything",
+ "Cancel subscription",
+ "Cancel account",
+ "Pay now",
+ "Purchase",
+ "Buy now",
+ "Checkout",
+ "Place order",
+ "Withdraw",
+ "Confirm payment",
+ "Revoke access",
+ ]) {
+ expect(DESTRUCTIVE_RE.test(label), `expected "${label}" to match`).toBe(true);
+ }
+ });
+
+ it("does NOT match legitimate non-destructive actions", () => {
+ for (const label of [
+ "Sign in",
+ "Submit a search",
+ "Submit",
+ "Add",
+ "Add to cart",
+ "Save",
+ "Save changes",
+ "Open",
+ "View",
+ "View details",
+ "Create",
+ "Create project",
+ "Next",
+ "Continue",
+ "Get started free",
+ // hero/reversible actions that must stay filmable (narrowed lexicon):
+ "Send",
+ "Send message",
+ "Remove",
+ "Remove item",
+ "Reset",
+ "Reset filters",
+ "Archive",
+ "Disable",
+ "Unsubscribe",
+ "Transfer to list",
+ ]) {
+ expect(DESTRUCTIVE_RE.test(label), `expected "${label}" NOT to match`).toBe(false);
+ }
+ });
+
+ it("models the inventory exclude/allow toggle on a 'Delete account' element", () => {
+ // mirrors inventory.ts: an element is excluded when it matches and
+ // allowDestructive is false; included when allowDestructive is true.
+ const accepted = (text: string, allowDestructive: boolean) =>
+ allowDestructive || !DESTRUCTIVE_RE.test(text);
+ expect(accepted("Delete account", false)).toBe(false); // excluded by default
+ expect(accepted("Delete account", true)).toBe(true); // included on opt-in
+ expect(accepted("Sign in", false)).toBe(true); // benign always kept
+ });
+});
+
describe("QC verdicts — frozen patch surface", () => {
const recipe = JSON.parse(validRecipeJson("#cta")) as Recipe;
const twoSceneRecipe: Recipe = {
diff --git a/test/generate.e2e.test.ts b/test/generate.e2e.test.ts
index bf6429f..87ae003 100644
--- a/test/generate.e2e.test.ts
+++ b/test/generate.e2e.test.ts
@@ -41,7 +41,7 @@ afterAll(async () => {
describe("inventory crawler on the fixture app", () => {
it("extracts real, resolvable selectors and follows same-origin links", async () => {
- const digests = await crawlApp(app.url, { maxPages: 3, screenshots: false });
+ const digests = await crawlApp(app.url, { maxPages: 3, screenshots: false, allowPrivateNetwork: true });
expect(digests.length).toBeGreaterThanOrEqual(1);
const selectors = digests[0]!.inventory.map((i) => i.selector);
expect(selectors).toContain("#cta");
@@ -66,9 +66,12 @@ describe("generate E2E (stubbed brain, real pipeline)", () => {
// ① analyze response
JSON.stringify({
product_summary: "Lumon Metrics: a dashboard product with instant signup and live metrics.",
+ product_name: "Lumon",
+ headline: "Your team's numbers, live in seconds",
+ tagline: "Metrics without the setup",
money_moments: [
- { title: "Zero-friction signup", why: "form appears instantly", page_url: `${app.url}/`, elements: ["#cta", "#email"] },
- { title: "Live dashboard", why: "numbers count up live", page_url: `${app.url}/dash`, elements: ["#task-ship"] },
+ { title: "Zero-friction signup", caption: "Start in one click", why: "form appears instantly", page_url: `${app.url}/`, elements: ["#cta", "#email"] },
+ { title: "Live dashboard", caption: "Watch the numbers move", why: "numbers count up live", page_url: `${app.url}/dash`, elements: ["#task-ship"] },
],
}),
// ② script response — real selectors from the fixture app
@@ -112,6 +115,7 @@ describe("generate E2E (stubbed brain, real pipeline)", () => {
url: app.url,
outDir,
seed: 7,
+ allowPrivateNetwork: true,
log: () => {},
});
@@ -128,7 +132,7 @@ describe("generate E2E (stubbed brain, real pipeline)", () => {
it("fails fast on an unreachable app URL (before any LLM call)", async () => {
const llm = new ScriptedLlm(() => []);
await expect(
- generate({ llm, url: "http://127.0.0.1:1", outDir: mkdtempSync(join(tmpdir(), "supercut-dead-")), log: () => {} }),
+ generate({ llm, url: "http://127.0.0.1:1", outDir: mkdtempSync(join(tmpdir(), "supercut-dead-")), allowPrivateNetwork: true, log: () => {} }),
).rejects.toThrow(/cannot reach/);
expect(llm.calls).toBe(0);
}, 30_000);
diff --git a/test/plan.test.ts b/test/plan.test.ts
index e7e5468..7d9b416 100644
--- a/test/plan.test.ts
+++ b/test/plan.test.ts
@@ -90,6 +90,58 @@ describe("buildRenderPlan", () => {
});
});
+describe("frame-the-result (4b): camera prefers focus_bbox", () => {
+ // a type into a tiny input at the top-right that PRODUCES a large central
+ // result region — the camera must hold on the result, not the input box.
+ const focusLog = makeLog([
+ { t: 1000, type: "scene", name: "s1", priority: 1 },
+ {
+ t: 1500,
+ type: "type",
+ bbox: [1740, 40, 160, 40], // the input, top-right corner
+ focus_bbox: [360, 240, 1200, 700], // the result region, center → (960, 590)
+ selector: "#q",
+ textLen: 4,
+ },
+ { t: 0, type: "cursor_path", points: [[0, 960, 980], [1500, 1820, 60]] },
+ ]);
+
+ it("frames the result region center, not the interaction bbox", () => {
+ const plan = buildRenderPlan(focusLog, frameIndex);
+ const layout = defaultLayout(viewport);
+ const s = layout.content.w / viewport.width;
+ const i = 95 * SUBFRAMES * 3; // ~event moment (t≈1500ms)
+ const cx = plan.camera[i + 1]!;
+ const cy = plan.camera[i + 2]!;
+ // near the result-region center (960, 590), far from the input center (1820, 60)
+ expect(Math.abs(cx - (layout.content.x + 960 * s))).toBeLessThan(40);
+ expect(Math.abs(cy - (layout.content.y + 590 * s))).toBeLessThan(40);
+ expect(Math.abs(cx - (layout.content.x + 1820 * s))).toBeGreaterThan(200);
+ });
+
+ it("fit-zooms a large region so it fills the frame instead of cropping it", () => {
+ const plan = buildRenderPlan(focusLog, frameIndex);
+ const zAt = (frame: number) => plan.camera[(frame * SUBFRAMES) * 3]!;
+ const z = zAt(95);
+ // a 1200x700 region in 1920x1080 fits at ~1.36x — gentler than the fixed
+ // 1.48 punch-in (proving the fit math), but still a real zoom (>1).
+ expect(z).toBeGreaterThan(1.05);
+ expect(z).toBeLessThan(1.48);
+ });
+
+ it("holds on the payoff longer than a plain interaction (FOCUS_DWELL)", () => {
+ const longIndex = Array.from({ length: 600 }, (_, i) => ({
+ file: `frames/${String(i).padStart(6, "0")}.png`,
+ t_source: i * 16,
+ }));
+ const plan = buildRenderPlan(focusLog, longIndex);
+ const zAt = (frame: number) => plan.camera[(frame * SUBFRAMES) * 3]!;
+ // still zoomed ~3s after the event (focus dwell 4200ms > plain dwell 1500ms)
+ // event t=1500ms ≈ frame 90; +3000ms ≈ frame 270
+ expect(zAt(270)).toBeGreaterThan(1.05);
+ });
+});
+
describe("plan input bounds (PR #1 review)", () => {
it("throws on a corrupt huge timestamp instead of allocating the moon", () => {
const evil = makeLog([
diff --git a/test/record.e2e.test.ts b/test/record.e2e.test.ts
index d616fbc..f1597bd 100644
--- a/test/record.e2e.test.ts
+++ b/test/record.e2e.test.ts
@@ -84,8 +84,8 @@ describe("record E2E on fixture app", () => {
const out2 = mkdtempSync(join(tmpdir(), "supercut-take2-"));
dirs.push(out1, out2);
- const r1 = await record({ recipe, outDir: out1, seed: 42 });
- const r2 = await record({ recipe, outDir: out2, seed: 42 });
+ const r1 = await record({ recipe, outDir: out1, seed: 42, allowPrivateNetwork: true });
+ const r2 = await record({ recipe, outDir: out2, seed: 42, allowPrivateNetwork: true });
// no failures on the fixture
expect(r1.aborted).toBe(false);
@@ -179,7 +179,7 @@ describe("record E2E on fixture app", () => {
const out = mkdtempSync(join(tmpdir(), "supercut-fail-"));
dirs.push(out);
// captureFrames off: this test is about failure policy, not pixels
- const res = await record({ recipe, outDir: out, seed: 1, captureFrames: false });
+ const res = await record({ recipe, outDir: out, seed: 1, captureFrames: false, allowPrivateNetwork: true });
expect(res.failedScenes).toContain("broken");
expect(res.failedScenes).toContain("child-of-broken");
@@ -208,7 +208,7 @@ describe("record E2E on fixture app", () => {
const out = mkdtempSync(join(tmpdir(), "supercut-partial-"));
dirs.push(out);
- const res = await record({ recipe, outDir: out, seed: 1, captureFrames: false });
+ const res = await record({ recipe, outDir: out, seed: 1, captureFrames: false, allowPrivateNetwork: true });
expect(res.aborted).toBe(false); // 1 of 3 ≤ 50% → take survives
expect(res.failedScenes).toEqual(["bad-mid"]);
diff --git a/test/redaction.test.ts b/test/redaction.test.ts
new file mode 100644
index 0000000..a46c387
--- /dev/null
+++ b/test/redaction.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { redactForPrompt } from "../src/security/redaction.js";
+
+describe("prompt redaction", () => {
+ it("redacts common credentials and emails before LLM prompts", () => {
+ const redacted = redactForPrompt("email ada@example.com api_key=sk-1234567890abcdef token: secret-value password=hunter2");
+ expect(redacted).not.toContain("ada@example.com");
+ expect(redacted).not.toContain("sk-1234567890abcdef");
+ expect(redacted).not.toContain("secret-value");
+ expect(redacted).not.toContain("hunter2");
+ expect(redacted).toContain("[REDACTED_EMAIL]");
+ expect(redacted).toContain("api_key=[REDACTED]");
+ });
+});
diff --git a/test/schema.test.ts b/test/schema.test.ts
index 21b8c8c..4a7c2a1 100644
--- a/test/schema.test.ts
+++ b/test/schema.test.ts
@@ -203,3 +203,107 @@ describe("recipe hardening (PR #1 review)", () => {
expect(() => parseRecipe(r)).toThrow();
});
});
+
+describe("schema hardening", () => {
+ it("rejects unknown recipe fields instead of silently dropping hallucinated keys", () => {
+ const raw = makeRecipe();
+ (raw.scenes[0] as unknown as Record).voiceover = "this field is not supported";
+ expect(() => parseRecipe(raw)).toThrow();
+ });
+
+ it("rejects invalid zoom boxes", () => {
+ const raw = makeRecipe();
+ raw.scenes[0]!.actions[0]!.zoom = [-10, 0, -1, 0];
+ expect(() => parseRecipe(raw)).toThrow(/zoom/i);
+ });
+
+ it("rejects event logs with too many events", () => {
+ const events = Array.from({ length: 5001 }, (_, i) => ({ type: "scene", t: i, name: `s${i}`, priority: 1 }));
+ expect(() => parseEventLog({ version: 0, viewport: { width: 1920, height: 1080, dpr: 2 }, fps: 60, events })).toThrow(
+ /too many events/i,
+ );
+ });
+
+ it("rejects cursor paths with too many points", () => {
+ const points = Array.from({ length: 20001 }, (_, i) => [i, 1, 1]);
+ expect(() =>
+ parseEventLog({
+ version: 0,
+ viewport: { width: 1920, height: 1080, dpr: 2 },
+ fps: 60,
+ events: [{ type: "cursor_path", t: 0, points }],
+ }),
+ ).toThrow(/too many cursor points/i);
+ });
+
+ it("rejects known events that go backwards in time", () => {
+ expect(() =>
+ parseEventLog({
+ version: 0,
+ viewport: { width: 1920, height: 1080, dpr: 2 },
+ fps: 60,
+ events: [
+ { type: "scene", t: 100, name: "a", priority: 1 },
+ { type: "click", t: 50, bbox: [0, 0, 10, 10], selector: "#x", point: [5, 5] },
+ ],
+ }),
+ ).toThrow(/monotonic/i);
+ });
+});
+
+describe("submit + frame-the-result schema (4b)", () => {
+ it("accepts a type action with submit and focus_selector", () => {
+ const r = parseRecipe(
+ makeRecipe({
+ scenes: [
+ {
+ name: "search",
+ priority: 1,
+ entry: { url: "http://localhost:3000/", prelude: [] },
+ depends_on: [],
+ actions: [
+ { kind: "type", selector: "#q", text: "NVDA", submit: true, focus_selector: "#graph", duration_ms: 2000 },
+ ],
+ hold_ms: 500,
+ },
+ ],
+ }),
+ );
+ const a = r.scenes[0]!.actions[0]!;
+ expect(a.submit).toBe(true);
+ expect(a.focus_selector).toBe("#graph");
+ });
+
+ it("leaves submit/focus_selector undefined when omitted (backward compatible)", () => {
+ const a = parseRecipe(makeRecipe()).scenes[0]!.actions[0]!;
+ expect(a.submit).toBeUndefined();
+ expect(a.focus_selector).toBeUndefined();
+ });
+
+ it("event log round-trips an action event with focus_bbox", () => {
+ const log = parseEventLog({
+ version: 0,
+ viewport: { width: 1920, height: 1080, dpr: 2 },
+ fps: 60,
+ events: [
+ { t: 1000, type: "type", bbox: [10, 20, 100, 40], focus_bbox: [200, 200, 1000, 700], selector: "#q", textLen: 4 },
+ ],
+ });
+ const ev = log.events[0]!;
+ expect(ev.type).toBe("type");
+ expect((ev as { focus_bbox?: number[] }).focus_bbox).toEqual([200, 200, 1000, 700]);
+ });
+
+ it("rejects a focus_bbox with non-positive width/height", () => {
+ expect(() =>
+ parseEventLog({
+ version: 0,
+ viewport: { width: 1920, height: 1080, dpr: 2 },
+ fps: 60,
+ events: [
+ { t: 1000, type: "click", bbox: [10, 20, 100, 40], focus_bbox: [0, 0, 0, 100], selector: "#q", point: [60, 40] },
+ ],
+ }),
+ ).toThrow();
+ });
+});
diff --git a/test/source-routes.test.ts b/test/source-routes.test.ts
new file mode 100644
index 0000000..0410354
--- /dev/null
+++ b/test/source-routes.test.ts
@@ -0,0 +1,72 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { extractAppRoutes, routesToSeedAndNotes } from "../src/director/sourceRoutes.js";
+
+/** Build a fake Next.js monorepo on disk to exercise route derivation. */
+let root: string;
+
+beforeAll(() => {
+ root = mkdtempSync(join(tmpdir(), "supercut-src-"));
+ const web = join(root, "apps", "web", "src", "app");
+ mkdirSync(web, { recursive: true });
+ const mk = (dir: string, body: string) => {
+ mkdirSync(join(web, dir), { recursive: true });
+ writeFileSync(join(web, dir, "page.tsx"), body);
+ };
+ writeFileSync(join(web, "page.tsx"), `export default () => Welcome home ;`);
+ mk("dashboard", `export default () =>
Monday Brief Open report ;`);
+ mk("locations", `export default () => The roster of locations ;`);
+ mk("(marketing)/pricing", `export default () => Pricing plans ;`); // route group → stripped
+ mk("items/[id]", `export default () => Item detail ;`); // dynamic
+ // noise that must be ignored
+ mkdirSync(join(root, "apps", "web", "node_modules", "pkg", "app", "evil"), { recursive: true });
+ writeFileSync(join(root, "apps", "web", "node_modules", "pkg", "app", "evil", "page.tsx"), `nope `);
+ // a second app to test monorepo scoping
+ const other = join(root, "apps", "admin", "src", "app");
+ mkdirSync(other, { recursive: true });
+ writeFileSync(join(other, "page.tsx"), `Admin `);
+});
+
+afterAll(() => rmSync(root, { recursive: true, force: true }));
+
+describe("extractAppRoutes", () => {
+ it("derives app-router routes, strips route groups, flags dynamic, skips node_modules", () => {
+ const routes = extractAppRoutes(join(root, "apps", "web"));
+ const map = new Map(routes.map((r) => [r.route, r]));
+ expect([...map.keys()].sort()).toEqual(["/", "/dashboard", "/items/[id]", "/locations", "/pricing"]);
+ expect(map.get("/items/[id]")!.dynamic).toBe(true);
+ expect(map.get("/dashboard")!.dynamic).toBe(false);
+ // never crawl node_modules
+ expect(routes.some((r) => r.file.includes("node_modules"))).toBe(false);
+ });
+
+ it("extracts a human summary from the page source", () => {
+ const routes = extractAppRoutes(join(root, "apps", "web"));
+ const dash = routes.find((r) => r.route === "/dashboard")!;
+ expect(dash.summary).toContain("Monday Brief");
+ });
+
+ it("scopes to one app in a monorepo via appName", () => {
+ const webOnly = extractAppRoutes(root, { appName: "web" });
+ expect(webOnly.some((r) => r.file.includes(`${"admin"}`))).toBe(false);
+ expect(webOnly.some((r) => r.route === "/dashboard")).toBe(true);
+ });
+
+ it("routesToSeedAndNotes seeds concrete routes only (no dynamic), same-origin", () => {
+ const routes = extractAppRoutes(join(root, "apps", "web"));
+ const { seedUrls, notes } = routesToSeedAndNotes(routes, "http://127.0.0.1:3100");
+ expect(seedUrls).toContain("http://127.0.0.1:3100/dashboard");
+ expect(seedUrls.some((u) => u.includes("[id]"))).toBe(false); // dynamic not seeded
+ expect(notes).toContain("/items/[id] (dynamic)"); // but still described
+ expect(notes).toContain("/dashboard");
+ });
+
+ it("returns [] for a non-framework directory (caller falls back to link-only)", () => {
+ const empty = mkdtempSync(join(tmpdir(), "supercut-empty-"));
+ writeFileSync(join(empty, "readme.md"), "just docs");
+ expect(extractAppRoutes(empty)).toEqual([]);
+ rmSync(empty, { recursive: true, force: true });
+ });
+});
diff --git a/test/url-policy.test.ts b/test/url-policy.test.ts
new file mode 100644
index 0000000..7996420
--- /dev/null
+++ b/test/url-policy.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { assertSafeNavigationUrl } from "../src/security/url-policy.js";
+
+describe("navigation URL policy", () => {
+ it("blocks cloud metadata addresses by default", async () => {
+ await expect(assertSafeNavigationUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(/private network/i);
+ });
+
+ it("blocks localhost by default but allows it explicitly", async () => {
+ await expect(assertSafeNavigationUrl("http://127.0.0.1:3000/")).rejects.toThrow(/private network/i);
+ await expect(assertSafeNavigationUrl("http://127.0.0.1:3000/", { allowPrivateNetwork: true })).resolves.toBeUndefined();
+ });
+
+ it("blocks bracketed IPv6 localhost and ULA literals by default", async () => {
+ await expect(assertSafeNavigationUrl("http://[::1]:3000/")).rejects.toThrow(/private network/i);
+ await expect(assertSafeNavigationUrl("http://[fd00::1]/")).rejects.toThrow(/private network/i);
+ });
+
+ it("rejects alternate IP encodings of loopback when private nets are blocked", async () => {
+ // decimal int, hex int, and IPv4-mapped IPv6 all canonicalize to 127.0.0.1
+ await expect(assertSafeNavigationUrl("http://2130706433/")).rejects.toThrow(/private network/i);
+ await expect(assertSafeNavigationUrl("http://0x7f000001/")).rejects.toThrow(/private network/i);
+ await expect(assertSafeNavigationUrl("http://[::ffff:127.0.0.1]/")).rejects.toThrow(/private network/i);
+ });
+
+ it("rejects redirects to private networks", async () => {
+ await expect(
+ assertSafeNavigationUrl("https://example.com/start", {
+ finalUrl: "http://10.0.0.2/admin",
+ }),
+ ).rejects.toThrow(/redirect/i);
+ });
+});