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 +

+ supercut — real app footage → cinematic launch video +

-> 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. +

+ Quick start + License: MIT + Node >= 20 + TypeScript + Status: pre-release +

-**Status: pre-release, under active construction.** The design doc and build -plan are complete; stages are landing in order. Nothing to install yet. +

+ supercut filming a live app: it opens the console, fills in a record, and frames the resulting audit +
+ 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

;`); + 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); + }); +});