agentreel 0.6.0 → 0.6.1
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 +12 -45
- package/bin/agentreel.mjs +173 -186
- package/package.json +1 -1
- package/public/browser-demo.mp4 +0 -0
- package/public/music.mp3 +0 -0
- package/src/CastVideo.tsx +981 -638
- package/src/Root.tsx +16 -12
- package/src/types.ts +100 -48
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# agentreel
|
|
2
2
|
|
|
3
|
-
Turn your
|
|
3
|
+
Turn your apps into launch videos.
|
|
4
4
|
|
|
5
5
|
https://github.com/user-attachments/assets/474fd85d-3b35-48f4-82b8-1b337840fb51
|
|
6
6
|
|
|
@@ -15,63 +15,31 @@ npx agentreel
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
#
|
|
19
|
-
agentreel --pr 123
|
|
20
|
-
agentreel --pr owner/repo#123
|
|
21
|
-
agentreel --pr https://github.com/owner/repo/pull/123
|
|
22
|
-
|
|
23
|
-
# PR demo with a dev server (for web UI changes):
|
|
24
|
-
agentreel --pr 123 --start "npm run dev"
|
|
25
|
-
|
|
26
|
-
# CLI demo:
|
|
18
|
+
# CLI launch video:
|
|
27
19
|
agentreel --cmd "npx my-cli-tool"
|
|
28
20
|
|
|
29
|
-
# Browser
|
|
21
|
+
# Browser launch video:
|
|
30
22
|
agentreel --url http://localhost:3000
|
|
31
23
|
```
|
|
32
24
|
|
|
33
|
-
## Modes
|
|
34
|
-
|
|
35
|
-
### PR demo (`--pr`)
|
|
36
|
-
|
|
37
|
-
Point it at a pull request. It fetches the diff, description, and README from GitHub, then AI plans and records a demo showing what the PR actually does.
|
|
38
|
-
|
|
39
|
-
- **1920x1080 landscape** — chapter-based walkthrough
|
|
40
|
-
- **4-6 chapters**, 12s each — full command + output flows
|
|
41
|
-
- No music, no marketing overlays — just the real demo
|
|
42
|
-
- Great for attaching to PRs so reviewers can see the change in action
|
|
43
|
-
|
|
44
|
-
Requires [`gh` CLI](https://cli.github.com) to be installed and authenticated.
|
|
45
|
-
|
|
46
|
-
### Marketing reel (`--cmd` / `--url`)
|
|
47
|
-
|
|
48
|
-
The original mode — creates a short, polished clip for social media.
|
|
49
|
-
|
|
50
|
-
- **1080x1080 square** — optimized for Twitter/X, LinkedIn, Reels
|
|
51
|
-
- **3-4 highlight snippets**, 4.5s each — the best moments
|
|
52
|
-
- Music, animated transitions, text overlays, cursor animations
|
|
53
|
-
- Prompts you to share on Twitter
|
|
54
|
-
|
|
55
25
|
## How it works
|
|
56
26
|
|
|
57
|
-
1.
|
|
58
|
-
2.
|
|
59
|
-
3. AI
|
|
60
|
-
4.
|
|
61
|
-
5. Renders video with Remotion
|
|
27
|
+
1. You provide a CLI command or URL
|
|
28
|
+
2. AI plans and executes the demo (terminal or Playwright browser)
|
|
29
|
+
3. AI generates a launch video with text slides, terminal highlights, diagrams, and panels
|
|
30
|
+
4. Renders a polished **1080x1080** video with Remotion — ready for Twitter/X, LinkedIn, Reels
|
|
62
31
|
|
|
63
32
|
## Flags
|
|
64
33
|
|
|
65
34
|
```
|
|
66
|
-
--pr <ref> PR number, owner/repo#N, or full GitHub URL
|
|
67
|
-
--start <cmd> command to start a dev server (for browser PR demos)
|
|
68
35
|
-c, --cmd <command> CLI command to demo
|
|
69
36
|
-u, --url <url> URL to demo (browser mode)
|
|
70
37
|
-t, --title <text> video title
|
|
38
|
+
-s, --subtitle <text> video subtitle
|
|
71
39
|
-o, --output <file> output file (default: agentreel.mp4)
|
|
72
|
-
-a, --auth <file> Playwright
|
|
73
|
-
-g, --guidelines <text>
|
|
74
|
-
--music <file>
|
|
40
|
+
-a, --auth <file> Playwright auth state for browser demos
|
|
41
|
+
-g, --guidelines <text> highlight generation guidelines
|
|
42
|
+
--music <file> background music mp3
|
|
75
43
|
--no-share skip the share prompt
|
|
76
44
|
```
|
|
77
45
|
|
|
@@ -79,11 +47,10 @@ The original mode — creates a short, polished clip for social media.
|
|
|
79
47
|
|
|
80
48
|
- Node.js 18+
|
|
81
49
|
- Claude CLI (`claude`) — plans and records demos
|
|
82
|
-
- `gh` CLI — required for `--pr` mode
|
|
83
50
|
|
|
84
51
|
## Credits
|
|
85
52
|
|
|
86
|
-
Default background music:
|
|
53
|
+
Default background music: "Boogie Funky" by Petrushka Sound
|
|
87
54
|
|
|
88
55
|
## License
|
|
89
56
|
|
package/bin/agentreel.mjs
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { execFileSync, spawnSync
|
|
3
|
+
import { execFileSync, spawnSync } 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";
|
|
10
9
|
|
|
11
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
11
|
const ROOT = resolve(__dirname, "..");
|
|
@@ -49,8 +48,6 @@ function parseArgs() {
|
|
|
49
48
|
}
|
|
50
49
|
if (a === "--cmd" || a === "-c") flags.cmd = args[++i];
|
|
51
50
|
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
51
|
else if (a === "--title" || a === "-t") flags.title = args[++i];
|
|
55
52
|
else if (a === "--subtitle" || a === "-s") flags.subtitle = args[++i];
|
|
56
53
|
else if (a === "--output" || a === "-o") flags.output = args[++i];
|
|
@@ -63,16 +60,13 @@ function parseArgs() {
|
|
|
63
60
|
}
|
|
64
61
|
|
|
65
62
|
function printUsage() {
|
|
66
|
-
console.log(`agentreel — Turn your apps into
|
|
63
|
+
console.log(`agentreel — Turn your apps into launch videos
|
|
67
64
|
|
|
68
65
|
Usage:
|
|
69
|
-
agentreel --
|
|
70
|
-
agentreel --
|
|
71
|
-
agentreel --url http://localhost:3000 # browser demo
|
|
66
|
+
agentreel --cmd "npx my-tool" # CLI launch video
|
|
67
|
+
agentreel --url http://localhost:3000 # browser launch video
|
|
72
68
|
|
|
73
69
|
Flags:
|
|
74
|
-
--pr <ref> PR number, owner/repo#N, or GitHub URL
|
|
75
|
-
--start <cmd> start a dev server for browser PR demos
|
|
76
70
|
-c, --cmd <cmd> CLI command to demo
|
|
77
71
|
-u, --url <url> URL to demo (browser mode)
|
|
78
72
|
-t, --title <text> video title
|
|
@@ -84,49 +78,7 @@ Flags:
|
|
|
84
78
|
--no-share skip the share prompt`);
|
|
85
79
|
}
|
|
86
80
|
|
|
87
|
-
// ──
|
|
88
|
-
|
|
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 }));
|
|
98
|
-
|
|
99
|
-
let diff = "";
|
|
100
|
-
try { diff = execFileSync("gh", ["pr", "diff", String(prRef)], { encoding: "utf-8", timeout: 30000 }); }
|
|
101
|
-
catch {}
|
|
102
|
-
|
|
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; }
|
|
107
|
-
}
|
|
108
|
-
return { ...pr, diff, readme };
|
|
109
|
-
}
|
|
110
|
-
|
|
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.
|
|
114
|
-
|
|
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: "" });
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── CLI Demo ───────────────────────────────────────────────
|
|
81
|
+
// ── CLI Recording ─────────────────────────────────────────
|
|
130
82
|
|
|
131
83
|
function planDemoSteps(command, context, guidelines) {
|
|
132
84
|
const extra = guidelines ? `\nIMPORTANT guidelines:\n${guidelines}` : "";
|
|
@@ -157,7 +109,7 @@ function executeSteps(steps, workDir) {
|
|
|
157
109
|
return outputs;
|
|
158
110
|
}
|
|
159
111
|
|
|
160
|
-
function extractHighlights(outputs, context, guidelines
|
|
112
|
+
function extractHighlights(outputs, context, guidelines) {
|
|
161
113
|
const session = outputs.map(o =>
|
|
162
114
|
`$ ${o.command}\n${o.stdout}${o.stderr ? `\n(stderr: ${o.stderr})` : ""}`
|
|
163
115
|
).join("\n\n");
|
|
@@ -167,46 +119,50 @@ function extractHighlights(outputs, context, guidelines, isDemo) {
|
|
|
167
119
|
? `Terminal output:\n---\n${session.slice(0, 6000)}\n---`
|
|
168
120
|
: "(No terminal output captured — generate representative output from context.)";
|
|
169
121
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
122
|
+
const prompt = `Create highlights for a sleek launch video (like a product launch reel).
|
|
123
|
+
Mix these highlight types for maximum impact:
|
|
124
|
+
|
|
125
|
+
1. Text slides — bold narrative statements:
|
|
126
|
+
{"label":"...", "statement":"Line one.\\nLine two."}
|
|
127
|
+
2. Terminal highlights — actual CLI demo:
|
|
128
|
+
{"label":"...", "lines":[{"text":"...", "isPrompt":true|false, "color":"#hex", "bold":true|false, "dim":true|false}]}
|
|
129
|
+
3. Animated tree — shows the tool's architecture/hierarchy as a branching visualization:
|
|
130
|
+
{"label":"...", "tree":{"root":"Root Label", "depth":4, "branching":[3,2,3], "nodeLabels":[["Child1","Child2","Child3"],["Grandchild1","Grandchild2"]]}}
|
|
131
|
+
The tree auto-generates a fractal structure. Make it CONTEXTUAL — root=the tool name, first-level children=its main modules/stages, second-level=sub-components. branching can be a number (uniform) or array (per-level). nodeLabels[0]=level 1 labels, nodeLabels[1]=level 2 labels (cycled if fewer than nodes).
|
|
132
|
+
4. Side-by-side panels — two content cards comparing concepts:
|
|
133
|
+
{"label":"...", "panels":{"left":{"title":"...", "content":"Line1.\\nLine2."}, "right":{"title":"...", "content":"Line1.\\nLine2."}}}
|
|
134
|
+
5. Diagram — manual node-and-edge flow (for pipelines/flows, not hierarchies):
|
|
135
|
+
{"label":"...", "diagram":{"nodes":[{"id":"...", "label":"...", "x":0.0-1.0, "y":0.0-1.0}], "edges":[{"from":"id", "to":"id"}]}}
|
|
176
136
|
|
|
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
137
|
${outputBlock}
|
|
184
138
|
|
|
185
139
|
Context: ${context}${extra}
|
|
186
140
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
Colors: green="#
|
|
141
|
+
Structure: Open with a text slide (the hook), then 1-2 terminal highlights showing the tool in action, then a tree or panels to visualize the architecture, then close with a text slide (the payoff). 5-6 highlights total.
|
|
142
|
+
IMPORTANT: Trees and diagrams must reflect the ACTUAL tool being demoed — use real module names, real pipeline stages, real concepts from the context. Do NOT use generic labels.
|
|
143
|
+
Colors (light terminal): green="#16a34a" purple="#6d28d9" blue="#2563eb" red="#dc2626" dim="#9ca3af" default="#1a1a1a"
|
|
144
|
+
For text slides: keep statements punchy, 1-2 lines max.
|
|
190
145
|
Return ONLY JSON array.`;
|
|
191
|
-
}
|
|
192
146
|
|
|
193
147
|
const result = parseJSON(claude(prompt), null);
|
|
194
148
|
if (result) return result;
|
|
195
149
|
|
|
196
150
|
console.error(" Retrying highlight extraction...");
|
|
197
|
-
const retry = parseJSON(claude(`Generate
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
8-15 lines per highlight. Return ONLY JSON array.`), null);
|
|
151
|
+
const retry = parseJSON(claude(`Generate a launch video with 4 highlights. Context: ${context}
|
|
152
|
+
Mix types: text slides {"label":"...", "statement":"Bold text"} and terminal {"label":"...", "lines":[{"text":"cmd", "isPrompt":true}, {"text":"output", "color":"#16a34a"}]}.
|
|
153
|
+
Start with a text slide hook, then terminal demos, end with a text slide payoff. Return ONLY JSON array.`), null);
|
|
201
154
|
if (retry) return retry;
|
|
202
155
|
|
|
203
|
-
return [
|
|
204
|
-
{
|
|
205
|
-
{
|
|
206
|
-
|
|
156
|
+
return [
|
|
157
|
+
{ label: "Intro", statement: context || "Demo" },
|
|
158
|
+
{ label: "Run", lines: [
|
|
159
|
+
{ text: context || "demo", isPrompt: true },
|
|
160
|
+
{ text: " Done.", color: "#16a34a" },
|
|
161
|
+
]},
|
|
162
|
+
];
|
|
207
163
|
}
|
|
208
164
|
|
|
209
|
-
// ── Browser
|
|
165
|
+
// ── Browser Recording ─────────────────────────────────────
|
|
210
166
|
|
|
211
167
|
async function ensurePlaywright() {
|
|
212
168
|
try {
|
|
@@ -219,26 +175,72 @@ async function ensurePlaywright() {
|
|
|
219
175
|
}
|
|
220
176
|
}
|
|
221
177
|
|
|
222
|
-
async function recordBrowser(url,
|
|
178
|
+
async function recordBrowser(url, authState, guidelines) {
|
|
223
179
|
const { chromium } = await import("playwright");
|
|
224
180
|
const fs = await import("node:fs");
|
|
225
181
|
const { mkdtemp } = await import("node:fs/promises");
|
|
226
182
|
const videoDir = await mkdtemp(join(tmpdir(), "agentreel-"));
|
|
227
|
-
const outFile = join(tmpdir(), "agentreel-browser
|
|
183
|
+
const outFile = join(tmpdir(), "agentreel-browser.mp4");
|
|
228
184
|
|
|
229
|
-
|
|
185
|
+
// Step 1: Navigate and extract page content
|
|
186
|
+
console.error(` Loading ${url}...`);
|
|
187
|
+
const browser = await chromium.launch({ headless: true });
|
|
188
|
+
const scoutCtx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
189
|
+
const scoutPage = await scoutCtx.newPage();
|
|
190
|
+
try { await scoutPage.goto(url, { waitUntil: "networkidle", timeout: 15000 }); }
|
|
191
|
+
catch { await scoutPage.goto(url, { timeout: 15000 }); }
|
|
192
|
+
await scoutPage.waitForTimeout(1000);
|
|
193
|
+
|
|
194
|
+
// Extract visible text, title, headings
|
|
195
|
+
const pageContent = await scoutPage.evaluate(() => {
|
|
196
|
+
const title = document.title || "";
|
|
197
|
+
const meta = document.querySelector('meta[name="description"]')?.getAttribute("content") || "";
|
|
198
|
+
const headings = Array.from(document.querySelectorAll("h1, h2, h3")).map(h => h.textContent?.trim()).filter(Boolean).slice(0, 10);
|
|
199
|
+
const buttons = Array.from(document.querySelectorAll("button, a[href]")).map(b => b.textContent?.trim()).filter(t => t && t.length < 40).slice(0, 10);
|
|
200
|
+
const body = document.body?.innerText?.slice(0, 3000) || "";
|
|
201
|
+
return { title, meta, headings, buttons, body };
|
|
202
|
+
});
|
|
203
|
+
await scoutPage.close();
|
|
204
|
+
await scoutCtx.close();
|
|
230
205
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
Use timeout:5000, force:true on clicks. Wrap actions in try/catch.
|
|
238
|
-
Return ONLY the function body, no function declaration, no imports.`);
|
|
206
|
+
const siteContext = `Website: ${url}
|
|
207
|
+
Title: ${pageContent.title}
|
|
208
|
+
Description: ${pageContent.meta}
|
|
209
|
+
Headings: ${pageContent.headings.join(", ")}
|
|
210
|
+
Buttons/Links: ${pageContent.buttons.join(", ")}
|
|
211
|
+
Content (truncated): ${pageContent.body.slice(0, 1500)}`;
|
|
239
212
|
|
|
213
|
+
console.error(` Page: "${pageContent.title}" — ${pageContent.headings.slice(0, 3).join(", ")}`);
|
|
214
|
+
|
|
215
|
+
// Step 2: Generate Playwright demo script using actual page content
|
|
216
|
+
const extra = guidelines ? `\nGuidelines: ${guidelines}` : "";
|
|
217
|
+
console.error(` Generating demo script...`);
|
|
218
|
+
const scriptCode = claude(`Generate a Playwright JS async function body that demos this website.
|
|
219
|
+
|
|
220
|
+
${siteContext}${extra}
|
|
221
|
+
|
|
222
|
+
The code runs inside: async (page) => { YOUR_CODE_HERE }
|
|
223
|
+
|
|
224
|
+
IMPORTANT RULES:
|
|
225
|
+
- page is already at ${url} — do NOT call page.goto()
|
|
226
|
+
- Start by scrolling down slowly to show the full page
|
|
227
|
+
- Use page.evaluate(() => window.scrollBy(0, 400)) for scrolling
|
|
228
|
+
- Click interesting buttons/links using page.click() with {timeout:5000, force:true}
|
|
229
|
+
- Add await page.waitForTimeout(2000) between actions
|
|
230
|
+
- Total ~25 seconds of activity
|
|
231
|
+
- Wrap each action in try/catch so failures don't stop the demo
|
|
232
|
+
- Return ONLY valid JS code — no comments before the first statement, no markdown
|
|
233
|
+
|
|
234
|
+
Example pattern:
|
|
235
|
+
await page.waitForTimeout(2000);
|
|
236
|
+
try { await page.evaluate(() => window.scrollBy({top: 500, behavior: 'smooth'})); } catch {}
|
|
237
|
+
await page.waitForTimeout(2000);
|
|
238
|
+
try { await page.click('text=Get Started', {timeout: 5000, force: true}); } catch {}
|
|
239
|
+
await page.waitForTimeout(2000);`);
|
|
240
|
+
|
|
241
|
+
// Step 3: Record with video
|
|
242
|
+
console.error(` Recording ${url}...`);
|
|
240
243
|
const recordingStartMs = Date.now();
|
|
241
|
-
const browser = await chromium.launch({ headless: true });
|
|
242
244
|
const ctxOpts = {
|
|
243
245
|
viewport: { width: 1280, height: 800 },
|
|
244
246
|
recordVideo: { dir: videoDir, size: { width: 1280, height: 800 } },
|
|
@@ -258,16 +260,21 @@ Return ONLY the function body, no function declaration, no imports.`);
|
|
|
258
260
|
const page = await context.newPage();
|
|
259
261
|
try { await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); }
|
|
260
262
|
catch { await page.goto(url, { timeout: 15000 }); }
|
|
261
|
-
await page.waitForTimeout(
|
|
263
|
+
await page.waitForTimeout(1500);
|
|
262
264
|
|
|
263
|
-
// Run the generated demo script in a sandboxed VM context
|
|
264
265
|
try {
|
|
265
266
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
266
267
|
const demoFn = new AsyncFunction("page", scriptCode);
|
|
267
268
|
await demoFn(page);
|
|
268
269
|
} catch (e) {
|
|
269
270
|
console.error(` Demo script error: ${e.message}`);
|
|
270
|
-
|
|
271
|
+
// Fallback: at least scroll the page
|
|
272
|
+
try {
|
|
273
|
+
for (let i = 0; i < 5; i++) {
|
|
274
|
+
await page.evaluate(() => window.scrollBy({ top: 400, behavior: "smooth" }));
|
|
275
|
+
await page.waitForTimeout(2000);
|
|
276
|
+
}
|
|
277
|
+
} catch {}
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
let clicks = [];
|
|
@@ -290,7 +297,7 @@ Return ONLY the function body, no function declaration, no imports.`);
|
|
|
290
297
|
}
|
|
291
298
|
}
|
|
292
299
|
|
|
293
|
-
return { videoPath: outFile, clicks };
|
|
300
|
+
return { videoPath: outFile, clicks, siteContext };
|
|
294
301
|
}
|
|
295
302
|
|
|
296
303
|
function buildBrowserHighlights(clicks, task, guidelines) {
|
|
@@ -346,6 +353,53 @@ Labels: 1-2 words. Overlays: short with **bold**. Return ONLY JSON.`, 30000);
|
|
|
346
353
|
return highlights;
|
|
347
354
|
}
|
|
348
355
|
|
|
356
|
+
function wrapBrowserHighlights(browserHighlights, context, guidelines) {
|
|
357
|
+
const clipCount = browserHighlights.length;
|
|
358
|
+
const extra = guidelines ? `\nGuidelines: ${guidelines}` : "";
|
|
359
|
+
|
|
360
|
+
const prompt = `Create a launch video structure that wraps ${clipCount} browser demo clips.
|
|
361
|
+
The browser clips are already recorded — you need to create the narrative beats AROUND them.
|
|
362
|
+
|
|
363
|
+
Context: ${context}${extra}
|
|
364
|
+
|
|
365
|
+
Return a JSON array mixing these types:
|
|
366
|
+
1. Text slides: {"label":"...", "statement":"Line one.\\nLine two."}
|
|
367
|
+
2. Panels: {"label":"...", "panels":{"left":{"title":"...", "content":"..."}, "right":{"title":"...", "content":"..."}}}
|
|
368
|
+
3. Trees: {"label":"...", "tree":{"root":"...", "depth":4, "branching":[4,3,2], "nodeLabels":[["child1","child2"]], "outro":"Closing text."}}
|
|
369
|
+
4. Browser clip placeholder: {"_browserClip": true}
|
|
370
|
+
|
|
371
|
+
Structure: Open with a text slide hook, then alternate browser clips with narrative beats, close with a text slide or tree with outro. Use "_browserClip" as a placeholder where each recorded browser clip should go (use exactly ${clipCount} of them).
|
|
372
|
+
|
|
373
|
+
Return ONLY JSON array.`;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const result = parseJSON(claude(prompt, 60000), null);
|
|
377
|
+
if (result && Array.isArray(result)) {
|
|
378
|
+
// Replace _browserClip placeholders with actual browser highlights
|
|
379
|
+
const final = [];
|
|
380
|
+
let clipIdx = 0;
|
|
381
|
+
for (const item of result) {
|
|
382
|
+
if (item._browserClip && clipIdx < browserHighlights.length) {
|
|
383
|
+
final.push(browserHighlights[clipIdx++]);
|
|
384
|
+
} else if (!item._browserClip) {
|
|
385
|
+
final.push(item);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Append any remaining browser clips
|
|
389
|
+
while (clipIdx < browserHighlights.length) {
|
|
390
|
+
final.push(browserHighlights[clipIdx++]);
|
|
391
|
+
}
|
|
392
|
+
return final;
|
|
393
|
+
}
|
|
394
|
+
} catch {}
|
|
395
|
+
|
|
396
|
+
// Fallback: just wrap with a simple text slide
|
|
397
|
+
return [
|
|
398
|
+
{ label: "Intro", statement: context || "Demo" },
|
|
399
|
+
...browserHighlights,
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
|
|
349
403
|
// ── SVG Fallback ───────────────────────────────────────────
|
|
350
404
|
|
|
351
405
|
function escSvg(s) {
|
|
@@ -355,36 +409,35 @@ function escSvg(s) {
|
|
|
355
409
|
function renderSVG(props, output) {
|
|
356
410
|
const FONT = '"SF Mono", "Fira Code", monospace';
|
|
357
411
|
const SANS = '-apple-system, system-ui, sans-serif';
|
|
358
|
-
const W =
|
|
412
|
+
const W = 700;
|
|
359
413
|
const PAD = 32, LINE_H = 22, TERM_PAD = 16, BAR_H = 36, GAP = 28, FS = 13;
|
|
360
414
|
|
|
361
415
|
let y = PAD, blocks = "";
|
|
362
|
-
blocks += `<text x="${W / 2}" y="${y + 28}" font-family="${escSvg(SANS)}" font-size="32" font-weight="800" fill="#
|
|
416
|
+
blocks += `<text x="${W / 2}" y="${y + 28}" font-family="${escSvg(SANS)}" font-size="32" font-weight="800" fill="#111" text-anchor="middle">${escSvg(props.title)}</text>`;
|
|
363
417
|
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="#
|
|
418
|
+
if (props.subtitle) blocks += `<text x="${W / 2}" y="${y - 22}" font-family="${escSvg(SANS)}" font-size="16" fill="#999" text-anchor="middle">${escSvg(props.subtitle)}</text>`;
|
|
365
419
|
|
|
366
420
|
for (const hl of props.highlights) {
|
|
367
421
|
if (!hl.lines?.length) continue;
|
|
368
|
-
|
|
369
|
-
y += 24;
|
|
422
|
+
y += 8;
|
|
370
423
|
const bodyH = TERM_PAD * 2 + hl.lines.length * LINE_H;
|
|
371
|
-
blocks += `<rect x="${PAD}" y="${y}" width="${W - PAD * 2}" height="${BAR_H + bodyH}" rx="
|
|
372
|
-
blocks += `<circle cx="${PAD + 16}" cy="${y + BAR_H / 2}" r="
|
|
373
|
-
blocks += `<rect x="${PAD}" y="${y + BAR_H}" width="${W - PAD * 2}" height="${bodyH}" fill="#
|
|
424
|
+
blocks += `<rect x="${PAD}" y="${y}" width="${W - PAD * 2}" height="${BAR_H + bodyH}" rx="12" fill="#f0f0f0"/>`;
|
|
425
|
+
blocks += `<circle cx="${PAD + 16}" cy="${y + BAR_H / 2}" r="4" fill="rgba(0,0,0,0.06)"/><circle cx="${PAD + 30}" cy="${y + BAR_H / 2}" r="4" fill="rgba(0,0,0,0.06)"/><circle cx="${PAD + 44}" cy="${y + BAR_H / 2}" r="4" fill="rgba(0,0,0,0.06)"/>`;
|
|
426
|
+
blocks += `<rect x="${PAD}" y="${y + BAR_H}" width="${W - PAD * 2}" height="${bodyH}" fill="#f8f8f8" rx="0"/>`;
|
|
374
427
|
let ly = y + BAR_H + TERM_PAD;
|
|
375
428
|
for (const line of hl.lines) {
|
|
376
|
-
const color = line.dim ? "#
|
|
377
|
-
const prefix = line.isPrompt ? `<tspan fill="#
|
|
429
|
+
const color = line.dim ? "#9ca3af" : line.color || "#1a1a1a";
|
|
430
|
+
const prefix = line.isPrompt ? `<tspan fill="#16a34a">$ </tspan>` : "";
|
|
378
431
|
const text = line.isPrompt ? line.text.replace(/^\$\s*/, "") : line.text;
|
|
379
432
|
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
433
|
ly += LINE_H;
|
|
381
434
|
}
|
|
382
435
|
y += BAR_H + bodyH + GAP;
|
|
383
436
|
}
|
|
384
|
-
if (props.endUrl) { blocks += `<text x="${W / 2}" y="${y + 16}" font-family="${escSvg(SANS)}" font-size="14" fill="#
|
|
437
|
+
if (props.endUrl) { blocks += `<text x="${W / 2}" y="${y + 16}" font-family="${escSvg(SANS)}" font-size="14" fill="#999" text-anchor="middle">${escSvg(props.endUrl)}</text>`; y += 28; }
|
|
385
438
|
y += PAD;
|
|
386
439
|
|
|
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="#
|
|
440
|
+
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="#fff"/>${blocks}</svg>`);
|
|
388
441
|
console.error(`\nDone: ${output} (SVG)`);
|
|
389
442
|
}
|
|
390
443
|
|
|
@@ -439,87 +492,18 @@ async function shareFlow(outputPath, title, desc) {
|
|
|
439
492
|
catch { console.error(` Link: ${intentURL}`); }
|
|
440
493
|
}
|
|
441
494
|
|
|
442
|
-
// ── Dev Server ─────────────────────────────────────────────
|
|
443
|
-
|
|
444
|
-
function startDevServer(command) {
|
|
445
|
-
console.error(` Starting: ${command}`);
|
|
446
|
-
const proc = spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
447
|
-
return new Promise((resolve, reject) => {
|
|
448
|
-
const timeout = setTimeout(() => resolve(proc), 30000);
|
|
449
|
-
const onData = (d) => {
|
|
450
|
-
if (/localhost|ready|started|listening|compiled/i.test(d.toString())) {
|
|
451
|
-
clearTimeout(timeout);
|
|
452
|
-
setTimeout(() => resolve(proc), 2000);
|
|
453
|
-
}
|
|
454
|
-
};
|
|
455
|
-
proc.stdout.on("data", onData);
|
|
456
|
-
proc.stderr.on("data", onData);
|
|
457
|
-
proc.on("error", e => { clearTimeout(timeout); reject(e); });
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function stopDevServer(proc) {
|
|
462
|
-
if (!proc?.killed) try { process.kill(-proc.pid, "SIGTERM"); } catch { try { proc.kill(); } catch {} }
|
|
463
|
-
}
|
|
464
|
-
|
|
465
495
|
// ── Main ───────────────────────────────────────────────────
|
|
466
496
|
|
|
467
497
|
async function main() {
|
|
468
498
|
const flags = parseArgs();
|
|
469
499
|
const output = flags.output || "agentreel.mp4";
|
|
470
500
|
|
|
471
|
-
if (!flags.cmd && !flags.url
|
|
472
|
-
console.error("Please provide --
|
|
501
|
+
if (!flags.cmd && !flags.url) {
|
|
502
|
+
console.error("Please provide --cmd or --url.\n");
|
|
473
503
|
printUsage();
|
|
474
504
|
process.exit(1);
|
|
475
505
|
}
|
|
476
506
|
|
|
477
|
-
// ── PR mode ──────────────────────────────────────────
|
|
478
|
-
if (flags.pr) {
|
|
479
|
-
console.error("Fetching PR context...");
|
|
480
|
-
const pr = fetchPRContext(flags.pr);
|
|
481
|
-
console.error(` PR #${pr.number}: ${pr.title}`);
|
|
482
|
-
|
|
483
|
-
console.error("Planning demo...");
|
|
484
|
-
const plan = planDemoFromPR(pr, flags.guidelines);
|
|
485
|
-
console.error(` Type: ${plan.type}, "${plan.description}"`);
|
|
486
|
-
|
|
487
|
-
const title = flags.title || plan.title || pr.title;
|
|
488
|
-
const subtitle = flags.subtitle || plan.description;
|
|
489
|
-
const demoGuidelines = `[demo] ${plan.guidelines || ""}`.trim();
|
|
490
|
-
|
|
491
|
-
if (plan.type === "browser") {
|
|
492
|
-
let serverProc = null;
|
|
493
|
-
try {
|
|
494
|
-
if (flags.start) serverProc = await startDevServer(flags.start);
|
|
495
|
-
await ensurePlaywright();
|
|
496
|
-
console.error("Step 1/3: Recording browser demo...");
|
|
497
|
-
const { videoPath, clicks } = await recordBrowser(plan.url || "http://localhost:3000", demoGuidelines, flags.auth, demoGuidelines);
|
|
498
|
-
const publicDir = join(ROOT, "public");
|
|
499
|
-
if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
|
|
500
|
-
copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
|
|
501
|
-
console.error("Step 2/3: Building highlights...");
|
|
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); }
|
|
506
|
-
} else {
|
|
507
|
-
if (!plan.command) { console.error("Error: could not determine command to demo."); process.exit(1); }
|
|
508
|
-
console.error("Step 1/3: Recording CLI demo...");
|
|
509
|
-
const steps = planDemoSteps(plan.command, plan.description, demoGuidelines);
|
|
510
|
-
console.error(` ${steps.length} steps planned`);
|
|
511
|
-
const outputs = executeSteps(steps, process.cwd());
|
|
512
|
-
console.error("Step 2/3: Extracting highlights...");
|
|
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);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (!flags.noShare) await shareFlow(resolve(output), title, plan.description);
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
507
|
// ── CLI mode ─────────────────────────────────────────
|
|
524
508
|
if (flags.cmd) {
|
|
525
509
|
const title = flags.title || flags.cmd;
|
|
@@ -528,7 +512,7 @@ async function main() {
|
|
|
528
512
|
console.error(` ${steps.length} steps planned`);
|
|
529
513
|
const outputs = executeSteps(steps, process.cwd());
|
|
530
514
|
console.error("Step 2/3: Extracting highlights...");
|
|
531
|
-
const highlights = extractHighlights(outputs, flags.cmd, flags.guidelines
|
|
515
|
+
const highlights = extractHighlights(outputs, flags.cmd, flags.guidelines);
|
|
532
516
|
console.error(` ${highlights.length} highlights`);
|
|
533
517
|
console.error("Step 3/3: Rendering...");
|
|
534
518
|
await render({ title, highlights, endText: flags.cmd }, output, flags.music);
|
|
@@ -538,17 +522,20 @@ async function main() {
|
|
|
538
522
|
|
|
539
523
|
// ── Browser mode ─────────────────────────────────────
|
|
540
524
|
if (flags.url) {
|
|
541
|
-
const title = flags.title || flags.url;
|
|
542
525
|
await ensurePlaywright();
|
|
543
|
-
console.error("Step 1/
|
|
544
|
-
const { videoPath, clicks } = await recordBrowser(flags.url,
|
|
526
|
+
console.error("Step 1/4: Recording browser demo...");
|
|
527
|
+
const { videoPath, clicks, siteContext } = await recordBrowser(flags.url, flags.auth, flags.guidelines);
|
|
545
528
|
const publicDir = join(ROOT, "public");
|
|
546
529
|
if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
|
|
547
530
|
copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
|
|
548
|
-
console.error("Step 2/
|
|
549
|
-
const
|
|
550
|
-
console.error("Step 3/
|
|
551
|
-
|
|
531
|
+
console.error("Step 2/4: Building browser clips...");
|
|
532
|
+
const browserClips = buildBrowserHighlights(clicks, siteContext, flags.guidelines);
|
|
533
|
+
console.error("Step 3/4: Generating launch video...");
|
|
534
|
+
const highlights = wrapBrowserHighlights(browserClips, siteContext, flags.guidelines);
|
|
535
|
+
console.error(` ${highlights.length} highlights (${browserClips.length} browser clips + narrative beats)`);
|
|
536
|
+
const title = flags.title || siteContext.split("\n")[1]?.replace("Title: ", "") || flags.url;
|
|
537
|
+
console.error("Step 4/4: Rendering...");
|
|
538
|
+
await render({ title, subtitle: flags.subtitle, highlights, endText: flags.url, endUrl: flags.url }, output, flags.music);
|
|
552
539
|
if (!flags.noShare) await shareFlow(resolve(output), title, flags.url);
|
|
553
540
|
}
|
|
554
541
|
}
|
package/package.json
CHANGED
|
Binary file
|
package/public/music.mp3
CHANGED
|
Binary file
|