agentreel 0.4.3 → 0.5.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,346 @@
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 === "--output" || a === "-o") flags.output = args[++i];
56
+ else if (a === "--music") flags.music = args[++i];
57
+ else if (a === "--auth" || a === "-a") flags.auth = args[++i];
58
+ else if (a === "--guidelines" || a === "-g") flags.guidelines = args[++i];
59
+ else if (a === "--no-share") flags.noShare = true;
36
60
  }
37
61
  return flags;
38
62
  }
39
63
 
40
64
  function printUsage() {
41
- console.log(`agentreel — Turn your web apps and CLIs into viral clips
65
+ console.log(`agentreel — Turn your apps into demo videos
42
66
 
43
67
  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
68
+ agentreel --pr 123 # demo a PR
69
+ agentreel --cmd "npx my-tool" # CLI demo
70
+ agentreel --url http://localhost:3000 # browser demo
48
71
 
49
72
  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`);
73
+ --pr <ref> PR number, owner/repo#N, or GitHub URL
74
+ --start <cmd> start a dev server for browser PR demos
75
+ -c, --cmd <cmd> CLI command to demo
76
+ -u, --url <url> URL to demo (browser mode)
77
+ -t, --title <text> video title
78
+ -o, --output <file> output file (default: agentreel.mp4)
79
+ -a, --auth <file> Playwright auth state for browser demos
80
+ -g, --guidelines <t> highlight generation guidelines
81
+ --music <file> background music mp3
82
+ --no-share skip the share prompt`);
62
83
  }
63
84
 
64
- // ── Recording + Highlights ──────────────────────────────────
85
+ // ── PR Context ─────────────────────────────────────────────
65
86
 
66
- function findPython() {
67
- const venvPython = join(ROOT, "scripts", ".venv", "bin", "python");
68
- if (existsSync(venvPython)) return venvPython;
69
- return "python3";
70
- }
87
+ function fetchPRContext(prRef) {
88
+ try { execFileSync("gh", ["--version"], { stdio: "ignore" }); }
89
+ catch {
90
+ console.error("Error: `gh` CLI required for --pr mode. Install from https://cli.github.com");
91
+ process.exit(1);
92
+ }
93
+ const pr = JSON.parse(execFileSync("gh", [
94
+ "pr", "view", String(prRef), "--json", "title,body,headRefName,url,number",
95
+ ], { encoding: "utf-8", timeout: 30000 }));
71
96
 
72
- function ensureBrowserDeps() {
73
- const venvDir = join(ROOT, "scripts", ".venv");
74
- const venvPython = join(venvDir, "bin", "python");
75
- const browsersDir = join(venvDir, "playwright-browsers");
97
+ let diff = "";
98
+ try { diff = execFileSync("gh", ["pr", "diff", String(prRef)], { encoding: "utf-8", timeout: 30000 }); }
99
+ catch {}
76
100
 
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
- });
101
+ let readme = "";
102
+ for (const name of ["README.md", "readme.md", "README"]) {
103
+ const p = join(process.cwd(), name);
104
+ if (existsSync(p)) { readme = readFileSync(p, "utf-8"); break; }
91
105
  }
106
+ return { ...pr, diff, readme };
107
+ }
92
108
 
93
- const pip = join(venvDir, "bin", "pip");
94
- console.error(" Installing playwright...");
95
- execFileSync(pip, ["install", "-q", "playwright"], {
96
- stdio: ["ignore", "inherit", "inherit"],
97
- });
109
+ function planDemoFromPR(prContext, guidelines) {
110
+ const extra = guidelines ? `\nAdditional guidelines: ${guidelines}` : "";
111
+ const result = claude(`You are planning a demo for a Pull Request.
98
112
 
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
- });
113
+ PR Title: ${prContext.title}
114
+ PR Description: ${prContext.body || "(none)"}
115
+
116
+ Diff (truncated):
117
+ ${prContext.diff.slice(0, 8000)}
118
+
119
+ README (truncated):
120
+ ${prContext.readme.slice(0, 3000)}${extra}
121
+
122
+ Return JSON: {"type":"cli"|"browser", "command":"..." or null, "url":"..." or null, "description":"one sentence", "title":"2-4 words", "guidelines":"what to demo"}
123
+ Show actual changes honestly. Return ONLY JSON.`);
124
+ return parseJSON(result, { type: "cli", command: prContext.title, description: prContext.title, title: prContext.title, guidelines: "" });
105
125
  }
