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