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/README.md +1 -2
- package/bin/agentreel.mjs +364 -578
- package/package.json +2 -3
- package/scripts/browser_demo.py +0 -286
- package/scripts/cli_demo.py +0 -343
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
|
-
// ──
|
|
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
|
|
20
|
-
if (
|
|
21
|
-
if (
|
|
22
|
-
|
|
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 (
|
|
27
|
-
else if (
|
|
28
|
-
else if (
|
|
29
|
-
else if (
|
|
30
|
-
else if (
|
|
31
|
-
else if (
|
|
32
|
-
else if (
|
|
33
|
-
else if (
|
|
34
|
-
else if (
|
|
35
|
-
else if (
|
|
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
|
|
65
|
+
console.log(`agentreel — Turn your apps into demo videos
|
|
42
66
|
|
|
43
67
|
Usage:
|
|
44
|
-
agentreel --pr 123
|
|
45
|
-
agentreel --
|
|
46
|
-
agentreel --
|
|
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>
|
|
51
|
-
--start <cmd>
|
|
52
|
-
-c, --cmd <
|
|
53
|
-
-u, --url <url>
|
|
54
|
-
-t, --title <text>
|
|
55
|
-
-o, --output <file>
|
|
56
|
-
-a, --auth <file>
|
|
57
|
-
-g, --guidelines <
|
|
58
|
-
--music <file>
|
|
59
|
-
--no-share
|
|
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
|
-
// ──
|
|
85
|
+
// ── PR Context ─────────────────────────────────────────────
|
|
65
86
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
return
|
|
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
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
168
|
+
let prompt;
|
|
169
|
+
if (isDemo) {
|
|
170
|
+
prompt = `Create chapter-based highlights for a demo video.
|
|
171
|
+
${outputBlock}
|
|
129
172
|
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
207
|
+
// ── Browser Demo ───────────────────────────────────────────
|
|
135
208
|
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
143
|
-
const
|
|
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(`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
291
|
+
return { videoPath: outFile, clicks };
|
|
292
|
+
}
|
|
171
293
|
|
|
172
|
-
function buildBrowserHighlights(clicks,
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
|
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
|
-
|
|
196
|
-
const
|
|
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
|
-
|
|
200
|
-
const clickHighlights = [];
|
|
312
|
+
const highlights = [];
|
|
201
313
|
if (clicks.length >= 1) {
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
.
|
|
219
|
-
.
|
|
220
|
-
.
|
|
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(
|
|
242
|
-
videoEndSec: Math.round(
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
293
|
-
const
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 ?
|
|
343
|
-
const
|
|
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="${
|
|
347
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
console.error(
|
|
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
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
626
|
-
console.error(` PR #${
|
|
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(
|
|
482
|
+
const plan = planDemoFromPR(pr, flags.guidelines);
|
|
630
483
|
console.error(` Type: ${plan.type}, "${plan.description}"`);
|
|
631
484
|
|
|
632
|
-
const
|
|
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
|
-
|
|
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
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
687
|
-
|
|
688
|
-
console.error(
|
|
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
|
-
// ──
|
|
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
|
|
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
|
|
716
|
-
|
|
717
|
-
console.error(
|
|
718
|
-
|
|
719
|
-
|
|
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
|
|
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,
|
|
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
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|