106
126
 
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");
127
+ // ── CLI Demo ───────────────────────────────────────────────
111
128
 
112
- const args = [script, command, workDir, outFile];
113
- if (context) args.push(context);
114
- if (guidelines) args.push(guidelines);
129
+ function planDemoSteps(command, context, guidelines) {
130
+ const extra = guidelines ? `\nIMPORTANT guidelines:\n${guidelines}` : "";
131
+ const result = claude(`Plan a terminal demo for: ${command}
132
+ Context: ${context}${extra}
115
133
 
116
- console.error(`Agent planning CLI demo for: ${command}`);
117
- execFileSync(python, args, { stdio: ["ignore", "inherit", "inherit"], env: process.env });
118
- return outFile;
134
+ Return a JSON array of steps. Each: {"type":"command", "value":"shell command", "delay":1, "description":"what it does"}
135
+ 5-8 steps, realistic shell commands only. Return ONLY JSON.`);
136
+ return parseJSON(result, [{ type: "command", value: command, delay: 1, description: "Run" }]);
119
137
  }
120
138
 
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";
139
+ function executeSteps(steps, workDir) {
140
+ const outputs = [];
141
+ for (const step of steps) {
142
+ if (step.type !== "command") continue;
143
+ console.error(` $ ${step.value}`);
144
+ const result = spawnSync("sh", ["-c", step.value], {
145
+ cwd: workDir, encoding: "utf-8", timeout: 15000,
146
+ env: { ...process.env, PS1: "$ ", TERM: "dumb" },
147
+ });
148
+ outputs.push({
149
+ command: step.value,
150
+ description: step.description || "",
151
+ stdout: (result.stdout || "").slice(0, 2000),
152
+ stderr: (result.stderr || "").slice(0, 500),
153
+ });
154
+ }
155
+ return outputs;
156
+ }
157
+
158
+ function extractHighlights(outputs, context, guidelines, isDemo) {
159
+ const session = outputs.map(o =>
160
+ `$ ${o.command}\n${o.stdout}${o.stderr ? `\n(stderr: ${o.stderr})` : ""}`
161
+ ).join("\n\n");
162
+
163
+ const extra = guidelines ? `\nGuidelines: ${guidelines}` : "";
164
+ const outputBlock = session.trim()
165
+ ? `Terminal output:\n---\n${session.slice(0, 6000)}\n---`
166
+ : "(No terminal output captured — generate representative output from context.)";
125
167
 
126
- const args = [script, "--highlights", castPath, outFile];
127
- if (context) args.push(context);
128
- if (guidelines) args.push(guidelines);
168
+ let prompt;
169
+ if (isDemo) {
170
+ prompt = `Create chapter-based highlights for a demo video.
171
+ ${outputBlock}
129
172
 
130
- execFileSync(python, args, { stdio: ["ignore", "inherit", "inherit"], env: process.env });
131
- return outFile;
173
+ Context: ${context}${extra}
174
+
175
+ Return JSON array. Each: {"label":"Chapter Name", "lines":[{"text":"...", "isPrompt":true|false, "color":"#hex", "bold":true|false, "dim":true|false}]}
176
+ 4-6 chapters, 12-20 lines each. Show complete commands + output.
177
+ Colors: green="#50fa7b" yellow="#f1fa8c" purple="#bd93f9" red="#ff5555" dim="#6272a4" white="#f8f8f2"
178
+ Return ONLY JSON array.`;
179
+ } else {
180
+ prompt = `Create highlights for a CLI demo video.
181
+ ${outputBlock}
182
+
183
+ Context: ${context}${extra}
184
+
185
+ Return JSON array. Each: {"label":"Name", "lines":[{"text":"...", "isPrompt":true|false, "color":"#hex", "bold":true|false, "dim":true|false}]}
186
+ 3-4 highlights, 4-8 lines each.
187
+ Colors: green="#50fa7b" yellow="#f1fa8c" purple="#bd93f9" red="#ff5555" dim="#6272a4" white="#f8f8f2"
188
+ Return ONLY JSON array.`;
189
+ }
190
+
191
+ const result = parseJSON(claude(prompt), null);
192
+ if (result) return result;
193
+
194
+ console.error(" Retrying highlight extraction...");
195
+ const retry = parseJSON(claude(`Generate ${isDemo ? 4 : 3} terminal highlights as JSON.
196
+ Context: ${context}
197
+ Each: {"label":"Name", "lines":[{"text":"cmd", "isPrompt":true}, {"text":"output", "color":"#50fa7b"}]}
198
+ 8-15 lines per highlight. Return ONLY JSON array.`), null);
199
+ if (retry) return retry;
200
+
201
+ return [{ label: "Run", lines: [
202
+ { text: context || "demo", isPrompt: true },
203
+ { text: " Done.", color: "#50fa7b" },
204
+ ]}];
132
205
  }
