diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 08b648a..b2c9265 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,10 +24,22 @@ jobs:
cache: npm
- run: npm ci
- run: npm run typecheck
+ # the package's distribution shape is part of the contract: tsc must
+ # actually EMIT, the bin entry must exist and resolve, and the tarball
+ # must stay lean (assets are allowlisted in package.json "files")
+ - run: npm run build
+ - name: verify bin entry resolves
+ run: |
+ test -f dist/cli/index.js
+ node dist/cli/index.js --help > /dev/null
+ - run: npm pack --dry-run
# 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'
+ # deliberately STRICT (no --omit=dev): dev-tree advisories must stay
+ # visible. This is green because the lockfile is clean, not because the
+ # check was weakened — keep it that way.
- run: npm audit --audit-level=moderate
browser-e2e:
@@ -41,4 +53,7 @@ jobs:
- 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
+ # filter by the *.e2e.test.ts naming convention (not an explicit file
+ # list) so a new e2e file cannot be silently skipped here — the same
+ # rule the unit job above already follows in the other direction
+ - run: npm test -- --run e2e.test
diff --git a/README.md b/README.md
index 9e88fdb..b9a1bc0 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -17,7 +17,7 @@
-
+
Generated by supercut from a live web app — zero manual editing. The cursor, the camera, the cuts: all automatic.
@@ -26,7 +26,7 @@
**You built something great. Now you need a launch video — and all you've got is a screen recording, iMovie, and a deadline.**
-`supercut` is a launch video generator for the command line — a Screen Studio alternative that makes a product demo video straight from a URL. Point its 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 cinematically — spring zoom-to-cursor, motion blur, music, a wallpaper stage, and a clean 1080p60 export.
+`supercut` is an AI director for launch videos. Screen recorders hand you a canvas and a timeline and leave the direction to you — supercut decides what to film by itself. Point it at your *running* app: it reads your source, crawls the live UI, picks the 2–4 moments that actually sell the product, drives a real browser to perform them on camera, then renders the result cinematically — spring zoom-to-cursor, motion blur, music, a wallpaper stage, and a clean 1080p60 export.
> Not a screen recording. Not a fake UI mockup. **Your real product**, shot like a launch film — automatically.
@@ -53,17 +53,29 @@ Each stage hands off a plain-JSON artifact, so you can stop at any point, hand-e
## 🚀 Quick start
+```bash
+# your app running locally? one command:
+npx supercut generate --url http://localhost:3000 --yes
+```
+
+`generate` needs an LLM key in a `.env` (see [provider setup](#-llm-provider-setup)),
+plus Chromium and ffmpeg: `npx playwright install chromium`, an `ffmpeg` on your PATH,
+and `npx supercut doctor` checks both.
+
+> Any command accepts `--help` to print its own usage (e.g. `npx supercut generate --help`).
+
+### From source (contributors — and the no-API-key demo)
+
```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 against the bundled demo app:
+No key? The non-AI path works standalone against the bundled demo app:
```bash
# 1. serve the bundled demo app on port 4173 (or: npx serve -l 4173 examples/demo-app)
@@ -76,10 +88,6 @@ 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.
-
-> Any command accepts `--help` to print its own usage (e.g. `node dist/cli/index.js generate --help`).
-
### 🤖 Or: let your coding agent set it up
Already living in **Claude Code, Codex, opencode, Cursor, or Cline**? Don't run the
@@ -130,9 +138,13 @@ node dist/cli/index.js generate --url https://untrusted.example --block-private-
```
(`--allow-private-network` is a deprecated no-op kept for back-compat. With the guard on,
-the crawler also resolves-and-pins the target host's DNS so a rebinding hostname can't
-swap in a private IP mid-run; the `record` stage's browser does not pin yet, so its
-navigations are validated as URLs only.)
+both the crawler and the `record` stage resolve-and-pin their target hosts' DNS so a
+rebinding hostname can't swap in a private IP mid-run, and every in-flight browser
+request — navigations from clicked links and submits, `fetch`/XHR, images, scripts,
+and WebSocket connections — is checked against the policy before it leaves the
+browser. WebSocket gating relies on Playwright's `routeWebSocket`; if you run supercut
+against a Playwright older than 1.48 it prints a warning and WebSocket connections are
+**not** policy-checked.)
> ⚠️ **supercut drives and may MUTATE the target app** — it performs real clicks and
> typing on whatever you point it at. Destructive controls (Delete, Remove, Pay, …)
@@ -140,6 +152,10 @@ navigations are validated as URLs only.)
> English-only**: it matches visible labels and cannot catch icon-only buttons or
> other wording. Film against a disposable/staging environment, never production
> data or URLs/recipes you do not trust. Pass `--allow-destructive` to opt back in.
+>
+> `generate` prints the recipe's full action list — every selector and every typed
+> string — before filming starts, and `--dry-run` stops right there: review
+> `recipe.json`, then film it with `supercut record`.
## 🔌 LLM provider setup
@@ -253,7 +269,18 @@ take directory ──▶ render ──▶ final.mp4
Schemas reject unsupported URL schemes, malformed events, non-monotonic timelines,
oversized logs, and impossible camera boxes.
-Event timestamps share the frame `t_source` clock: identical runs now produce structurally/geometrically identical events.json with timestamps agreeing within ~150ms (not byte-identical), and renders fail when events lead the footage by >250ms unless `SUPERCUT_ALLOW_SKEW=1` (legacy sparse takes only warn).
+Event timestamps share the frame `t_source` clock, declared by `t_source_unified: true`
+in `events.json` (the built-in recorder always writes it). Identical runs produce
+structurally/geometrically identical events.json with timestamps agreeing within ~150ms
+(not byte-identical). Two render-time gates protect the output:
+
+- **Skew**: on a unified-clock take, events leading the footage by >250ms fail the render
+ (`SUPERCUT_ALLOW_SKEW=1` forces). Logs without the marker are treated as legacy
+ recorders whose clocks were never unified, and only warn.
+- **Capture health**: a take whose frame count falls far below its duration × fps is
+ refused — that footage renders as stills with a camera gliding over them. Average
+ source fps is printed on every `record`/`generate`/`render` run. To render a genuinely
+ sparse take (e.g. from an old change-driven recorder) set `SUPERCUT_ALLOW_SPARSE=1`.
## Project principles
diff --git a/assets/music/CREDITS.md b/assets/music/CREDITS.md
index ea44668..b310ad0 100644
--- a/assets/music/CREDITS.md
+++ b/assets/music/CREDITS.md
@@ -24,7 +24,7 @@ Use them in your videos — commercial or not — with no attribution required.
| track | vibe | length | bpm |
| -------------- | ----------------------------- | ------ | ---- |
-| `pulse.mp3` | minimal tech-house, sleek | 95s | ~104 |
-| `daybreak.mp3` | bright melodic house, upbeat | 95s | ~110 |
-| `midnight.mp3` | dark synthwave/techno, premium| 100s | ~100 |
-| `momentum.mp3` | driving minimal techno | 95s | ~122 |
+| `pulse.mp3` | minimal tech-house, sleek | 92s | ~104 |
+| `daybreak.mp3` | bright melodic house, upbeat | 92s | ~110 |
+| `midnight.mp3` | dark synthwave/techno, premium| 92s | ~100 |
+| `momentum.mp3` | driving minimal techno | 91s | ~122 |
diff --git a/package-lock.json b/package-lock.json
index 2d49317..762a27d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1377,9 +1377,9 @@
}
},
"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==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -1467,9 +1467,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -1487,7 +1487,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
diff --git a/package.json b/package.json
index 93aac07..464f68f 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,11 @@
{
"name": "supercut",
- "version": "0.0.1",
- "description": "AI launch-video generator: films your real web app with a cinematic camera, motion blur, and music — a CLI alternative to Screen Studio for product demos",
+ "version": "0.1.0",
+ "description": "An AI director that films your real web app by itself: it decides the money moments, drives a browser to perform them, and renders a cinematic 60s launch video — camera, motion blur, music, done",
"keywords": [
"launch-video",
+ "ai-director",
"product-demo",
- "screen-recording",
- "screen-studio-alternative",
"demo-video",
"marketing-video",
"ai-video",
@@ -15,9 +14,20 @@
"cinematic",
"cli",
"video-generator",
+ "screen-recording",
+ "screen-studio-alternative",
"product-hunt"
],
"license": "MIT",
+ "author": "Brayden Siew",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Co-Messi/supercut.git"
+ },
+ "homepage": "https://supercut-five.vercel.app",
+ "bugs": {
+ "url": "https://github.com/Co-Messi/supercut/issues"
+ },
"type": "module",
"engines": {
"node": ">=20"
@@ -27,7 +37,8 @@
},
"files": [
"dist",
- "assets",
+ "assets/backgrounds",
+ "assets/music",
"examples/demo-app",
"examples/demo.recipe.json"
],
@@ -39,7 +50,7 @@
"typecheck": "tsc --noEmit",
"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"
+ "test:e2e": "vitest run e2e.test"
},
"dependencies": {
"playwright": "^1.53.0",
diff --git a/src/capture/executor.ts b/src/capture/executor.ts
index dc951cf..83dce89 100644
--- a/src/capture/executor.ts
+++ b/src/capture/executor.ts
@@ -29,7 +29,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";
+import { assertSafeNavigationUrl, createRequestGate, gateWebSockets, resolveAndPinHost } from "../security/url-policy.js";
const VIEWPORT = { width: 1920, height: 1080 };
const DPR = 2;
@@ -159,13 +159,23 @@ export interface RecordOptions {
seed?: number;
/** Skip screencast (faster scheduling-only tests). */
captureFrames?: boolean;
- /** allow localhost/RFC1918/cloud-metadata navigation; off by default for safety */
+ /** Allow localhost/RFC1918/link-local navigation. Defaults to FALSE: the
+ * library fails closed and callers opt in. Every caller in this repo
+ * (generate(), the CLI) passes the value explicitly — the CLI allows by
+ * default and --block-private-network opts the guard in — so the default
+ * exists only for external embedders, and for them the safe direction is
+ * closed (matching crawlApp()'s default). With the guard on, the recipe's
+ * URLs are policy-checked, the target hosts are DNS resolve-and-pinned,
+ * and every in-flight request is gated. */
allowPrivateNetwork?: boolean;
}
export interface RecordResult {
eventLog: EventLog;
frameCount: number;
+ /** frames captured per second of take time (frame + event span). ~60 on a
+ * healthy beacon-era capture; near zero when the screencast starved. */
+ avgSourceFps: number;
failedScenes: string[];
aborted: boolean;
outDir: string;
@@ -219,9 +229,38 @@ export async function record(opts: RecordOptions): Promise {
mkdirSync(join(outDir, "frames"), { recursive: true });
+ // guard ON: resolve-and-pin every recipe host so the browser connects to the
+ // exact IPs the policy vetted — a DNS re-resolve mid-run can't swap in a
+ // private one (same defense the crawler applies).
+ // Note: this re-resolves hosts that assertRecipeNavigationPolicy above
+ // already resolved — a second lookup and a small TOCTOU window between the
+ // two. Deliberate: the assert is a pure yes/no policy check, the pin is the
+ // one whose answer the browser actually connects to, and collapsing them
+ // would couple the policy module to Chromium launch-arg formatting.
+ const launchArgs: string[] = [];
+ if (!allowPrivateNetwork) {
+ const rules: string[] = [];
+ const seenHosts = new Set();
+ const recipeUrls: string[] = [];
+ for (const scene of recipe.scenes) {
+ recipeUrls.push(scene.entry.url);
+ for (const action of [...scene.entry.prelude, ...scene.actions]) {
+ if (action.kind === "goto" && action.url) recipeUrls.push(action.url);
+ }
+ }
+ for (const u of recipeUrls) {
+ const host = new URL(u).hostname;
+ if (seenHosts.has(host)) continue;
+ seenHosts.add(host);
+ const pinned = await resolveAndPinHost(u, { allowPrivateNetwork });
+ if (pinned) rules.push(pinned.hostResolverRule);
+ }
+ if (rules.length > 0) launchArgs.push(`--host-resolver-rules=${rules.join(",")}`);
+ }
+
// launch is the only setup outside try/finally; everything else (newPage,
// CDP session) lives inside so a setup failure can't leak the browser
- const browser = await chromium.launch({ headless: true });
+ const browser = await chromium.launch({ headless: true, args: launchArgs });
const events: KnownEvent[] = [];
const pathPoints: [number, number, number][] = []; // [t, x, y] global cursor track
@@ -487,6 +526,25 @@ export async function record(opts: RecordOptions): Promise {
try {
page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: DPR });
+ // guard ON: gate EVERY in-flight request (clicked links, Enter submits,
+ // redirects, subresources) — assertSafeNavigationUrl only covers entry/goto
+ // URLs known from the recipe, but a click on an a[href] or a submit
+ // navigates with no pre-check. Installed ONLY when the guard is engaged:
+ // route interception funnels every request through Node, and the default
+ // local-app path must not pay that tax during a 60fps capture.
+ if (!allowPrivateNetwork) {
+ const gate = createRequestGate({ allowPrivateNetwork });
+ await page.context().route("**/*", async (route) => {
+ if (!(await gate.allows(route.request().url()))) return route.abort();
+ return route.continue();
+ });
+ // WebSocket upgrades bypass ctx.route — gate them separately
+ if (!(await gateWebSockets(page.context(), gate))) {
+ console.error(
+ "warning: this Playwright build lacks routeWebSocket — WebSocket connections are NOT policy-checked",
+ );
+ }
+ }
if (captureFrames) await page.addInitScript(REPAINT_BEACON_SCRIPT);
await page.addInitScript(MUTATION_OBSERVER_SCRIPT);
cdp = await page.context().newCDPSession(page);
@@ -653,6 +711,10 @@ export async function record(opts: RecordOptions): Promise {
const eventLog: EventLog = {
version: 0,
+ // clock declaration (schema): event `t` shares the frame t_source timeline.
+ // The render stage keys its skew/health gates off this marker — never off
+ // the capture's frame rate — so a starved take can't pass as "legacy".
+ t_source_unified: true,
viewport: { width: VIEWPORT.width, height: VIEWPORT.height, dpr: DPR },
fps: FPS,
events,
@@ -665,5 +727,15 @@ export async function record(opts: RecordOptions): Promise {
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 };
+ // capture-health telemetry: frames per second of take time. The span uses
+ // BOTH clocks (last frame t_source and last event t) so a capture that
+ // stalled early — few frames, but a long event timeline — reads as sparse
+ // instead of hiding behind its own short frame span.
+ let maxEventT = 0;
+ for (const e of events) maxEventT = Math.max(maxEventT, e.t);
+ const lastFrameT = frameIndex.length ? frameIndex[frameIndex.length - 1]!.t_source : 0;
+ const spanMs = Math.max(lastFrameT, maxEventT);
+ const avgSourceFps = spanMs > 0 ? (frameIndex.length / spanMs) * 1000 : 0;
+
+ return { eventLog, frameCount: frameIndex.length, avgSourceFps, failedScenes, aborted, outDir };
}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index cf2707e..8e5ea48 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -11,7 +11,7 @@ import { doctor } from "./doctor.js";
* supercut doctor check deps
*/
-const HELP = `supercut — institutional-grade 60s launch videos from your real app
+const HELP = `supercut — an AI director that films your real app into a cinematic 60s launch video
Usage:
supercut generate --url [--repo ] [--music