agentreel 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/agentreel.mjs CHANGED
@@ -1,284 +1,348 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execFileSync, spawn } from "node:child_process";
3
+ import { execFileSync, spawnSync, spawn } from "node:child_process";
4
4
  import { readFileSync, writeFileSync, statSync, existsSync, mkdirSync, copyFileSync } from "node:fs";
5
5
  import { join, dirname, resolve } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createInterface } from "node:readline";
9
+ import vm from "node:vm";
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const ROOT = resolve(__dirname, "..");
12
13
 
13
- // ── CLI flags ───────────────────────────────────────────────
14
+ // ── Helpers ────────────────────────────────────────────────
15
+
16
+ function claude(prompt, timeout = 300000) {
17
+ const result = execFileSync("claude", ["-p", prompt, "--output-format", "text"], {
18
+ encoding: "utf-8", timeout, stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ return stripFences(result);
21
+ }
22
+
23
+ function stripFences(text) {
24
+ if (!text.includes("```")) return text;
25
+ for (let part of text.split("```")) {
26
+ part = part.trim();
27
+ if (part.startsWith("json")) part = part.slice(4).trim();
28
+ if (part.startsWith("[") || part.startsWith("{")) return part;
29
+ }
30
+ return text;
31
+ }
32
+
33
+ function parseJSON(text, fallback) {
34
+ try { return JSON.parse(text); }
35
+ catch { return fallback; }
36
+ }
37
+
38
+ // ── CLI flags ──────────────────────────────────────────────
14
39
 
15
40
  function parseArgs() {
16
41
  const args = process.argv.slice(2);
17
42
  const flags = {};
18
43
  for (let i = 0; i < args.length; i++) {
19
- const arg = args[i];
20
- if (arg === "--help" || arg === "-h") { printUsage(); process.exit(0); }
21
- if (arg === "--version" || arg === "-v") {
22
- const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
23
- console.log(pkg.version);
44
+ const a = args[i];
45
+ if (a === "--help" || a === "-h") { printUsage(); process.exit(0); }
46
+ if (a === "--version" || a === "-v") {
47
+ console.log(JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")).version);
24
48
  process.exit(0);
25
49
  }
26
- if (arg === "--cmd" || arg === "-c") flags.cmd = args[++i];
27
- else if (arg === "--url" || arg === "-u") flags.url = args[++i];
28
- else if (arg === "--pr") flags.pr = args[++i];
29
- else if (arg === "--start") flags.start = args[++i];
30
- else if (arg === "--title" || arg === "-t") flags.title = args[++i];
31
- else if (arg === "--output" || arg === "-o") flags.output = args[++i];
32
- else if (arg === "--music") flags.music = args[++i];
33
- else if (arg === "--auth" || arg === "-a") flags.auth = args[++i];
34
- else if (arg === "--guidelines" || arg === "-g") flags.guidelines = args[++i];
35
- else if (arg === "--no-share") flags.noShare = true;
50
+ if (a === "--cmd" || a === "-c") flags.cmd = args[++i];
51
+ else if (a === "--url" || a === "-u") flags.url = args[++i];
52
+ else if (a === "--pr") flags.pr = args[++i];
53
+ else if (a === "--start") flags.start = args[++i];
54
+ else if (a === "--title" || a === "-t") flags.title = args[++i];
55
+ else if (a === "--subtitle" || a === "-s") flags.subtitle = args[++i];
56
+ else if (a === "--output" || a === "-o") flags.output = args[++i];
57
+ else if (a === "--music") flags.music = args[++i];
58
+ else if (a === "--auth" || a === "-a") flags.auth = args[++i];
59
+ else if (a === "--guidelines" || a === "-g") flags.guidelines = args[++i];
60
+ else if (a === "--no-share") flags.noShare = true;
36
61
  }
37
62
  return flags;
38
63
  }
39
64
 
40
65
  function printUsage() {
41
- console.log(`agentreel — Turn your web apps and CLIs into viral clips
66
+ console.log(`agentreel — Turn your apps into demo videos
42
67
 
43
68
  Usage:
44
- agentreel --pr 123 # demo a PR (reads context from GitHub)
45
- agentreel --pr owner/repo#123 # demo a PR (explicit repo)
46
- agentreel --cmd "npx my-cli-tool" # CLI demo
47
- agentreel --url http://localhost:3000 # browser demo
69
+ agentreel --pr 123 # demo a PR
70
+ agentreel --cmd "npx my-tool" # CLI demo
71
+ agentreel --url http://localhost:3000 # browser demo
48
72
 
49
73
  Flags:
50
- --pr <ref> PR number, owner/repo#N, or full GitHub URL
51
- --start <cmd> command to start a dev server (for browser PR demos)
52
- -c, --cmd <command> CLI command to demo
53
- -u, --url <url> URL to demo (browser mode)
54
- -t, --title <text> video title
55
- -o, --output <file> output file (default: agentreel.mp4)
56
- -a, --auth <file> Playwright storage state (cookies/auth) for browser demos
57
- -g, --guidelines <text> guidelines for highlight generation (e.g. "focus on speed")
58
- --music <file> path to background music mp3
59
- --no-share skip the share prompt
60
- -h, --help show help
61
- -v, --version show version`);
74
+ --pr <ref> PR number, owner/repo#N, or GitHub URL
75
+ --start <cmd> start a dev server for browser PR demos
76
+ -c, --cmd <cmd> CLI command to demo
77
+ -u, --url <url> URL to demo (browser mode)
78
+ -t, --title <text> video title
79
+ -s, --subtitle <text> video subtitle
80
+ -o, --output <file> output file (default: agentreel.mp4)
81
+ -a, --auth <file> Playwright auth state for browser demos
82
+ -g, --guidelines <t> highlight generation guidelines
83
+ --music <file> background music mp3
84
+ --no-share skip the share prompt`);
62
85
  }
63
86
 
64
- // ── Recording + Highlights ──────────────────────────────────
87
+ // ── PR Context ─────────────────────────────────────────────
65
88
 
66
- function findPython() {
67
- const venvPython = join(ROOT, "scripts", ".venv", "bin", "python");
68
- if (existsSync(venvPython)) return venvPython;
69
- return "python3";
70
- }
89
+ function fetchPRContext(prRef) {
90
+ try { execFileSync("gh", ["--version"], { stdio: "ignore" }); }
91
+ catch {
92
+ console.error("Error: `gh` CLI required for --pr mode. Install from https://cli.github.com");
93
+ process.exit(1);
94
+ }
95
+ const pr = JSON.parse(execFileSync("gh", [
96
+ "pr", "view", String(prRef), "--json", "title,body,headRefName,url,number",
97
+ ], { encoding: "utf-8", timeout: 30000 }));
71
98
 
72
- function ensureBrowserDeps() {
73
- const venvDir = join(ROOT, "scripts", ".venv");
74
- const venvPython = join(venvDir, "bin", "python");
75
- const browsersDir = join(venvDir, "playwright-browsers");
99
+ let diff = "";
100
+ try { diff = execFileSync("gh", ["pr", "diff", String(prRef)], { encoding: "utf-8", timeout: 30000 }); }
101
+ catch {}
76
102
 
77
- if (existsSync(venvPython)) {
78
- try {
79
- execFileSync(venvPython, ["-c", "import playwright"], {
80
- stdio: "ignore",
81
- });
82
- return; // all good
83
- } catch {
84
- // playwright missing, install below
85
- }
86
- } else {
87
- console.error(" Setting up Python environment...");
88
- execFileSync("python3", ["-m", "venv", venvDir], {
89
- stdio: ["ignore", "inherit", "inherit"],
90
- });
103
+ let readme = "";
104
+ for (const name of ["README.md", "readme.md", "README"]) {
105
+ const p = join(process.cwd(), name);
106
+ if (existsSync(p)) { readme = readFileSync(p, "utf-8"); break; }
91
107
  }
108
+ return { ...pr, diff, readme };
109
+ }
92
110
 
93
- const pip = join(venvDir, "bin", "pip");
94
- console.error(" Installing playwright...");
95
- execFileSync(pip, ["install", "-q", "playwright"], {
96
- stdio: ["ignore", "inherit", "inherit"],
97
- });
111
+ function planDemoFromPR(prContext, guidelines) {
112
+ const extra = guidelines ? `\nAdditional guidelines: ${guidelines}` : "";
113
+ const result = claude(`You are planning a demo for a Pull Request.
98
114
 
99
- console.error(" Installing Chromium (one-time, ~150MB)...");
100
- execFileSync(venvPython, ["-m", "playwright", "install", "chromium"], {
101
- stdio: ["ignore", "inherit", "inherit"],
102
- env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersDir },
103
- cwd: tmpdir(),
104
- });
115
+ PR Title: ${prContext.title}
116
+ PR Description: ${prContext.body || "(none)"}
117
+
118
+ Diff (truncated):
119
+ ${prContext.diff.slice(0, 8000)}
120
+
121
+ README (truncated):
122
+ ${prContext.readme.slice(0, 3000)}${extra}
123
+
124
+ Return JSON: {"type":"cli"|"browser", "command":"..." or null, "url":"..." or null, "description":"one sentence", "title":"2-4 words", "guidelines":"what to demo"}
125
+ Show actual changes honestly. Return ONLY JSON.`);
126
+ return parseJSON(result, { type: "cli", command: prContext.title, description: prContext.title, title: prContext.title, guidelines: "" });
105
127
  }
106
128
 
107
- function recordCLI(command, workDir, context, guidelines) {
108
- const python = findPython();
109
- const script = join(ROOT, "scripts", "cli_demo.py");
110
- const outFile = join(tmpdir(), "agentreel-cli-demo.cast");
129
+ // ── CLI Demo ───────────────────────────────────────────────
111
130
 
112
- const args = [script, command, workDir, outFile];
113
- if (context) args.push(context);
114
- if (guidelines) args.push(guidelines);
131
+ function planDemoSteps(command, context, guidelines) {
132
+ const extra = guidelines ? `\nIMPORTANT guidelines:\n${guidelines}` : "";
133
+ const result = claude(`Plan a terminal demo for: ${command}
134
+ Context: ${context}${extra}
115
135
 
116
- console.error(`Agent planning CLI demo for: ${command}`);
117
- execFileSync(python, args, { stdio: ["ignore", "inherit", "inherit"], env: process.env });
118
- return outFile;
136
+ Return a JSON array of steps. Each: {"type":"command", "value":"shell command", "delay":1, "description":"what it does"}
137
+ 5-8 steps, realistic shell commands only. Return ONLY JSON.`);
138
+ return parseJSON(result, [{ type: "command", value: command, delay: 1, description: "Run" }]);
119
139
  }
120
140
 
121
- function extractHighlightsFromCast(castPath, context, guidelines) {
122
- const python = findPython();
123
- const script = join(ROOT, "scripts", "cli_demo.py");
124
- const outFile = castPath + "-highlights.json";
141
+ function executeSteps(steps, workDir) {
142
+ const outputs = [];
143
+ for (const step of steps) {
144
+ if (step.type !== "command") continue;
145
+ console.error(` $ ${step.value}`);
146
+ const result = spawnSync("sh", ["-c", step.value], {
147
+ cwd: workDir, encoding: "utf-8", timeout: 15000,
148
+ env: { ...process.env, PS1: "$ ", TERM: "dumb" },
149
+ });
150
+ outputs.push({
151
+ command: step.value,
152
+ description: step.description || "",
153
+ stdout: (result.stdout || "").slice(0, 2000),
154
+ stderr: (result.stderr || "").slice(0, 500),
155
+ });
156
+ }
157
+ return outputs;
158
+ }
159
+
160
+ function extractHighlights(outputs, context, guidelines, isDemo) {
161
+ const session = outputs.map(o =>
162
+ `$ ${o.command}\n${o.stdout}${o.stderr ? `\n(stderr: ${o.stderr})` : ""}`
163
+ ).join("\n\n");
164
+
165
+ const extra = guidelines ? `\nGuidelines: ${guidelines}` : "";
166
+ const outputBlock = session.trim()
167
+ ? `Terminal output:\n---\n${session.slice(0, 6000)}\n---`
168
+ : "(No terminal output captured — generate representative output from context.)";
125
169
 
126
- const args = [script, "--highlights", castPath, outFile];
127
- if (context) args.push(context);
128
- if (guidelines) args.push(guidelines);
170
+ let prompt;
171
+ if (isDemo) {
172
+ prompt = `Create chapter-based highlights for a demo video.
173
+ ${outputBlock}
129
174
 
130
- execFileSync(python, args, { stdio: ["ignore", "inherit", "inherit"], env: process.env });
131
- return outFile;
175
+ Context: ${context}${extra}
176
+
177
+ Return JSON array. Each: {"label":"Chapter Name", "lines":[{"text":"...", "isPrompt":true|false, "color":"#hex", "bold":true|false, "dim":true|false}]}
178
+ 4-6 chapters, 12-20 lines each. Show complete commands + output.
179
+ Colors: green="#50fa7b" yellow="#f1fa8c" purple="#bd93f9" red="#ff5555" dim="#6272a4" white="#f8f8f2"
180
+ Return ONLY JSON array.`;
181
+ } else {
182
+ prompt = `Create highlights for a CLI demo video.
183
+ ${outputBlock}
184
+
185
+ Context: ${context}${extra}
186
+
187
+ Return JSON array. Each: {"label":"Name", "lines":[{"text":"...", "isPrompt":true|false, "color":"#hex", "bold":true|false, "dim":true|false}]}
188
+ 3-4 highlights, 4-8 lines each.
189
+ Colors: green="#50fa7b" yellow="#f1fa8c" purple="#bd93f9" red="#ff5555" dim="#6272a4" white="#f8f8f2"
190
+ Return ONLY JSON array.`;
191
+ }
192
+
193
+ const result = parseJSON(claude(prompt), null);
194
+ if (result) return result;
195
+
196
+ console.error(" Retrying highlight extraction...");
197
+ const retry = parseJSON(claude(`Generate ${isDemo ? 4 : 3} terminal highlights as JSON.
198
+ Context: ${context}
199
+ Each: {"label":"Name", "lines":[{"text":"cmd", "isPrompt":true}, {"text":"output", "color":"#50fa7b"}]}
200
+ 8-15 lines per highlight. Return ONLY JSON array.`), null);
201
+ if (retry) return retry;
202
+
203
+ return [{ label: "Run", lines: [
204
+ { text: context || "demo", isPrompt: true },
205
+ { text: " Done.", color: "#50fa7b" },
206
+ ]}];
132
207
  }
133
208
 
134
- // ── Browser Recording ───────────────────────────────────────
209
+ // ── Browser Demo ───────────────────────────────────────────
135
210
 
136
- function browserEnv() {
137
- const browsersDir = join(ROOT, "scripts", ".venv", "playwright-browsers");
138
- return { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersDir };
211
+ async function ensurePlaywright() {
212
+ try {
213
+ await import("playwright");
214
+ } catch {
215
+ console.error("Installing playwright...");
216
+ execFileSync("npm", ["install", "--no-save", "playwright"], {
217
+ cwd: ROOT, stdio: ["ignore", "inherit", "inherit"],
218
+ });
219
+ }
139
220
  }
140
221
 
141
- function recordBrowser(url, task, authState, guidelines) {
142
- const python = findPython();
143
- const script = join(ROOT, "scripts", "browser_demo.py");
222
+ async function recordBrowser(url, task, authState, guidelines) {
223
+ const { chromium } = await import("playwright");
224
+ const fs = await import("node:fs");
225
+ const { mkdtemp } = await import("node:fs/promises");
226
+ const videoDir = await mkdtemp(join(tmpdir(), "agentreel-"));
144
227
  const outFile = join(tmpdir(), "agentreel-browser-demo.mp4");
145
228
 
146
- console.error(`Agent demoing browser app: ${url}`);
147
- const args = [script, url, outFile, task];
148
- if (authState) args.push("--auth", authState);
149
- if (guidelines) args.push("--guidelines", guidelines);
150
- execFileSync(python, args, {
151
- stdio: ["ignore", "inherit", "inherit"],
152
- env: browserEnv(),
153
- timeout: 300000,
154
- });
155
- return outFile;
156
- }
229
+ console.error(` Recording ${url}...`);
230
+
231
+ const extra = guidelines ? `\nGuidelines: ${guidelines}` : "";
232
+ const scriptCode = claude(`Generate a Playwright JS async function body that demos ${url}.
233
+ Task: ${task}${extra}
234
+ The code will run inside: async (page) => { YOUR_CODE_HERE }
235
+ Navigate, click buttons, fill forms, scroll. ~20 seconds total.
236
+ Add await page.waitForTimeout(1500) between actions.
237
+ Use timeout:5000, force:true on clicks. Wrap actions in try/catch.
238
+ Return ONLY the function body, no function declaration, no imports.`);
239
+
240
+ const recordingStartMs = Date.now();
241
+ const browser = await chromium.launch({ headless: true });
242
+ const ctxOpts = {
243
+ viewport: { width: 1280, height: 800 },
244
+ recordVideo: { dir: videoDir, size: { width: 1280, height: 800 } },
245
+ };
246
+ if (authState && existsSync(authState)) ctxOpts.storageState = authState;
247
+ const context = await browser.newContext(ctxOpts);
248
+
249
+ await context.addInitScript(`
250
+ if (!window.__clicks) {
251
+ window.__clicks = [];
252
+ document.addEventListener('click', e => {
253
+ window.__clicks.push({ x: e.clientX, y: e.clientY, timestamp: Date.now() - ${recordingStartMs} });
254
+ }, true);
255
+ }
256
+ `);
157
257
 
158
- function extractBrowserHighlights(videoPath, task) {
159
- const python = findPython();
160
- const script = join(ROOT, "scripts", "browser_demo.py");
161
- const outFile = videoPath + "-highlights.json";
258
+ const page = await context.newPage();
259
+ try { await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); }
260
+ catch { await page.goto(url, { timeout: 15000 }); }
261
+ await page.waitForTimeout(1000);
162
262
 
163
- execFileSync(python, [script, "--highlights", videoPath, outFile, task], {
164
- stdio: ["ignore", "inherit", "inherit"],
165
- env: browserEnv(),
166
- });
167
- return outFile;
168
- }
263
+ // Run the generated demo script in a sandboxed VM context
264
+ try {
265
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
266
+ const demoFn = new AsyncFunction("page", scriptCode);
267
+ await demoFn(page);
268
+ } catch (e) {
269
+ console.error(` Demo script error: ${e.message}`);
270
+ await page.waitForTimeout(3000);
271
+ }
272
+
273
+ let clicks = [];
274
+ try {
275
+ const raw = await page.evaluate("window.__clicks || []");
276
+ clicks = raw.map(c => ({ x: c.x, y: c.y, timeSec: Math.round(c.timestamp / 100) / 10 }));
277
+ } catch {}
278
+
279
+ await page.close();
280
+ await context.close();
281
+ await browser.close();
282
+
283
+ const files = fs.readdirSync(videoDir);
284
+ const webm = files.find(f => f.endsWith(".webm"));
285
+ if (webm) {
286
+ try {
287
+ spawnSync("ffmpeg", ["-y", "-i", join(videoDir, webm), "-c:v", "libx264", "-preset", "fast", "-crf", "23", outFile], { timeout: 60000 });
288
+ } catch {
289
+ fs.copyFileSync(join(videoDir, webm), outFile.replace(".mp4", ".webm"));
290
+ }
291
+ }
169
292
 
170
- // ── Browser Highlight Builder ───────────────────────────────
293
+ return { videoPath: outFile, clicks };
294
+ }
171
295
 
172
- function buildBrowserHighlights(clicks, videoPath, task, guidelines) {
173
- const CLIP_DUR = 7;
174
- const MIN_HIGHLIGHTS = 3;
175
- const MAX_HIGHLIGHTS = 4;
176
- // Ask Claude to generate labels/overlays based on the task
296
+ function buildBrowserHighlights(clicks, task, guidelines) {
297
+ const CLIP_DUR = 7, MIN = 3, MAX = 4;
177
298
  let labels, overlays;
178
299
  try {
179
- const guidelinesLine = guidelines ? `\nGuidelines: ${guidelines}` : "";
180
- const genPrompt = `Generate exactly 4 highlight labels and overlay captions for a short browser demo video.
181
- Task: ${task}${guidelinesLine}
182
-
183
- Return a JSON object: {"labels": ["word1", "word2", "word3", "word4"], "overlays": ["**caption1**", "**caption2**", "**caption3**", "**caption4**"]}
184
- Labels: 1-2 words each, specific to this app (not generic). Overlays: short punchy captions with **markdown bold** for emphasis. Return ONLY JSON.`;
185
- const result = execFileSync("claude", ["-p", genPrompt, "--output-format", "text"], {
186
- encoding: "utf-8", timeout: 30000, stdio: ["ignore", "pipe", "ignore"],
187
- }).trim();
188
- const parsed = JSON.parse(result.replace(/```json?\n?/g, "").replace(/```/g, "").trim());
300
+ const result = claude(`Generate 4 highlight labels and overlays for a browser demo.
301
+ Task: ${task}${guidelines ? `\nGuidelines: ${guidelines}` : ""}
302
+ Return JSON: {"labels":["w1","w2","w3","w4"], "overlays":["**c1**","**c2**","**c3**","**c4**"]}
303
+ Labels: 1-2 words. Overlays: short with **bold**. Return ONLY JSON.`, 30000);
304
+ const parsed = parseJSON(result, {});
189
305
  labels = parsed.labels?.length >= 4 ? parsed.labels : null;
190
306
  overlays = parsed.overlays?.length >= 4 ? parsed.overlays : null;
191
- } catch { /* fall through */ }
307
+ } catch {}
192
308
  if (!labels) labels = ["Overview", "Interact", "Navigate", "Result"];
193
309
  if (!overlays) overlays = ["**First look**", "**Key action**", "**Exploring**", "**The result**"];
194
310
 
195
- // Estimate video duration from last click or default to 25s
196
- const lastClickTime = clicks.length > 0 ? clicks[clicks.length - 1].timeSec : 0;
197
- const videoDur = Math.max(25, lastClickTime + 5);
311
+ const lastClick = clicks.length > 0 ? clicks[clicks.length - 1].timeSec : 0;
312
+ const videoDur = Math.max(25, lastClick + 5);
198
313
 
199
- // Build click-based highlights
200
- const clickHighlights = [];
314
+ const highlights = [];
201
315
  if (clicks.length >= 1) {
202
- // Group clicks that are within 3s of each other
203
- const clusters = [];
204
- let cluster = [clicks[0]];
205
-
316
+ const clusters = []; let cluster = [clicks[0]];
206
317
  for (let i = 1; i < clicks.length; i++) {
207
- if (clicks[i].timeSec - cluster[cluster.length - 1].timeSec < 3) {
208
- cluster.push(clicks[i]);
209
- } else {
210
- clusters.push(cluster);
211
- cluster = [clicks[i]];
212
- }
318
+ if (clicks[i].timeSec - cluster[cluster.length - 1].timeSec < 3) cluster.push(clicks[i]);
319
+ else { clusters.push(cluster); cluster = [clicks[i]]; }
213
320
  }
214
321
  clusters.push(cluster);
215
-
216
- // Take top clusters by density, sorted by time
217
- const ranked = clusters
218
- .map((c) => ({ cluster: c }))
219
- .sort((a, b) => b.cluster.length - a.cluster.length)
220
- .slice(0, MAX_HIGHLIGHTS)
221
- .sort((a, b) => a.cluster[0].timeSec - b.cluster[0].timeSec);
222
-
223
- for (const r of ranked) {
224
- const first = r.cluster[0];
225
- const last = r.cluster[r.cluster.length - 1];
226
- const center = (first.timeSec + last.timeSec) / 2;
227
- const startSec = Math.max(0, center - CLIP_DUR / 2);
228
- const endSec = startSec + CLIP_DUR;
229
-
230
- const hlClicks = r.cluster.map(c => ({
231
- x: Math.max(0, Math.min(1280, c.x)),
232
- y: Math.max(0, Math.min(800, c.y)),
233
- timeSec: c.timeSec - startSec,
234
- }));
235
-
236
- const focusX = hlClicks.reduce((s, c) => s + c.x, 0) / hlClicks.length / 1280;
237
- const focusY = hlClicks.reduce((s, c) => s + c.y, 0) / hlClicks.length / 800;
238
-
239
- clickHighlights.push({
322
+ const ranked = clusters.sort((a, b) => b.length - a.length).slice(0, MAX).sort((a, b) => a[0].timeSec - b[0].timeSec);
323
+ for (const c of ranked) {
324
+ const center = (c[0].timeSec + c[c.length - 1].timeSec) / 2;
325
+ const start = Math.max(0, center - CLIP_DUR / 2);
326
+ const hlClicks = c.map(k => ({ x: Math.min(1280, Math.max(0, k.x)), y: Math.min(800, Math.max(0, k.y)), timeSec: k.timeSec - start }));
327
+ highlights.push({
240
328
  videoSrc: "browser-demo.mp4",
241
- videoStartSec: Math.round(startSec * 10) / 10,
242
- videoEndSec: Math.round(endSec * 10) / 10,
243
- focusX,
244
- focusY,
329
+ videoStartSec: Math.round(start * 10) / 10,
330
+ videoEndSec: Math.round((start + CLIP_DUR) * 10) / 10,
331
+ focusX: hlClicks.reduce((s, c) => s + c.x, 0) / hlClicks.length / 1280,
332
+ focusY: hlClicks.reduce((s, c) => s + c.y, 0) / hlClicks.length / 800,
245
333
  clicks: hlClicks,
246
334
  });
247
335
  }
248
336
  }
249
337
 
250
- // Pad to MIN_HIGHLIGHTS with evenly-spaced filler clips
251
- const highlights = [...clickHighlights];
252
- if (highlights.length < MIN_HIGHLIGHTS) {
253
- // Find time gaps not covered by existing highlights
254
- const covered = highlights.map(h => [h.videoStartSec, h.videoEndSec]);
255
- const fillerCount = MIN_HIGHLIGHTS - highlights.length;
256
-
257
- // Divide full video into slots, pick uncovered ones
258
- const slotDur = videoDur / (fillerCount + covered.length + 1);
259
- for (let i = 0; i < fillerCount; i++) {
260
- const candidate = slotDur * (i + 1);
261
- // Skip if overlaps with existing highlight
262
- const overlaps = covered.some(([s, e]) => candidate >= s && candidate <= e);
263
- const startSec = overlaps
264
- ? Math.max(0, videoDur - CLIP_DUR * (fillerCount - i))
265
- : Math.max(0, candidate - CLIP_DUR / 2);
266
- highlights.push({
267
- videoSrc: "browser-demo.mp4",
268
- videoStartSec: Math.round(startSec * 10) / 10,
269
- videoEndSec: Math.round((startSec + CLIP_DUR) * 10) / 10,
270
- });
271
- }
338
+ while (highlights.length < MIN) {
339
+ const slot = videoDur * (highlights.length + 1) / (MIN + 1);
340
+ const start = Math.max(0, slot - CLIP_DUR / 2);
341
+ highlights.push({ videoSrc: "browser-demo.mp4", videoStartSec: Math.round(start * 10) / 10, videoEndSec: Math.round((start + CLIP_DUR) * 10) / 10 });
272
342
  }
273
343
 
274
- // Sort by start time and assign labels
275
344
  highlights.sort((a, b) => a.videoStartSec - b.videoStartSec);
276
- for (let i = 0; i < highlights.length; i++) {
277
- highlights[i].label = labels[i % labels.length];
278
- highlights[i].overlay = overlays[i % overlays.length];
279
- }
280
-
281
- console.error(` ${highlights.length} highlights (${clickHighlights.length} from clicks, ${highlights.length - clickHighlights.length} filler)`);
345
+ highlights.forEach((h, i) => { h.label = labels[i % labels.length]; h.overlay = overlays[i % overlays.length]; });
282
346
  return highlights;
283
347
  }
284
348
 
@@ -289,329 +353,120 @@ function escSvg(s) {
289
353
  }
290
354
 
291
355
  function renderSVG(props, output) {
292
- const TERM_BG = "#282a36";
293
- const TITLE_BAR = "#1e1f29";
294
- const ACCENT = "#50fa7b";
295
- const DIM = "#6272a4";
296
- const WHITE = "#f8f8f2";
297
- const FONT = '"SF Mono", "Fira Code", "Cascadia Code", monospace';
298
- const SANS = '-apple-system, "SF Pro Display", system-ui, sans-serif';
299
-
356
+ const FONT = '"SF Mono", "Fira Code", monospace';
357
+ const SANS = '-apple-system, system-ui, sans-serif';
300
358
  const W = props.mode === "demo" ? 1200 : 700;
301
- const PAD = 32;
302
- const LINE_H = 22;
303
- const TERM_PAD = 16;
304
- const TITLE_BAR_H = 36;
305
- const CHAPTER_GAP = 28;
306
- const LABEL_H = 24;
307
- const FONT_SIZE = 13;
308
-
309
- let y = PAD;
310
- let blocks = "";
311
-
312
- // Title
313
- blocks += `<text x="${W / 2}" y="${y + 28}" font-family="${escSvg(SANS)}" font-size="32" font-weight="800" fill="${WHITE}" text-anchor="middle">${escSvg(props.title)}</text>`;
314
- y += 40;
315
- if (props.subtitle) {
316
- blocks += `<text x="${W / 2}" y="${y + 18}" font-family="${escSvg(SANS)}" font-size="16" fill="${DIM}" text-anchor="middle">${escSvg(props.subtitle)}</text>`;
317
- y += 28;
318
- }
319
- y += 16;
320
-
321
- for (const hl of props.highlights) {
322
- if (!hl.lines || hl.lines.length === 0) continue;
359
+ const PAD = 32, LINE_H = 22, TERM_PAD = 16, BAR_H = 36, GAP = 28, FS = 13;
323
360
 
324
- // Chapter label
325
- blocks += `<text x="${PAD}" y="${y + 14}" font-family="${escSvg(FONT)}" font-size="11" fill="${ACCENT}" letter-spacing="2" text-transform="uppercase">${escSvg(hl.label.toUpperCase())}</text>`;
326
- y += LABEL_H;
361
+ let y = PAD, blocks = "";
362
+ blocks += `<text x="${W / 2}" y="${y + 28}" font-family="${escSvg(SANS)}" font-size="32" font-weight="800" fill="#f8f8f2" text-anchor="middle">${escSvg(props.title)}</text>`;
363
+ y += props.subtitle ? 68 : 56;
364
+ if (props.subtitle) blocks += `<text x="${W / 2}" y="${y - 22}" font-family="${escSvg(SANS)}" font-size="16" fill="#6272a4" text-anchor="middle">${escSvg(props.subtitle)}</text>`;
327
365
 
366
+ for (const hl of props.highlights) {
367
+ if (!hl.lines?.length) continue;
368
+ blocks += `<text x="${PAD}" y="${y + 14}" font-family="${escSvg(FONT)}" font-size="11" fill="#50fa7b" letter-spacing="2">${escSvg(hl.label.toUpperCase())}</text>`;
369
+ y += 24;
328
370
  const bodyH = TERM_PAD * 2 + hl.lines.length * LINE_H;
329
- const termH = TITLE_BAR_H + bodyH;
330
-
331
- // Terminal window
332
- blocks += `<rect x="${PAD}" y="${y}" width="${W - PAD * 2}" height="${termH}" rx="8" fill="${TITLE_BAR}"/>`;
333
- // Traffic lights
334
- blocks += `<circle cx="${PAD + 16}" cy="${y + TITLE_BAR_H / 2}" r="5" fill="#ff5555"/>`;
335
- blocks += `<circle cx="${PAD + 34}" cy="${y + TITLE_BAR_H / 2}" r="5" fill="#f1fa8c"/>`;
336
- blocks += `<circle cx="${PAD + 52}" cy="${y + TITLE_BAR_H / 2}" r="5" fill="#50fa7b"/>`;
337
- // Body
338
- blocks += `<rect x="${PAD}" y="${y + TITLE_BAR_H}" width="${W - PAD * 2}" height="${bodyH}" fill="${TERM_BG}"/>`;
339
-
340
- let lineY = y + TITLE_BAR_H + TERM_PAD;
371
+ blocks += `<rect x="${PAD}" y="${y}" width="${W - PAD * 2}" height="${BAR_H + bodyH}" rx="8" fill="#1e1f29"/>`;
372
+ blocks += `<circle cx="${PAD + 16}" cy="${y + BAR_H / 2}" r="5" fill="#ff5555"/><circle cx="${PAD + 34}" cy="${y + BAR_H / 2}" r="5" fill="#f1fa8c"/><circle cx="${PAD + 52}" cy="${y + BAR_H / 2}" r="5" fill="#50fa7b"/>`;
373
+ blocks += `<rect x="${PAD}" y="${y + BAR_H}" width="${W - PAD * 2}" height="${bodyH}" fill="#282a36"/>`;
374
+ let ly = y + BAR_H + TERM_PAD;
341
375
  for (const line of hl.lines) {
342
- const color = line.dim ? DIM : line.color || WHITE;
343
- const weight = line.bold ? "700" : "400";
344
- const prefix = line.isPrompt ? `<tspan fill="${ACCENT}">$ </tspan>` : "";
376
+ const color = line.dim ? "#6272a4" : line.color || "#f8f8f2";
377
+ const prefix = line.isPrompt ? `<tspan fill="#50fa7b">$ </tspan>` : "";
345
378
  const text = line.isPrompt ? line.text.replace(/^\$\s*/, "") : line.text;
346
- blocks += `<text x="${PAD + TERM_PAD}" y="${lineY + FONT_SIZE}" font-family="${escSvg(FONT)}" font-size="${FONT_SIZE}" font-weight="${weight}" fill="${color}">${prefix}${escSvg(text)}</text>`;
347
- lineY += LINE_H;
379
+ blocks += `<text x="${PAD + TERM_PAD}" y="${ly + FS}" font-family="${escSvg(FONT)}" font-size="${FS}" font-weight="${line.bold ? 700 : 400}" fill="${color}">${prefix}${escSvg(text)}</text>`;
380
+ ly += LINE_H;
348
381
  }
349
-
350
- y += termH + CHAPTER_GAP;
351
- }
352
-
353
- // End text
354
- if (props.endUrl) {
355
- blocks += `<text x="${W / 2}" y="${y + 16}" font-family="${escSvg(SANS)}" font-size="14" fill="${DIM}" text-anchor="middle">${escSvg(props.endUrl)}</text>`;
356
- y += 28;
382
+ y += BAR_H + bodyH + GAP;
357
383
  }
384
+ if (props.endUrl) { blocks += `<text x="${W / 2}" y="${y + 16}" font-family="${escSvg(SANS)}" font-size="14" fill="#6272a4" text-anchor="middle">${escSvg(props.endUrl)}</text>`; y += 28; }
358
385
  y += PAD;
359
386
 
360
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${y}" viewBox="0 0 ${W} ${y}">
361
- <rect width="${W}" height="${y}" fill="#0f0f1a"/>
362
- ${blocks}
363
- </svg>`;
364
-
365
- writeFileSync(output, svg);
366
- console.error(`\nDone: ${output} (SVG fallback)`);
387
+ writeFileSync(output, `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${y}" viewBox="0 0 ${W} ${y}"><rect width="${W}" height="${y}" fill="#0f0f1a"/>${blocks}</svg>`);
388
+ console.error(`\nDone: ${output} (SVG)`);
367
389
  }
368
390
 
369
- async function renderWithFallback(props, output, musicPath) {
370
- try {
371
- await renderVideo(props, output, musicPath);
372
- } catch (e) {
373
- console.error(` Video rendering failed: ${e.message}`);
374
- const svgOutput = output.replace(/\.[^.]+$/, ".svg");
375
- console.error(` Falling back to SVG: ${svgOutput}`);
376
- renderSVG(props, svgOutput);
377
- }
378
- }
379
-
380
- // ── Render ──────────────────────────────────────────────────
391
+ // ── Video Render ───────────────────────────────────────────
381
392
 
382
393
  async function renderVideo(props, output, musicPath) {
383
394
  const publicDir = join(ROOT, "public");
384
395
  if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
385
- if (musicPath && existsSync(musicPath)) {
386
- copyFileSync(musicPath, join(publicDir, "music.mp3"));
387
- }
388
-
389
- const absOutput = resolve(output);
390
- const propsJSON = JSON.stringify(props);
396
+ if (musicPath && existsSync(musicPath)) copyFileSync(musicPath, join(publicDir, "music.mp3"));
391
397
 
392
- // Render using Remotion's Node.js API — no CLI binary needed
393
398
  const { bundle } = await import("@remotion/bundler");
394
399
  const { renderMedia, selectComposition } = await import("@remotion/renderer");
395
400
 
396
- const entryPoint = join(ROOT, "src", "index.ts");
397
-
398
401
  console.error(" Bundling...");
399
- const serveUrl = await bundle({
400
- entryPoint,
401
- webpackOverride: (config) => config,
402
- });
402
+ const serveUrl = await bundle({ entryPoint: join(ROOT, "src", "index.ts"), webpackOverride: c => c });
403
403
 
404
404
  console.error(" Preparing renderer...");
405
405
  const composition = await selectComposition({
406
- serveUrl,
407
- id: "CastVideo",
408
- inputProps: props,
409
- onBrowserDownload: () => {
410
- console.error(" Downloading renderer (one-time, ~90MB)...");
411
- return { onProgress: () => {} };
412
- },
406
+ serveUrl, id: "CastVideo", inputProps: props,
407
+ onBrowserDownload: () => { console.error(" Downloading renderer (~90MB)..."); return { onProgress: () => {} }; },
413
408
  });
414
409
 
415
410
  console.error(" Rendering...");
416
- await renderMedia({
417
- composition,
418
- serveUrl,
419
- codec: "h264",
420
- outputLocation: absOutput,
421
- inputProps: props,
422
- });
423
-
424
- const size = statSync(absOutput).size;
425
- console.error(`\nDone: ${output} (${Math.round(size / 1024)} KB)`);
411
+ await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: resolve(output), inputProps: props });
412
+ console.error(`\nDone: ${output} (${Math.round(statSync(resolve(output)).size / 1024)} KB)`);
426
413
  }
427
414
 
428
- // ── Share ───────────────────────────────────────────────────
429
-
430
- function askYesNo(question) {
431
- return new Promise((resolve) => {
432
- const rl = createInterface({ input: process.stdin, output: process.stderr });
433
- rl.question(question, (answer) => {
434
- rl.close();
435
- resolve(answer.trim().toLowerCase() !== "n");
436
- });
437
- });
415
+ async function render(props, output, musicPath) {
416
+ try { await renderVideo(props, output, musicPath); }
417
+ catch (e) {
418
+ console.error(` Video rendering failed: ${e.message}`);
419
+ const svg = output.replace(/\.[^.]+$/, ".svg");
420
+ console.error(` Falling back to SVG: ${svg}`);
421
+ renderSVG(props, svg);
422
+ }
438
423
  }
439
424
 
440
- async function shareFlow(outputPath, title, prompt) {
441
- const shouldShare = await askYesNo("Share to Twitter? [Y/n] ");
442
- if (!shouldShare) return;
425
+ // ── Share ──────────────────────────────────────────────────
426
+
427
+ async function shareFlow(outputPath, title, desc) {
428
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
429
+ const answer = await new Promise(r => rl.question("Share to Twitter? [Y/n] ", a => { rl.close(); r(a); }));
430
+ if (answer.trim().toLowerCase() === "n") return;
443
431
 
444
432
  const name = title || "this";
445
- const desc = prompt || "";
446
433
  const text = desc
447
434
  ? `Introducing ${name} — ${desc}\n\nMade with https://github.com/islo-labs/agentreel`
448
435
  : `Introducing ${name}\n\nMade with https://github.com/islo-labs/agentreel`;
449
- const tweetText = encodeURIComponent(text);
450
- const intentURL = `https://twitter.com/intent/tweet?text=${tweetText}`;
451
-
452
- console.error(`\n Opening Twitter — attach your video to the tweet.`);
453
- console.error(` Video: ${resolve(outputPath)}\n`);
454
-
455
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
456
- try {
457
- execFileSync(openCmd, [intentURL], { stdio: "ignore" });
458
- } catch {
459
- console.error(` Link: ${intentURL}`);
460
- }
461
- }
462
-
463
- // ── PR Context ─────────────────────────────────────────────
464
-
465
- function fetchPRContext(prRef) {
466
- try {
467
- execFileSync("gh", ["--version"], { stdio: "ignore" });
468
- } catch {
469
- console.error("Error: `gh` CLI is required for --pr mode. Install it from https://cli.github.com");
470
- process.exit(1);
471
- }
472
-
473
- const prJson = execFileSync("gh", [
474
- "pr", "view", String(prRef),
475
- "--json", "title,body,headRefName,baseRefName,url,number",
476
- ], { encoding: "utf-8", timeout: 30000 });
477
- const pr = JSON.parse(prJson);
478
-
479
- let diff = "";
480
- try {
481
- diff = execFileSync("gh", ["pr", "diff", String(prRef)], {
482
- encoding: "utf-8", timeout: 30000,
483
- });
484
- } catch (e) {
485
- console.error(` Warning: could not fetch PR diff: ${e.message}`);
486
- }
487
-
488
- // Read README from cwd (the agent already has the repo checked out)
489
- let readme = "";
490
- for (const name of ["README.md", "readme.md", "README", "README.rst"]) {
491
- const p = join(process.cwd(), name);
492
- if (existsSync(p)) {
493
- readme = readFileSync(p, "utf-8");
494
- break;
495
- }
496
- }
497
-
498
- return { ...pr, diff, readme };
499
- }
500
-
501
- function planDemoFromPR(prContext, guidelines) {
502
- const guidelinesBlock = guidelines
503
- ? `\nAdditional guidelines: ${guidelines}`
504
- : "";
505
-
506
- const prompt = `You are planning a demo for a Pull Request. Your job is to decide whether this is a CLI or browser demo, and provide the details needed to record it.
507
-
508
- PR Title: ${prContext.title}
509
- PR Description: ${prContext.body || "(no description)"}
510
-
511
- Diff (truncated):
512
- ${prContext.diff.slice(0, 8000)}
513
-
514
- README (truncated):
515
- ${prContext.readme.slice(0, 3000)}${guidelinesBlock}
516
-
517
- Return a JSON object with these fields:
518
- {
519
- "type": "cli" or "browser",
520
- "command": "the command to run" (for CLI demos, e.g. "npx my-tool --help") or null,
521
- "url": "http://localhost:3000/relevant-page" (for browser demos) or null,
522
- "description": "one-sentence summary of what the PR does",
523
- "title": "short video title (2-4 words)",
524
- "guidelines": "specific instructions for the demo recorder about what steps to show and what to focus on"
525
- }
526
-
527
- Rules:
528
- - If the PR changes a CLI tool, script, or backend logic that can be demonstrated in a terminal, use "cli".
529
- - If the PR changes a web UI, frontend, or something best shown in a browser, use "browser".
530
- - The "guidelines" field should tell the demo recorder exactly what to demonstrate — the specific feature or fix from this PR.
531
- - The demo should show the actual changes working honestly, not market the product.
532
- - Return ONLY the JSON object, no markdown fences.`;
533
-
534
- const result = execFileSync("claude", ["-p", prompt, "--output-format", "text"], {
535
- encoding: "utf-8",
536
- timeout: 60000,
537
- stdio: ["ignore", "pipe", "ignore"],
538
- }).trim();
539
-
540
- // Strip markdown fences if present
541
- let text = result;
542
- if (text.includes("```")) {
543
- const parts = text.split("```");
544
- for (let part of parts) {
545
- part = part.trim();
546
- if (part.startsWith("json")) part = part.slice(4).trim();
547
- if (part.startsWith("{")) { text = part; break; }
548
- }
549
- }
550
-
551
- return JSON.parse(text);
436
+ const intentURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`;
437
+ console.error(`\n Opening Twitter attach your video.\n Video: ${resolve(outputPath)}\n`);
438
+ try { execFileSync(process.platform === "darwin" ? "open" : "xdg-open", [intentURL], { stdio: "ignore" }); }
439
+ catch { console.error(` Link: ${intentURL}`); }
552
440
  }
553
441
 
554
442
  // ── Dev Server ─────────────────────────────────────────────
555
443
 
556
444
  function startDevServer(command) {
557
- console.error(` Starting dev server: ${command}`);
558
- const proc = spawn("sh", ["-c", command], {
559
- stdio: ["ignore", "pipe", "pipe"],
560
- detached: true,
561
- });
562
-
563
- // Wait for server to be ready (look for common ready signals in output)
445
+ console.error(` Starting: ${command}`);
446
+ const proc = spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"], detached: true });
564
447
  return new Promise((resolve, reject) => {
565
- const timeout = setTimeout(() => {
566
- console.error(" Dev server ready (timeout assuming started)");
567
- resolve(proc);
568
- }, 30000);
569
-
570
- const onData = (data) => {
571
- const text = data.toString();
572
- if (/localhost|ready|started|listening|compiled/i.test(text)) {
448
+ const timeout = setTimeout(() => resolve(proc), 30000);
449
+ const onData = (d) => {
450
+ if (/localhost|ready|started|listening|compiled/i.test(d.toString())) {
573
451
  clearTimeout(timeout);
574
- // Give it a moment to fully start
575
- setTimeout(() => {
576
- console.error(" Dev server ready");
577
- resolve(proc);
578
- }, 2000);
452
+ setTimeout(() => resolve(proc), 2000);
579
453
  }
580
454
  };
581
-
582
455
  proc.stdout.on("data", onData);
583
456
  proc.stderr.on("data", onData);
584
-
585
- proc.on("error", (err) => {
586
- clearTimeout(timeout);
587
- reject(new Error(`Dev server failed to start: ${err.message}`));
588
- });
589
-
590
- proc.on("exit", (code) => {
591
- clearTimeout(timeout);
592
- if (code !== null && code !== 0) {
593
- reject(new Error(`Dev server exited with code ${code}`));
594
- }
595
- });
457
+ proc.on("error", e => { clearTimeout(timeout); reject(e); });
596
458
  });
597
459
  }
598
460
 
599
461
  function stopDevServer(proc) {
600
- if (!proc || proc.killed) return;
601
- try {
602
- // Kill the process group (detached process + children)
603
- process.kill(-proc.pid, "SIGTERM");
604
- } catch {
605
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
606
- }
462
+ if (!proc?.killed) try { process.kill(-proc.pid, "SIGTERM"); } catch { try { proc.kill(); } catch {} }
607
463
  }
608
464
 
609
- // ── Main ────────────────────────────────────────────────────
465
+ // ── Main ───────────────────────────────────────────────────
610
466
 
611
467
  async function main() {
612
468
  const flags = parseArgs();
613
469
  const output = flags.output || "agentreel.mp4";
614
- const noShare = flags.noShare;
615
470
 
616
471
  if (!flags.cmd && !flags.url && !flags.pr) {
617
472
  console.error("Please provide --pr, --cmd, or --url.\n");
@@ -619,148 +474,82 @@ async function main() {
619
474
  process.exit(1);
620
475
  }
621
476
 
622
- // ── PR mode ──────────────────────────────────────────────
477
+ // ── PR mode ──────────────────────────────────────────
623
478
  if (flags.pr) {
624
479
  console.error("Fetching PR context...");
625
- const prContext = fetchPRContext(flags.pr);
626
- console.error(` PR #${prContext.number}: ${prContext.title}`);
480
+ const pr = fetchPRContext(flags.pr);
481
+ console.error(` PR #${pr.number}: ${pr.title}`);
627
482
 
628
483
  console.error("Planning demo...");
629
- const plan = planDemoFromPR(prContext, flags.guidelines);
484
+ const plan = planDemoFromPR(pr, flags.guidelines);
630
485
  console.error(` Type: ${plan.type}, "${plan.description}"`);
631
486
 
632
- const videoTitle = flags.title || plan.title || prContext.title;
633
- const description = plan.description;
634
- // Prepend "demo" to guidelines so downstream scripts know to use chapter-based extraction
487
+ const title = flags.title || plan.title || pr.title;
488
+ const subtitle = flags.subtitle || plan.description;
635
489
  const demoGuidelines = `[demo] ${plan.guidelines || ""}`.trim();
636
490
 
637
491
  if (plan.type === "browser") {
638
- const url = plan.url || "http://localhost:3000";
639
492
  let serverProc = null;
640
-
641
493
  try {
642
- if (flags.start) {
643
- serverProc = await startDevServer(flags.start);
644
- }
645
-
646
- ensureBrowserDeps();
494
+ if (flags.start) serverProc = await startDevServer(flags.start);
495
+ await ensurePlaywright();
647
496
  console.error("Step 1/3: Recording browser demo...");
648
- const videoPath = recordBrowser(url, demoGuidelines, flags.auth, demoGuidelines);
649
-
497
+ const { videoPath, clicks } = await recordBrowser(plan.url || "http://localhost:3000", demoGuidelines, flags.auth, demoGuidelines);
650
498
  const publicDir = join(ROOT, "public");
651
499
  if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
652
500
  copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
653
-
654
501
  console.error("Step 2/3: Building highlights...");
655
- const clicksPath = videoPath.replace(".mp4", "-clicks.json");
656
- let allClicks = [];
657
- if (existsSync(clicksPath)) {
658
- allClicks = JSON.parse(readFileSync(clicksPath, "utf-8"));
659
- console.error(` ${allClicks.length} clicks captured`);
660
- }
661
- const highlights = buildBrowserHighlights(allClicks, videoPath, demoGuidelines, demoGuidelines);
662
-
663
- console.error("Step 3/3: Rendering video...");
664
- await renderWithFallback({
665
- title: videoTitle,
666
- subtitle: description,
667
- highlights,
668
- endText: prContext.title,
669
- endUrl: prContext.url,
670
- mode: "demo",
671
- }, output, flags.music);
672
- } finally {
673
- stopDevServer(serverProc);
674
- }
502
+ const highlights = buildBrowserHighlights(clicks, demoGuidelines, demoGuidelines);
503
+ console.error("Step 3/3: Rendering...");
504
+ await render({ title, subtitle, highlights, endText: pr.title, endUrl: pr.url, mode: "demo" }, output, flags.music);
505
+ } finally { stopDevServer(serverProc); }
675
506
  } else {
676
- // CLI demo
677
- if (!plan.command) {
678
- console.error("Error: Claude could not determine a command to demo for this PR.");
679
- process.exit(1);
680
- }
681
-
507
+ if (!plan.command) { console.error("Error: could not determine command to demo."); process.exit(1); }
682
508
  console.error("Step 1/3: Recording CLI demo...");
683
- const castPath = recordCLI(plan.command, process.cwd(), description, demoGuidelines);
684
-
509
+ const steps = planDemoSteps(plan.command, plan.description, demoGuidelines);
510
+ console.error(` ${steps.length} steps planned`);
511
+ const outputs = executeSteps(steps, process.cwd());
685
512
  console.error("Step 2/3: Extracting highlights...");
686
- const highlightsPath = extractHighlightsFromCast(castPath, description, demoGuidelines);
687
- const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
688
- console.error(` ${highlights.length} highlights extracted`);
689
-
690
- console.error("Step 3/3: Rendering video...");
691
- await renderWithFallback({
692
- title: videoTitle,
693
- subtitle: description,
694
- highlights,
695
- endText: plan.command,
696
- endUrl: prContext.url,
697
- mode: "demo",
698
- }, output, flags.music);
513
+ const highlights = extractHighlights(outputs, plan.description, demoGuidelines, true);
514
+ console.error(` ${highlights.length} highlights`);
515
+ console.error("Step 3/3: Rendering...");
516
+ await render({ title, subtitle, highlights, endText: plan.command, endUrl: pr.url, mode: "demo" }, output, flags.music);
699
517
  }
700
518
 
701
- if (!noShare) {
702
- await shareFlow(resolve(output), videoTitle, description);
703
- }
519
+ if (!flags.noShare) await shareFlow(resolve(output), title, plan.description);
704
520
  return;
705
521
  }
706
522
 
707
- // ── Manual modes (--cmd / --url) ─────────────────────────
708
- let videoTitle = flags.title || flags.cmd || flags.url;
709
-
523
+ // ── CLI mode ─────────────────────────────────────────
710
524
  if (flags.cmd) {
525
+ const title = flags.title || flags.cmd;
711
526
  console.error("Step 1/3: Recording CLI demo...");
712
- const castPath = recordCLI(flags.cmd, process.cwd(), flags.cmd, flags.guidelines);
713
-
527
+ const steps = planDemoSteps(flags.cmd, flags.cmd, flags.guidelines);
528
+ console.error(` ${steps.length} steps planned`);
529
+ const outputs = executeSteps(steps, process.cwd());
714
530
  console.error("Step 2/3: Extracting highlights...");
715
- const highlightsPath = extractHighlightsFromCast(castPath, flags.cmd, flags.guidelines);
716
- const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
717
- console.error(` ${highlights.length} highlights extracted`);
718
-
719
- console.error("Step 3/3: Rendering video...");
720
- await renderWithFallback({
721
- title: videoTitle,
722
- highlights,
723
- endText: flags.cmd,
724
- }, output, flags.music);
725
-
726
- if (!noShare) {
727
- await shareFlow(resolve(output), videoTitle, flags.cmd);
728
- }
531
+ const highlights = extractHighlights(outputs, flags.cmd, flags.guidelines, false);
532
+ console.error(` ${highlights.length} highlights`);
533
+ console.error("Step 3/3: Rendering...");
534
+ await render({ title, highlights, endText: flags.cmd }, output, flags.music);
535
+ if (!flags.noShare) await shareFlow(resolve(output), title, flags.cmd);
729
536
  return;
730
537
  }
731
538
 
539
+ // ── Browser mode ─────────────────────────────────────
732
540
  if (flags.url) {
733
- const task = "Explore the main features of this app";
734
-
735
- ensureBrowserDeps();
541
+ const title = flags.title || flags.url;
542
+ await ensurePlaywright();
736
543
  console.error("Step 1/3: Recording browser demo...");
737
- const videoPath = recordBrowser(flags.url, task, flags.auth, flags.guidelines);
738
-
544
+ const { videoPath, clicks } = await recordBrowser(flags.url, "Explore the main features", flags.auth, flags.guidelines);
739
545
  const publicDir = join(ROOT, "public");
740
546
  if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
741
547
  copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
742
-
743
548
  console.error("Step 2/3: Building highlights...");
744
- const clicksPath = videoPath.replace(".mp4", "-clicks.json");
745
- let allClicks = [];
746
- if (existsSync(clicksPath)) {
747
- allClicks = JSON.parse(readFileSync(clicksPath, "utf-8"));
748
- console.error(` ${allClicks.length} clicks captured`);
749
- }
750
- const highlights = buildBrowserHighlights(allClicks, videoPath, task, flags.guidelines);
751
-
752
- console.error("Step 3/3: Rendering video...");
753
- await renderWithFallback({
754
- title: videoTitle,
755
- highlights,
756
- endText: flags.url,
757
- endUrl: flags.url,
758
- }, output, flags.music);
759
-
760
- if (!noShare) {
761
- await shareFlow(resolve(output), videoTitle, flags.url);
762
- }
763
- return;
549
+ const highlights = buildBrowserHighlights(clicks, "Explore the main features", flags.guidelines);
550
+ console.error("Step 3/3: Rendering...");
551
+ await render({ title, highlights, endText: flags.url, endUrl: flags.url }, output, flags.music);
552
+ if (!flags.noShare) await shareFlow(resolve(output), title, flags.url);
764
553
  }
765
554
  }
766
555