133
206
 
134
- // ── Browser Recording ───────────────────────────────────────
207
+ // ── Browser Demo ───────────────────────────────────────────
135
208
 
136
- function browserEnv() {
137
- const browsersDir = join(ROOT, "scripts", ".venv", "playwright-browsers");
138
- return { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersDir };
209
+ async function ensurePlaywright() {
210
+ try {
211
+ await import("playwright");
212
+ } catch {
213
+ console.error("Installing playwright...");
214
+ execFileSync("npm", ["install", "--no-save", "playwright"], {
215
+ cwd: ROOT, stdio: ["ignore", "inherit", "inherit"],
216
+ });
217
+ }
139
218
  }
140
219
 
141
- function recordBrowser(url, task, authState, guidelines) {
142
- const python = findPython();
143
- const script = join(ROOT, "scripts", "browser_demo.py");
220
+ async function recordBrowser(url, task, authState, guidelines) {
221
+ const { chromium } = await import("playwright");
222
+ const fs = await import("node:fs");
223
+ const { mkdtemp } = await import("node:fs/promises");
224
+ const videoDir = await mkdtemp(join(tmpdir(), "agentreel-"));
144
225
  const outFile = join(tmpdir(), "agentreel-browser-demo.mp4");
145
226
 
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
- }
227
+ console.error(` Recording ${url}...`);
228
+
229
+ const extra = guidelines ? `\nGuidelines: ${guidelines}` : "";
230
+ const scriptCode = claude(`Generate a Playwright JS async function body that demos ${url}.
231
+ Task: ${task}${extra}
232
+ The code will run inside: async (page) => { YOUR_CODE_HERE }
233
+ Navigate, click buttons, fill forms, scroll. ~20 seconds total.
234
+ Add await page.waitForTimeout(1500) between actions.
235
+ Use timeout:5000, force:true on clicks. Wrap actions in try/catch.
236
+ Return ONLY the function body, no function declaration, no imports.`);
237
+
238
+ const recordingStartMs = Date.now();
239
+ const browser = await chromium.launch({ headless: true });
240
+ const ctxOpts = {
241
+ viewport: { width: 1280, height: 800 },
242
+ recordVideo: { dir: videoDir, size: { width: 1280, height: 800 } },
243
+ };
244
+ if (authState && existsSync(authState)) ctxOpts.storageState = authState;
245
+ const context = await browser.newContext(ctxOpts);
246
+
247
+ await context.addInitScript(`
248
+ if (!window.__clicks) {
249
+ window.__clicks = [];
250
+ document.addEventListener('click', e => {
251
+ window.__clicks.push({ x: e.clientX, y: e.clientY, timestamp: Date.now() - ${recordingStartMs} });
252
+ }, true);
253
+ }
254
+ `);
157
255
 
158
- function extractBrowserHighlights(videoPath, task) {
159
- const python = findPython();
160
- const script = join(ROOT, "scripts", "browser_demo.py");
161
- const outFile = videoPath + "-highlights.json";
256
+ const page = await context.newPage();
257
+ try { await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); }
258
+ catch { await page.goto(url, { timeout: 15000 }); }
259
+ await page.waitForTimeout(1000);
162
260
 
163
- execFileSync(python, [script, "--highlights", videoPath, outFile, task], {
164
- stdio: ["ignore", "inherit", "inherit"],
165
- env: browserEnv(),
166
- });
167
- return outFile;
168
- }
261
+ // Run the generated demo script in a sandboxed VM context
262
+ try {
263
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
264
+ const demoFn = new AsyncFunction("page", scriptCode);
265
+ await demoFn(page);
266
+ } catch (e) {
267
+ console.error(` Demo script error: ${e.message}`);
268
+ await page.waitForTimeout(3000);
269
+ }
270
+
271
+ let clicks = [];
272
+ try {
273
+ const raw = await page.evaluate("window.__clicks || []");
274
+ clicks = raw.map(c => ({ x: c.x, y: c.y, timeSec: Math.round(c.timestamp / 100) / 10 }));
275
+ } catch {}
276
+
277
+ await page.close();
278
+ await context.close();
279
+ await browser.close();
280
+
281
+ const files = fs.readdirSync(videoDir);
282
+ const webm = files.find(f => f.endsWith(".webm"));
283
+ if (webm) {
284
+ try {
285
+ spawnSync("ffmpeg", ["-y", "-i", join(videoDir, webm), "-c:v", "libx264", "-preset", "fast", "-crf", "23", outFile], { timeout: 60000 });
286
+ } catch {
287
+ fs.copyFileSync(join(videoDir, webm), outFile.replace(".mp4", ".webm"));
288
+ }
289
+ }
169
290
 
170
- // ── Browser Highlight Builder ───────────────────────────────
291
+ return { videoPath: outFile, clicks };
292
+ }
171
293
 
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
294
+ function buildBrowserHighlights(clicks, task, guidelines) {
295
+ const CLIP_DUR = 7, MIN = 3, MAX = 4;
177
296
  let labels, overlays;
178
297
  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());
298
+ const result = claude(`Generate 4 highlight labels and overlays for a browser demo.
299
+ Task: ${task}${guidelines ? `\nGuidelines: ${guidelines}` : ""}
300
+ Return JSON: {"labels":["w1","w2","w3","w4"], "overlays":["**c1**","**c2**","**c3**","**c4**"]}
301
+ Labels: 1-2 words. Overlays: short with **bold**. Return ONLY JSON.`, 30000);
302
+ const parsed = parseJSON(result, {});
189
303
  labels = parsed.labels?.length >= 4 ? parsed.labels : null;
190
304
  overlays = parsed.overlays?.length >= 4 ? parsed.overlays : null;
191
- } catch { /* fall through */ }
305
+ } catch {}
192
306
  if (!labels) labels = ["Overview", "Interact", "Navigate", "Result"];
193
307
  if (!overlays) overlays = ["**First look**", "**Key action**", "**Exploring**", "**The result**"];
194
308
 
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);
309
+ const lastClick = clicks.length > 0 ? clicks[clicks.length - 1].timeSec : 0;
310
+ const videoDur = Math.max(25, lastClick + 5);
198
311
 
199
- // Build click-based highlights
200
- const clickHighlights = [];
312
+ const highlights = [];
201
313
  if (clicks.length >= 1) {
202
- // Group clicks that are within 3s of each other
203
- const clusters = [];
204
- let cluster = [clicks[0]];
205
-
314
+ const clusters = []; let cluster = [clicks[0]];
206
315
  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
- }
316
+ if (clicks[i].timeSec - cluster[cluster.length - 1].timeSec < 3) cluster.push(clicks[i]);
317
+ else { clusters.push(cluster); cluster = [clicks[i]]; }
213
318
  }
214
319
  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({
320
+ const ranked = clusters.sort((a, b) => b.length - a.length).slice(0, MAX).sort((a, b) => a[0].timeSec - b[0].timeSec);
321
+ for (const c of ranked) {
322
+ const center = (c[0].timeSec + c[c.length - 1].timeSec) / 2;
323
+ const start = Math.max(0, center - CLIP_DUR / 2);
324
+ 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 }));
325
+ highlights.push({
240
326
  videoSrc: "browser-demo.mp4",
241
- videoStartSec: Math.round(startSec * 10) / 10,
242
- videoEndSec: Math.round(endSec * 10) / 10,
243
- focusX,
244
- focusY,
327
+ videoStartSec: Math.round(start * 10) / 10,
328
+ videoEndSec: Math.round((start + CLIP_DUR) * 10) / 10,
329
+ focusX: hlClicks.reduce((s, c) => s + c.x, 0) / hlClicks.length / 1280,
330
+ focusY: hlClicks.reduce((s, c) => s + c.y, 0) / hlClicks.length / 800,
245
331
  clicks: hlClicks,
246
332
  });
247
333
  }
248
334
  }
249
335
 
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
- }
336
+ while (highlights.length < MIN) {
337
+ const slot = videoDur * (highlights.length + 1) / (MIN + 1);
338
+ const start = Math.max(0, slot - CLIP_DUR / 2);
339
+ highlights.push({ videoSrc: "browser-demo.mp4", videoStartSec: Math.round(start * 10) / 10, videoEndSec: Math.round((start + CLIP_DUR) * 10) / 10 });
272
340
  }
273
341
 
274
- // Sort by start time and assign labels
275
342
  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)`);
343
+ highlights.forEach((h, i) => { h.label = labels[i % labels.length]; h.overlay = overlays[i % overlays.length]; });
282
344
  return highlights;
283
345
  }
284
346
 
@@ -289,329 +351,120 @@ function escSvg(s) {
289
351
  }
290
352
 
291
353
  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
-
354
+ const FONT = '"SF Mono", "Fira Code", monospace';
355
+ const SANS = '-apple-system, system-ui, sans-serif';
300
356
  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;
357
+ const PAD = 32, LINE_H = 22, TERM_PAD = 16, BAR_H = 36, GAP = 28, FS = 13;
323
358
 
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;
359
+ let y = PAD, blocks = "";
360
+ 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>`;
361
+ y += props.subtitle ? 68 : 56;
362
+ 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
363
 
364
+ for (const hl of props.highlights) {
365
+ if (!hl.lines?.length) continue;
366
+ blocks += `<text x="${PAD}" y="${y + 14}" font-family="${escSvg(FONT)}" font-size="11" fill="#50fa7b" letter-spacing="2">${escSvg(hl.label.toUpperCase())}</text>`;
367
+ y += 24;
328
368
  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;
369
+ blocks += `<rect x="${PAD}" y="${y}" width="${W - PAD * 2}" height="${BAR_H + bodyH}" rx="8" fill="#1e1f29"/>`;
370
+ 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"/>`;
371
+ blocks += `<rect x="${PAD}" y="${y + BAR_H}" width="${W - PAD * 2}" height="${bodyH}" fill="#282a36"/>`;
372
+ let ly = y + BAR_H + TERM_PAD;
341
373
  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>` : "";
374
+ const color = line.dim ? "#6272a4" : line.color || "#f8f8f2";
375
+ const prefix = line.isPrompt ? `<tspan fill="#50fa7b">$ </tspan>` : "";
345
376
  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;
377
+ 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>`;
378
+ ly += LINE_H;
348
379
  }
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;
380
+ y += BAR_H + bodyH + GAP;
357
381
  }
382
+ 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
383
  y += PAD;
359
384
 
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)`);
385
+ 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>`);
386
+ console.error(`\nDone: ${output} (SVG)`);
367
387
  }
368
388
 
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 ──────────────────────────────────────────────────
389
+ // ── Video Render ───────────────────────────────────────────
381
390
 
382
391
  async function renderVideo(props, output, musicPath) {
383
392
  const publicDir = join(ROOT, "public");
384
393
  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);
394
+ if (musicPath && existsSync(musicPath)) copyFileSync(musicPath, join(publicDir, "music.mp3"));
391
395
 
392
- // Render using Remotion's Node.js API — no CLI binary needed
393
396
  const { bundle } = await import("@remotion/bundler");
394
397
  const { renderMedia, selectComposition } = await import("@remotion/renderer");
395
398
 
396
- const entryPoint = join(ROOT, "src", "index.ts");
397
-
398
399
  console.error(" Bundling...");
399
- const serveUrl = await bundle({
400
- entryPoint,
401
- webpackOverride: (config) => config,
402
- });
400
+ const serveUrl = await bundle({ entryPoint: join(ROOT, "src", "index.ts"), webpackOverride: c => c });
403
401
 
404
402
  console.error(" Preparing renderer...");
405
403
  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
- },
404
+ serveUrl, id: "CastVideo", inputProps: props,
405
+ onBrowserDownload: () => { console.error(" Downloading renderer (~90MB)..."); return { onProgress: () => {} }; },
413
406
  });
414
407
 
415
408
  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)`);
409
+ await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: resolve(output), inputProps: props });
410
+ console.error(`\nDone: ${output} (${Math.round(statSync(resolve(output)).size / 1024)} KB)`);
426
411
  }
427
412
 
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
- });
413
+ async function render(props, output, musicPath) {
414
+ try { await renderVideo(props, output, musicPath); }
415
+ catch (e) {
416
+ console.error(` Video rendering failed: ${e.message}`);
417
+ const svg = output.replace(/\.[^.]+$/, ".svg");
418
+ console.error(` Falling back to SVG: ${svg}`);
419
+ renderSVG(props, svg);
420
+ }
438
421
  }
439
422
 
440
- async function shareFlow(outputPath, title, prompt) {
441
- const shouldShare = await askYesNo("Share to Twitter? [Y/n] ");
442
- if (!shouldShare) return;
423
+ // ── Share ──────────────────────────────────────────────────
424
+
425
+ async function shareFlow(outputPath, title, desc) {
426
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
427
+ const answer = await new Promise(r => rl.question("Share to Twitter? [Y/n] ", a => { rl.close(); r(a); }));
428
+ if (answer.trim().toLowerCase() === "n") return;
443
429
 
444
430
  const name = title || "this";
445
- const desc = prompt || "";
446
431
  const text = desc
447
432
  ? `Introducing ${name} — ${desc}\n\nMade with https://github.com/islo-labs/agentreel`
448
433
  : `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);
434
+ const intentURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`;
435
+ console.error(`\n Opening Twitter attach your video.\n Video: ${resolve(outputPath)}\n`);
436
+ try { execFileSync(process.platform === "darwin" ? "open" : "xdg-open", [intentURL], { stdio: "ignore" }); }
437
+ catch { console.error(` Link: ${intentURL}`); }
552
438
  }
553
439
 
554
440
  // ── Dev Server ─────────────────────────────────────────────
555
441
 
556
442
  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)
443
+ console.error(` Starting: ${command}`);
444
+ const proc = spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"], detached: true });
564
445
  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)) {
446
+ const timeout = setTimeout(() => resolve(proc), 30000);
447
+ const onData = (d) => {
448
+ if (/localhost|ready|started|listening|compiled/i.test(d.toString())) {
573
449
  clearTimeout(timeout);
574
- // Give it a moment to fully start
575
- setTimeout(() => {
576
- console.error(" Dev server ready");
577
- resolve(proc);
578
- }, 2000);
450
+ setTimeout(() => resolve(proc), 2000);
579
451
  }
580
452
  };
581
-
582
453
  proc.stdout.on("data", onData);
583
454
  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
- });
455
+ proc.on("error", e => { clearTimeout(timeout); reject(e); });
596
456
  });
597
457
  }
598
458
 
599
459
  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
- }
460
+ if (!proc?.killed) try { process.kill(-proc.pid, "SIGTERM"); } catch { try { proc.kill(); } catch {} }
607
461
  }
608
462
 
609
- // ── Main ────────────────────────────────────────────────────
463
+ // ── Main ───────────────────────────────────────────────────
610
464
 
611
465
  async function main() {
612
466
  const flags = parseArgs();
613
467
  const output = flags.output || "agentreel.mp4";
614
- const noShare = flags.noShare;
615
468
 
616
469
  if (!flags.cmd && !flags.url && !flags.pr) {
617
470
  console.error("Please provide --pr, --cmd, or --url.\n");
@@ -619,148 +472,81 @@ async function main() {
619
472
  process.exit(1);
620
473
  }
621
474
 
622
- // ── PR mode ──────────────────────────────────────────────
475
+ // ── PR mode ──────────────────────────────────────────
623
476
  if (flags.pr) {
624
477
  console.error("Fetching PR context...");
625
- const prContext = fetchPRContext(flags.pr);
626
- console.error(` PR #${prContext.number}: ${prContext.title}`);
478
+ const pr = fetchPRContext(flags.pr);
479
+ console.error(` PR #${pr.number}: ${pr.title}`);
627
480
 
628
481
  console.error("Planning demo...");
629
- const plan = planDemoFromPR(prContext, flags.guidelines);
482
+ const plan = planDemoFromPR(pr, flags.guidelines);
630
483
  console.error(` Type: ${plan.type}, "${plan.description}"`);
631
484
 
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
485
+ const title = flags.title || plan.title || pr.title;
635
486
  const demoGuidelines = `[demo] ${plan.guidelines || ""}`.trim();
636
487
 
637
488
  if (plan.type === "browser") {
638
- const url = plan.url || "http://localhost:3000";
639
489
  let serverProc = null;
640
-
641
490
  try {
642
- if (flags.start) {
643
- serverProc = await startDevServer(flags.start);
644
- }
645
-
646
- ensureBrowserDeps();
491
+ if (flags.start) serverProc = await startDevServer(flags.start);
492
+ await ensurePlaywright();
647
493
  console.error("Step 1/3: Recording browser demo...");
648
- const videoPath = recordBrowser(url, demoGuidelines, flags.auth, demoGuidelines);
649
-
494
+ const { videoPath, clicks } = await recordBrowser(plan.url || "http://localhost:3000", demoGuidelines, flags.auth, demoGuidelines);
650
495
  const publicDir = join(ROOT, "public");
651
496
  if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
652
497
  copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
653
-
654
498
  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
- }
499
+ const highlights = buildBrowserHighlights(clicks, demoGuidelines, demoGuidelines);
500
+ console.error("Step 3/3: Rendering...");
501
+ await render({ title, subtitle: plan.description, highlights, endText: pr.title, endUrl: pr.url, mode: "demo" }, output, flags.music);
502
+ } finally { stopDevServer(serverProc); }
675
503
  } 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
-
504
+ if (!plan.command) { console.error("Error: could not determine command to demo."); process.exit(1); }
682
505
  console.error("Step 1/3: Recording CLI demo...");
683
- const castPath = recordCLI(plan.command, process.cwd(), description, demoGuidelines);
684
-
506
+ const steps = planDemoSteps(plan.command, plan.description, demoGuidelines);
507
+ console.error(` ${steps.length} steps planned`);
508
+ const outputs = executeSteps(steps, process.cwd());
685
509
  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);
510
+ const highlights = extractHighlights(outputs, plan.description, demoGuidelines, true);
511
+ console.error(` ${highlights.length} highlights`);
512
+ console.error("Step 3/3: Rendering...");
513
+ await render({ title, subtitle: plan.description, highlights, endText: plan.command, endUrl: pr.url, mode: "demo" }, output, flags.music);
699
514
  }
700
515
 
701
- if (!noShare) {
702
- await shareFlow(resolve(output), videoTitle, description);
703
- }
516
+ if (!flags.noShare) await shareFlow(resolve(output), title, plan.description);
704
517
  return;
705
518
  }
706
519
 
707
- // ── Manual modes (--cmd / --url) ─────────────────────────
708
- let videoTitle = flags.title || flags.cmd || flags.url;
709
-
520
+ // ── CLI mode ─────────────────────────────────────────
710
521
  if (flags.cmd) {
522
+ const title = flags.title || flags.cmd;
711
523
  console.error("Step 1/3: Recording CLI demo...");
712
- const castPath = recordCLI(flags.cmd, process.cwd(), flags.cmd, flags.guidelines);
713
-
524
+ const steps = planDemoSteps(flags.cmd, flags.cmd, flags.guidelines);
525
+ console.error(` ${steps.length} steps planned`);
526
+ const outputs = executeSteps(steps, process.cwd());
714
527
  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
- }
528
+ const highlights = extractHighlights(outputs, flags.cmd, flags.guidelines, false);
529
+ console.error(` ${highlights.length} highlights`);
530
+ console.error("Step 3/3: Rendering...");
531
+ await render({ title, highlights, endText: flags.cmd }, output, flags.music);
532
+ if (!flags.noShare) await shareFlow(resolve(output), title, flags.cmd);
729
533
  return;
730
534
  }
731
535
 
536
+ // ── Browser mode ─────────────────────────────────────
732
537
  if (flags.url) {
733
- const task = "Explore the main features of this app";
734
-
735
- ensureBrowserDeps();
538
+ const title = flags.title || flags.url;
539
+ await ensurePlaywright();
736
540
  console.error("Step 1/3: Recording browser demo...");
737
- const videoPath = recordBrowser(flags.url, task, flags.auth, flags.guidelines);
738
-
541
+ const { videoPath, clicks } = await recordBrowser(flags.url, "Explore the main features", flags.auth, flags.guidelines);
739
542
  const publicDir = join(ROOT, "public");
740
543
  if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
741
544
  copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
742
-
743
545
  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;
546
+ const highlights = buildBrowserHighlights(clicks, "Explore the main features", flags.guidelines);
547
+ console.error("Step 3/3: Rendering...");
548
+ await render({ title, highlights, endText: flags.url, endUrl: flags.url }, output, flags.music);
549
+ if (!flags.noShare) await shareFlow(resolve(output), title, flags.url);
764
550
  }
765
551
  }
766
552