agentreel 0.3.5 → 0.4.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 +49 -23
- package/bin/agentreel.mjs +259 -34
- package/package.json +1 -1
- package/scripts/cli_demo.py +24 -2
- package/src/CastVideo.tsx +187 -215
- package/src/Root.tsx +18 -7
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# agentreel
|
|
2
2
|
|
|
3
|
-
Turn your web apps and CLIs into viral clips.
|
|
3
|
+
Turn your web apps and CLIs into viral clips — or demo what a PR does.
|
|
4
4
|
|
|
5
5
|
https://github.com/user-attachments/assets/474fd85d-3b35-48f4-82b8-1b337840fb51
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> Turn on sound
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -15,46 +15,72 @@ npx agentreel
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
+
# Demo a PR (reads context from GitHub):
|
|
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
|
+
|
|
18
26
|
# CLI demo:
|
|
19
27
|
agentreel --cmd "npx my-cli-tool"
|
|
20
28
|
|
|
21
29
|
# Browser demo:
|
|
22
30
|
agentreel --url http://localhost:3000
|
|
23
|
-
|
|
24
|
-
# With context for smarter demo planning:
|
|
25
|
-
agentreel --cmd "npx my-tool" --prompt "A CLI that manages cron jobs"
|
|
26
31
|
```
|
|
27
32
|
|
|
28
|
-
##
|
|
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.
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
5. Prompts you to share on Twitter
|
|
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
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
Requires [`gh` CLI](https://cli.github.com) to be installed and authenticated.
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
- **Title card** with your project name
|
|
40
|
-
- **Highlight clips** — terminal or browser window on animated gradient
|
|
41
|
-
- **Text overlays** — bold captions that work on mute
|
|
42
|
-
- **Cursor + typing** — looks like someone's actually using it
|
|
43
|
-
- **Background music** with fade in/out
|
|
44
|
-
- **End CTA** — install command + URL
|
|
46
|
+
### Marketing reel (`--cmd` / `--url`)
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
The original mode — creates a short, polished clip for social media.
|
|
47
49
|
|
|
48
|
-
|
|
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
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
1. **PR mode**: Fetches PR diff + README from GitHub, AI decides CLI or browser demo, plans steps that show the actual changes
|
|
58
|
+
2. **Manual mode**: You provide a CLI command or URL, AI plans an impressive demo
|
|
59
|
+
3. AI executes the demo (terminal PTY or Playwright browser)
|
|
60
|
+
4. AI picks the best moments as highlights
|
|
61
|
+
5. Renders video with Remotion
|
|
62
|
+
|
|
63
|
+
## Flags
|
|
64
|
+
|
|
65
|
+
```
|
|
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
|
+
-c, --cmd <command> CLI command to demo
|
|
69
|
+
-u, --url <url> URL to demo (browser mode)
|
|
70
|
+
-t, --title <text> video title
|
|
71
|
+
-o, --output <file> output file (default: agentreel.mp4)
|
|
72
|
+
-a, --auth <file> Playwright storage state (cookies/auth) for browser demos
|
|
73
|
+
-g, --guidelines <text> guidelines for highlight generation
|
|
74
|
+
--music <file> path to background music mp3
|
|
75
|
+
--no-share skip the share prompt
|
|
76
|
+
```
|
|
52
77
|
|
|
53
78
|
## Requirements
|
|
54
79
|
|
|
55
80
|
- Node.js 18+
|
|
56
81
|
- Python 3.10+
|
|
57
82
|
- Claude CLI (`claude`) — used to plan demo sequences
|
|
83
|
+
- `gh` CLI — required for `--pr` mode
|
|
58
84
|
|
|
59
85
|
## Credits
|
|
60
86
|
|
package/bin/agentreel.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { readFileSync, statSync, existsSync, mkdirSync, copyFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname, resolve } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -25,7 +25,8 @@ function parseArgs() {
|
|
|
25
25
|
}
|
|
26
26
|
if (arg === "--cmd" || arg === "-c") flags.cmd = args[++i];
|
|
27
27
|
else if (arg === "--url" || arg === "-u") flags.url = args[++i];
|
|
28
|
-
else if (arg === "--
|
|
28
|
+
else if (arg === "--pr") flags.pr = args[++i];
|
|
29
|
+
else if (arg === "--start") flags.start = args[++i];
|
|
29
30
|
else if (arg === "--title" || arg === "-t") flags.title = args[++i];
|
|
30
31
|
else if (arg === "--output" || arg === "-o") flags.output = args[++i];
|
|
31
32
|
else if (arg === "--music") flags.music = args[++i];
|
|
@@ -40,13 +41,16 @@ function printUsage() {
|
|
|
40
41
|
console.log(`agentreel — Turn your web apps and CLIs into viral clips
|
|
41
42
|
|
|
42
43
|
Usage:
|
|
44
|
+
agentreel --pr 123 # demo a PR (reads context from GitHub)
|
|
45
|
+
agentreel --pr owner/repo#123 # demo a PR (explicit repo)
|
|
43
46
|
agentreel --cmd "npx my-cli-tool" # CLI demo
|
|
44
47
|
agentreel --url http://localhost:3000 # browser demo
|
|
45
48
|
|
|
46
49
|
Flags:
|
|
50
|
+
--pr <ref> PR number, owner/repo#N, or full GitHub URL
|
|
51
|
+
--start <cmd> command to start a dev server (for browser PR demos)
|
|
47
52
|
-c, --cmd <command> CLI command to demo
|
|
48
53
|
-u, --url <url> URL to demo (browser mode)
|
|
49
|
-
-p, --prompt <text> description of what the tool does
|
|
50
54
|
-t, --title <text> video title
|
|
51
55
|
-o, --output <file> output file (default: agentreel.mp4)
|
|
52
56
|
-a, --auth <file> Playwright storage state (cookies/auth) for browser demos
|
|
@@ -399,6 +403,152 @@ function autoDescribe(cmd, url) {
|
|
|
399
403
|
return cmd ? cmd.split(/\s+/).pop() : "Web app demo";
|
|
400
404
|
}
|
|
401
405
|
|
|
406
|
+
// ── PR Context ─────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
function fetchPRContext(prRef) {
|
|
409
|
+
try {
|
|
410
|
+
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
|
411
|
+
} catch {
|
|
412
|
+
console.error("Error: `gh` CLI is required for --pr mode. Install it from https://cli.github.com");
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const prJson = execFileSync("gh", [
|
|
417
|
+
"pr", "view", String(prRef),
|
|
418
|
+
"--json", "title,body,headRefName,baseRefName,url,number",
|
|
419
|
+
], { encoding: "utf-8", timeout: 30000 });
|
|
420
|
+
const pr = JSON.parse(prJson);
|
|
421
|
+
|
|
422
|
+
let diff = "";
|
|
423
|
+
try {
|
|
424
|
+
diff = execFileSync("gh", ["pr", "diff", String(prRef)], {
|
|
425
|
+
encoding: "utf-8", timeout: 30000,
|
|
426
|
+
});
|
|
427
|
+
} catch (e) {
|
|
428
|
+
console.error(` Warning: could not fetch PR diff: ${e.message}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Read README from cwd (the agent already has the repo checked out)
|
|
432
|
+
let readme = "";
|
|
433
|
+
for (const name of ["README.md", "readme.md", "README", "README.rst"]) {
|
|
434
|
+
const p = join(process.cwd(), name);
|
|
435
|
+
if (existsSync(p)) {
|
|
436
|
+
readme = readFileSync(p, "utf-8");
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { ...pr, diff, readme };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function planDemoFromPR(prContext, guidelines) {
|
|
445
|
+
const guidelinesBlock = guidelines
|
|
446
|
+
? `\nAdditional guidelines: ${guidelines}`
|
|
447
|
+
: "";
|
|
448
|
+
|
|
449
|
+
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.
|
|
450
|
+
|
|
451
|
+
PR Title: ${prContext.title}
|
|
452
|
+
PR Description: ${prContext.body || "(no description)"}
|
|
453
|
+
|
|
454
|
+
Diff (truncated):
|
|
455
|
+
${prContext.diff.slice(0, 8000)}
|
|
456
|
+
|
|
457
|
+
README (truncated):
|
|
458
|
+
${prContext.readme.slice(0, 3000)}${guidelinesBlock}
|
|
459
|
+
|
|
460
|
+
Return a JSON object with these fields:
|
|
461
|
+
{
|
|
462
|
+
"type": "cli" or "browser",
|
|
463
|
+
"command": "the command to run" (for CLI demos, e.g. "npx my-tool --help") or null,
|
|
464
|
+
"url": "http://localhost:3000/relevant-page" (for browser demos) or null,
|
|
465
|
+
"description": "one-sentence summary of what the PR does",
|
|
466
|
+
"title": "short video title (2-4 words)",
|
|
467
|
+
"guidelines": "specific instructions for the demo recorder about what steps to show and what to focus on"
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
Rules:
|
|
471
|
+
- If the PR changes a CLI tool, script, or backend logic that can be demonstrated in a terminal, use "cli".
|
|
472
|
+
- If the PR changes a web UI, frontend, or something best shown in a browser, use "browser".
|
|
473
|
+
- The "guidelines" field should tell the demo recorder exactly what to demonstrate — the specific feature or fix from this PR.
|
|
474
|
+
- The demo should show the actual changes working honestly, not market the product.
|
|
475
|
+
- Return ONLY the JSON object, no markdown fences.`;
|
|
476
|
+
|
|
477
|
+
const result = execFileSync("claude", ["-p", prompt, "--output-format", "text"], {
|
|
478
|
+
encoding: "utf-8",
|
|
479
|
+
timeout: 60000,
|
|
480
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
481
|
+
}).trim();
|
|
482
|
+
|
|
483
|
+
// Strip markdown fences if present
|
|
484
|
+
let text = result;
|
|
485
|
+
if (text.includes("```")) {
|
|
486
|
+
const parts = text.split("```");
|
|
487
|
+
for (let part of parts) {
|
|
488
|
+
part = part.trim();
|
|
489
|
+
if (part.startsWith("json")) part = part.slice(4).trim();
|
|
490
|
+
if (part.startsWith("{")) { text = part; break; }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return JSON.parse(text);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── Dev Server ─────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
function startDevServer(command) {
|
|
500
|
+
console.error(` Starting dev server: ${command}`);
|
|
501
|
+
const proc = spawn("sh", ["-c", command], {
|
|
502
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
503
|
+
detached: true,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Wait for server to be ready (look for common ready signals in output)
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
const timeout = setTimeout(() => {
|
|
509
|
+
console.error(" Dev server ready (timeout — assuming started)");
|
|
510
|
+
resolve(proc);
|
|
511
|
+
}, 30000);
|
|
512
|
+
|
|
513
|
+
const onData = (data) => {
|
|
514
|
+
const text = data.toString();
|
|
515
|
+
if (/localhost|ready|started|listening|compiled/i.test(text)) {
|
|
516
|
+
clearTimeout(timeout);
|
|
517
|
+
// Give it a moment to fully start
|
|
518
|
+
setTimeout(() => {
|
|
519
|
+
console.error(" Dev server ready");
|
|
520
|
+
resolve(proc);
|
|
521
|
+
}, 2000);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
proc.stdout.on("data", onData);
|
|
526
|
+
proc.stderr.on("data", onData);
|
|
527
|
+
|
|
528
|
+
proc.on("error", (err) => {
|
|
529
|
+
clearTimeout(timeout);
|
|
530
|
+
reject(new Error(`Dev server failed to start: ${err.message}`));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
proc.on("exit", (code) => {
|
|
534
|
+
clearTimeout(timeout);
|
|
535
|
+
if (code !== null && code !== 0) {
|
|
536
|
+
reject(new Error(`Dev server exited with code ${code}`));
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function stopDevServer(proc) {
|
|
543
|
+
if (!proc || proc.killed) return;
|
|
544
|
+
try {
|
|
545
|
+
// Kill the process group (detached process + children)
|
|
546
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
547
|
+
} catch {
|
|
548
|
+
try { proc.kill("SIGTERM"); } catch { /* already dead */ }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
402
552
|
// ── Main ────────────────────────────────────────────────────
|
|
403
553
|
|
|
404
554
|
async function main() {
|
|
@@ -406,83 +556,158 @@ async function main() {
|
|
|
406
556
|
const output = flags.output || "agentreel.mp4";
|
|
407
557
|
const noShare = flags.noShare;
|
|
408
558
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
let prompt = flags.prompt;
|
|
412
|
-
|
|
413
|
-
// Auto-generate description if not provided
|
|
414
|
-
if (!prompt) {
|
|
415
|
-
console.error("Generating description...");
|
|
416
|
-
prompt = autoDescribe(demoCmd, demoURL);
|
|
417
|
-
console.error(` "${prompt}"`);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (!demoCmd && !demoURL) {
|
|
421
|
-
console.error("Please provide --cmd or --url.\n");
|
|
559
|
+
if (!flags.cmd && !flags.url && !flags.pr) {
|
|
560
|
+
console.error("Please provide --pr, --cmd, or --url.\n");
|
|
422
561
|
printUsage();
|
|
423
562
|
process.exit(1);
|
|
424
563
|
}
|
|
425
564
|
|
|
426
|
-
|
|
565
|
+
// ── PR mode ──────────────────────────────────────────────
|
|
566
|
+
if (flags.pr) {
|
|
567
|
+
console.error("Fetching PR context...");
|
|
568
|
+
const prContext = fetchPRContext(flags.pr);
|
|
569
|
+
console.error(` PR #${prContext.number}: ${prContext.title}`);
|
|
570
|
+
|
|
571
|
+
console.error("Planning demo...");
|
|
572
|
+
const plan = planDemoFromPR(prContext, flags.guidelines);
|
|
573
|
+
console.error(` Type: ${plan.type}, "${plan.description}"`);
|
|
574
|
+
|
|
575
|
+
const videoTitle = flags.title || plan.title || prContext.title;
|
|
576
|
+
const description = plan.description;
|
|
577
|
+
// Prepend "demo" to guidelines so downstream scripts know to use chapter-based extraction
|
|
578
|
+
const demoGuidelines = `[demo] ${plan.guidelines || ""}`.trim();
|
|
579
|
+
|
|
580
|
+
if (plan.type === "browser") {
|
|
581
|
+
const url = plan.url || "http://localhost:3000";
|
|
582
|
+
let serverProc = null;
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
if (flags.start) {
|
|
586
|
+
serverProc = await startDevServer(flags.start);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
ensureBrowserDeps();
|
|
590
|
+
console.error("Step 1/3: Recording browser demo...");
|
|
591
|
+
const videoPath = recordBrowser(url, demoGuidelines, flags.auth, demoGuidelines);
|
|
592
|
+
|
|
593
|
+
const publicDir = join(ROOT, "public");
|
|
594
|
+
if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
|
|
595
|
+
copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
|
|
596
|
+
|
|
597
|
+
console.error("Step 2/3: Building highlights...");
|
|
598
|
+
const clicksPath = videoPath.replace(".mp4", "-clicks.json");
|
|
599
|
+
let allClicks = [];
|
|
600
|
+
if (existsSync(clicksPath)) {
|
|
601
|
+
allClicks = JSON.parse(readFileSync(clicksPath, "utf-8"));
|
|
602
|
+
console.error(` ${allClicks.length} clicks captured`);
|
|
603
|
+
}
|
|
604
|
+
const highlights = buildBrowserHighlights(allClicks, videoPath, demoGuidelines, demoGuidelines);
|
|
605
|
+
|
|
606
|
+
console.error("Step 3/3: Rendering video...");
|
|
607
|
+
await renderVideo({
|
|
608
|
+
title: videoTitle,
|
|
609
|
+
subtitle: description,
|
|
610
|
+
highlights,
|
|
611
|
+
endText: prContext.title,
|
|
612
|
+
endUrl: prContext.url,
|
|
613
|
+
mode: "demo",
|
|
614
|
+
}, output, flags.music);
|
|
615
|
+
} finally {
|
|
616
|
+
stopDevServer(serverProc);
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
// CLI demo
|
|
620
|
+
if (!plan.command) {
|
|
621
|
+
console.error("Error: Claude could not determine a command to demo for this PR.");
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
console.error("Step 1/3: Recording CLI demo...");
|
|
626
|
+
const castPath = recordCLI(plan.command, process.cwd(), description, demoGuidelines);
|
|
627
|
+
|
|
628
|
+
console.error("Step 2/3: Extracting highlights...");
|
|
629
|
+
const highlightsPath = extractHighlightsFromCast(castPath, description, demoGuidelines);
|
|
630
|
+
const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
|
|
631
|
+
console.error(` ${highlights.length} highlights extracted`);
|
|
632
|
+
|
|
633
|
+
console.error("Step 3/3: Rendering video...");
|
|
634
|
+
await renderVideo({
|
|
635
|
+
title: videoTitle,
|
|
636
|
+
subtitle: description,
|
|
637
|
+
highlights,
|
|
638
|
+
endText: plan.command,
|
|
639
|
+
endUrl: prContext.url,
|
|
640
|
+
mode: "demo",
|
|
641
|
+
}, output, flags.music);
|
|
642
|
+
}
|
|
427
643
|
|
|
428
|
-
|
|
644
|
+
if (!noShare) {
|
|
645
|
+
await shareFlow(resolve(output), videoTitle, description);
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── Manual modes (--cmd / --url) ─────────────────────────
|
|
651
|
+
console.error("Generating description...");
|
|
652
|
+
const description = autoDescribe(flags.cmd, flags.url);
|
|
653
|
+
console.error(` "${description}"`);
|
|
654
|
+
|
|
655
|
+
let videoTitle = flags.title || flags.cmd || flags.url;
|
|
656
|
+
|
|
657
|
+
if (flags.cmd) {
|
|
429
658
|
console.error("Step 1/3: Recording CLI demo...");
|
|
430
|
-
const castPath = recordCLI(
|
|
659
|
+
const castPath = recordCLI(flags.cmd, process.cwd(), description, flags.guidelines);
|
|
431
660
|
|
|
432
661
|
console.error("Step 2/3: Extracting highlights...");
|
|
433
|
-
const highlightsPath = extractHighlightsFromCast(castPath,
|
|
662
|
+
const highlightsPath = extractHighlightsFromCast(castPath, description, flags.guidelines);
|
|
434
663
|
const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
|
|
435
664
|
console.error(` ${highlights.length} highlights extracted`);
|
|
436
665
|
|
|
437
666
|
console.error("Step 3/3: Rendering video...");
|
|
438
667
|
await renderVideo({
|
|
439
668
|
title: videoTitle,
|
|
440
|
-
subtitle:
|
|
669
|
+
subtitle: description,
|
|
441
670
|
highlights,
|
|
442
|
-
endText:
|
|
671
|
+
endText: flags.cmd,
|
|
443
672
|
}, output, flags.music);
|
|
444
673
|
|
|
445
674
|
if (!noShare) {
|
|
446
|
-
await shareFlow(resolve(output), videoTitle,
|
|
675
|
+
await shareFlow(resolve(output), videoTitle, description);
|
|
447
676
|
}
|
|
448
677
|
return;
|
|
449
678
|
}
|
|
450
679
|
|
|
451
|
-
if (
|
|
452
|
-
const task =
|
|
680
|
+
if (flags.url) {
|
|
681
|
+
const task = description || "Explore the main features of this app";
|
|
453
682
|
|
|
454
683
|
ensureBrowserDeps();
|
|
455
684
|
console.error("Step 1/3: Recording browser demo...");
|
|
456
|
-
const videoPath = recordBrowser(
|
|
685
|
+
const videoPath = recordBrowser(flags.url, task, flags.auth, flags.guidelines);
|
|
457
686
|
|
|
458
|
-
// Copy video to Remotion public dir so it can be served
|
|
459
687
|
const publicDir = join(ROOT, "public");
|
|
460
688
|
if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
|
|
461
689
|
copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
|
|
462
690
|
|
|
463
691
|
console.error("Step 2/3: Building highlights...");
|
|
464
|
-
|
|
465
|
-
// Read click data — this is the primary signal for highlights
|
|
466
692
|
const clicksPath = videoPath.replace(".mp4", "-clicks.json");
|
|
467
693
|
let allClicks = [];
|
|
468
694
|
if (existsSync(clicksPath)) {
|
|
469
695
|
allClicks = JSON.parse(readFileSync(clicksPath, "utf-8"));
|
|
470
696
|
console.error(` ${allClicks.length} clicks captured`);
|
|
471
697
|
}
|
|
472
|
-
|
|
473
698
|
const highlights = buildBrowserHighlights(allClicks, videoPath, task, flags.guidelines);
|
|
474
699
|
|
|
475
700
|
console.error("Step 3/3: Rendering video...");
|
|
476
701
|
await renderVideo({
|
|
477
702
|
title: videoTitle,
|
|
478
|
-
subtitle:
|
|
703
|
+
subtitle: description,
|
|
479
704
|
highlights,
|
|
480
|
-
endText:
|
|
481
|
-
endUrl:
|
|
705
|
+
endText: flags.url,
|
|
706
|
+
endUrl: flags.url,
|
|
482
707
|
}, output, flags.music);
|
|
483
708
|
|
|
484
709
|
if (!noShare) {
|
|
485
|
-
await shareFlow(resolve(output), videoTitle,
|
|
710
|
+
await shareFlow(resolve(output), videoTitle, description);
|
|
486
711
|
}
|
|
487
712
|
return;
|
|
488
713
|
}
|
package/package.json
CHANGED
package/scripts/cli_demo.py
CHANGED
|
@@ -198,7 +198,7 @@ def record_demo(steps: list[dict], workdir: str, output_path: str):
|
|
|
198
198
|
|
|
199
199
|
|
|
200
200
|
def extract_highlights(cast_path: str, context: str, guidelines: str = "") -> list[dict]:
|
|
201
|
-
"""Ask Claude to pick
|
|
201
|
+
"""Ask Claude to pick highlight moments from the recorded session."""
|
|
202
202
|
# Read the asciicast and strip to just the text content
|
|
203
203
|
lines_output = []
|
|
204
204
|
with open(cast_path) as f:
|
|
@@ -218,7 +218,29 @@ def extract_highlights(cast_path: str, context: str, guidelines: str = "") -> li
|
|
|
218
218
|
|
|
219
219
|
guidelines_block = f"\n\nAdditional guidelines: {guidelines}" if guidelines else ""
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
# Demo mode: more chapters, more lines, show full flows
|
|
222
|
+
is_demo = "demo" in guidelines.lower() if guidelines else False
|
|
223
|
+
|
|
224
|
+
if is_demo:
|
|
225
|
+
prompt = f"""You are creating chapter-based highlights for a demo walkthrough video. Here is the full terminal output:
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
{clean[:6000]}
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
Context: {context}{guidelines_block}
|
|
232
|
+
|
|
233
|
+
Create 4-6 chapters that walk through the full demo. Each chapter shows a complete command and its output. For each chapter, return:
|
|
234
|
+
- "label": chapter name (1-3 words) like "Setup", "Run Command", "View Results", "Verify"
|
|
235
|
+
- "lines": array of objects, each with "text" (string), and optionally "color" (hex), "bold" (bool), "dim" (bool), "isPrompt" (bool if it's a shell command)
|
|
236
|
+
|
|
237
|
+
Each chapter should have 12-20 lines. Show the COMPLETE command and output for each step.
|
|
238
|
+
Include the prompt line (isPrompt: true) followed by the actual output.
|
|
239
|
+
Use these colors: green="#50fa7b", yellow="#f1fa8c", purple="#bd93f9", red="#ff5555", dim="#6272a4", white="#f8f8f2"
|
|
240
|
+
|
|
241
|
+
Return ONLY a JSON array. No markdown fences."""
|
|
242
|
+
else:
|
|
243
|
+
prompt = f"""You are creating a highlights reel for a CLI tool demo video. Here is the full terminal output:
|
|
222
244
|
|
|
223
245
|
---
|
|
224
246
|
{clean[:3000]}
|
package/src/CastVideo.tsx
CHANGED
|
@@ -20,19 +20,18 @@ const TERM_BG = "#282a36";
|
|
|
20
20
|
const TITLE_BAR = "#1e1f29";
|
|
21
21
|
const CURSOR_COLOR = "#f8f8f2";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const TRANSITION_DUR = 0.5;
|
|
27
|
-
const END_DUR = 3.5;
|
|
23
|
+
// Mode-specific timing (synced with Root.tsx calculateMetadata)
|
|
24
|
+
const REEL_TIMING = { title: 2.5, termHighlight: 4.5, browserHighlight: 7.0, transition: 0.5, end: 3.5 };
|
|
25
|
+
const DEMO_TIMING = { title: 2.0, termHighlight: 12.0, browserHighlight: 10.0, transition: 0.4, end: 3.0 };
|
|
28
26
|
|
|
29
27
|
const VIEWPORT_W = 1280;
|
|
30
28
|
const VIEWPORT_H = 800;
|
|
31
29
|
const VIDEO_AREA_W = 880;
|
|
32
30
|
const VIDEO_AREA_H = 550; // 880 * 10/16
|
|
33
31
|
|
|
34
|
-
function getHighlightDuration(h: Highlight): number {
|
|
35
|
-
|
|
32
|
+
function getHighlightDuration(h: Highlight, mode: "reel" | "demo" = "reel"): number {
|
|
33
|
+
const t = mode === "demo" ? DEMO_TIMING : REEL_TIMING;
|
|
34
|
+
return h.videoSrc ? t.browserHighlight : t.termHighlight;
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
const SANS =
|
|
@@ -87,17 +86,21 @@ export const CastVideo: React.FC<CastProps> = ({
|
|
|
87
86
|
endText,
|
|
88
87
|
endUrl,
|
|
89
88
|
gradient,
|
|
89
|
+
mode: rawMode,
|
|
90
90
|
}) => {
|
|
91
91
|
const frame = useCurrentFrame();
|
|
92
92
|
const { fps, durationInFrames } = useVideoConfig();
|
|
93
|
+
const mode = rawMode || "reel";
|
|
94
|
+
const isDemo = mode === "demo";
|
|
95
|
+
const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
|
|
93
96
|
const g = gradient || ["#0f0f1a", "#1a0f2e"];
|
|
94
97
|
|
|
95
|
-
const titleFrames = Math.round(
|
|
96
|
-
const endFrames = Math.round(
|
|
98
|
+
const titleFrames = Math.round(timing.title * fps);
|
|
99
|
+
const endFrames = Math.round(timing.end * fps);
|
|
97
100
|
|
|
98
101
|
// Compute per-highlight durations and cumulative offsets
|
|
99
102
|
const hlDurations = highlights.map((h) =>
|
|
100
|
-
Math.round(getHighlightDuration(h) * fps)
|
|
103
|
+
Math.round(getHighlightDuration(h, mode) * fps)
|
|
101
104
|
);
|
|
102
105
|
const hlOffsets: number[] = [];
|
|
103
106
|
let cumulative = 0;
|
|
@@ -107,9 +110,11 @@ export const CastVideo: React.FC<CastProps> = ({
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
// Animated gradient — hue rotates slowly over time
|
|
110
|
-
const gradAngle =
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
const gradAngle = isDemo
|
|
114
|
+
? 145 // static angle for demo
|
|
115
|
+
: interpolate(frame, [0, durationInFrames], [125, 200], {
|
|
116
|
+
extrapolateRight: "clamp",
|
|
117
|
+
});
|
|
113
118
|
|
|
114
119
|
return (
|
|
115
120
|
<AbsoluteFill
|
|
@@ -118,17 +123,18 @@ export const CastVideo: React.FC<CastProps> = ({
|
|
|
118
123
|
backgroundSize: "200% 200%",
|
|
119
124
|
}}
|
|
120
125
|
>
|
|
121
|
-
{/* Subtle animated glow blobs
|
|
122
|
-
<AnimatedBackground frame={frame} duration={durationInFrames} />
|
|
126
|
+
{/* Subtle animated glow blobs — reel only */}
|
|
127
|
+
{!isDemo && <AnimatedBackground frame={frame} duration={durationInFrames} />}
|
|
123
128
|
|
|
124
|
-
|
|
129
|
+
{/* Music — reel only */}
|
|
130
|
+
{!isDemo && <MusicTrack />}
|
|
125
131
|
|
|
126
132
|
<Sequence durationInFrames={titleFrames}>
|
|
127
|
-
<TitleCard title={title} subtitle={subtitle} />
|
|
133
|
+
<TitleCard title={title} subtitle={subtitle} isDemo={isDemo} />
|
|
128
134
|
</Sequence>
|
|
129
135
|
|
|
130
136
|
{highlights.map((h, i) => {
|
|
131
|
-
const dur = getHighlightDuration(h);
|
|
137
|
+
const dur = getHighlightDuration(h, mode);
|
|
132
138
|
return (
|
|
133
139
|
<Sequence
|
|
134
140
|
key={i}
|
|
@@ -142,6 +148,7 @@ export const CastVideo: React.FC<CastProps> = ({
|
|
|
142
148
|
total={highlights.length}
|
|
143
149
|
transition={TRANSITIONS[i % TRANSITIONS.length]}
|
|
144
150
|
durationSec={dur}
|
|
151
|
+
isDemo={isDemo}
|
|
145
152
|
/>
|
|
146
153
|
) : (
|
|
147
154
|
<HighlightClip
|
|
@@ -150,6 +157,7 @@ export const CastVideo: React.FC<CastProps> = ({
|
|
|
150
157
|
total={highlights.length}
|
|
151
158
|
transition={TRANSITIONS[i % TRANSITIONS.length]}
|
|
152
159
|
durationSec={dur}
|
|
160
|
+
isDemo={isDemo}
|
|
153
161
|
/>
|
|
154
162
|
)}
|
|
155
163
|
</Sequence>
|
|
@@ -160,7 +168,7 @@ export const CastVideo: React.FC<CastProps> = ({
|
|
|
160
168
|
from={titleFrames + cumulative}
|
|
161
169
|
durationInFrames={endFrames}
|
|
162
170
|
>
|
|
163
|
-
<EndCard text={endText || title} url={endUrl} />
|
|
171
|
+
<EndCard text={endText || title} url={endUrl} isDemo={isDemo} />
|
|
164
172
|
</Sequence>
|
|
165
173
|
</AbsoluteFill>
|
|
166
174
|
);
|
|
@@ -408,12 +416,14 @@ const TextOverlay: React.FC<{ text: string; durationSec: number }> = ({
|
|
|
408
416
|
|
|
409
417
|
// ─── Title Card ───────────────────────────────────────────
|
|
410
418
|
|
|
411
|
-
const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({
|
|
419
|
+
const TitleCard: React.FC<{ title: string; subtitle?: string; isDemo?: boolean }> = ({
|
|
412
420
|
title,
|
|
413
421
|
subtitle,
|
|
422
|
+
isDemo,
|
|
414
423
|
}) => {
|
|
415
424
|
const frame = useCurrentFrame();
|
|
416
425
|
const { fps } = useVideoConfig();
|
|
426
|
+
const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
|
|
417
427
|
|
|
418
428
|
const titleSpring = spring({ fps, frame, config: { damping: 14 } });
|
|
419
429
|
const subSpring = spring({
|
|
@@ -423,11 +433,11 @@ const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({
|
|
|
423
433
|
});
|
|
424
434
|
const fadeOut = interpolate(
|
|
425
435
|
frame,
|
|
426
|
-
[fps * (
|
|
436
|
+
[fps * (timing.title - timing.transition), fps * timing.title],
|
|
427
437
|
[1, 0],
|
|
428
438
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
429
439
|
);
|
|
430
|
-
const titleZoom = interpolate(frame, [0, fps *
|
|
440
|
+
const titleZoom = isDemo ? 1 : interpolate(frame, [0, fps * timing.title], [1, 1.08], {
|
|
431
441
|
extrapolateLeft: "clamp",
|
|
432
442
|
extrapolateRight: "clamp",
|
|
433
443
|
});
|
|
@@ -449,7 +459,7 @@ const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({
|
|
|
449
459
|
<div
|
|
450
460
|
style={{
|
|
451
461
|
fontFamily: SANS,
|
|
452
|
-
fontSize: 76,
|
|
462
|
+
fontSize: isDemo ? 56 : 76,
|
|
453
463
|
fontWeight: 800,
|
|
454
464
|
color: WHITE,
|
|
455
465
|
letterSpacing: -3,
|
|
@@ -463,7 +473,7 @@ const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({
|
|
|
463
473
|
transform: `translateY(${interpolate(subSpring, [0, 1], [20, 0])}px)`,
|
|
464
474
|
opacity: subSpring,
|
|
465
475
|
fontFamily: SANS,
|
|
466
|
-
fontSize: 28,
|
|
476
|
+
fontSize: isDemo ? 24 : 28,
|
|
467
477
|
color: DIM,
|
|
468
478
|
marginTop: 16,
|
|
469
479
|
letterSpacing: 1,
|
|
@@ -485,82 +495,79 @@ const HighlightClip: React.FC<{
|
|
|
485
495
|
total: number;
|
|
486
496
|
transition: TransitionStyle;
|
|
487
497
|
durationSec: number;
|
|
488
|
-
|
|
498
|
+
isDemo?: boolean;
|
|
499
|
+
}> = ({ highlight, index, total, transition, durationSec, isDemo }) => {
|
|
489
500
|
const frame = useCurrentFrame();
|
|
490
|
-
const { fps } = useVideoConfig();
|
|
501
|
+
const { fps, width } = useVideoConfig();
|
|
502
|
+
const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
|
|
491
503
|
|
|
492
|
-
// Entry animation —
|
|
504
|
+
// Entry animation — simpler in demo mode
|
|
493
505
|
const enterSpring = spring({
|
|
494
506
|
fps,
|
|
495
507
|
frame,
|
|
496
|
-
config: { damping: 18, stiffness: 80 },
|
|
508
|
+
config: isDemo ? { damping: 22, stiffness: 120 } : { damping: 18, stiffness: 80 },
|
|
497
509
|
});
|
|
498
|
-
const entry =
|
|
510
|
+
const entry = isDemo
|
|
511
|
+
? { scale: 1, x: 0, y: 0 } // no transform in demo
|
|
512
|
+
: getEntryTransform(transition, enterSpring);
|
|
499
513
|
|
|
500
514
|
// Fade transitions
|
|
501
|
-
const fadeIn = interpolate(frame, [0, fps *
|
|
515
|
+
const fadeIn = interpolate(frame, [0, fps * timing.transition], [0, 1], {
|
|
502
516
|
extrapolateLeft: "clamp",
|
|
503
517
|
extrapolateRight: "clamp",
|
|
504
518
|
});
|
|
505
519
|
const fadeOut = interpolate(
|
|
506
520
|
frame,
|
|
507
|
-
[fps * (durationSec -
|
|
521
|
+
[fps * (durationSec - timing.transition), fps * durationSec],
|
|
508
522
|
[1, 0],
|
|
509
523
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
510
524
|
);
|
|
511
525
|
const opacity = Math.min(fadeIn, fadeOut);
|
|
512
526
|
|
|
513
|
-
// Zoom
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
[1, 1.12],
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
easing: Easing.
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
{
|
|
529
|
-
extrapolateLeft: "clamp",
|
|
530
|
-
extrapolateRight: "clamp",
|
|
531
|
-
easing: Easing.inOut(Easing.cubic),
|
|
532
|
-
}
|
|
533
|
-
);
|
|
534
|
-
const zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
|
|
527
|
+
// Zoom/pan — reel only
|
|
528
|
+
let zoom = 1;
|
|
529
|
+
let panY = 0;
|
|
530
|
+
if (!isDemo) {
|
|
531
|
+
const zoomIn = interpolate(frame, [fps * 0.8, fps * 2.0], [1, 1.12], {
|
|
532
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic),
|
|
533
|
+
});
|
|
534
|
+
const zoomOut = interpolate(frame, [fps * 2.5, fps * (durationSec - 0.5)], [1.12, 1.02], {
|
|
535
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic),
|
|
536
|
+
});
|
|
537
|
+
zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
|
|
538
|
+
panY = interpolate(frame, [fps * 0.8, fps * 2.0, fps * (durationSec - 1.0)], [0, -15, 5], {
|
|
539
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
|
540
|
+
});
|
|
541
|
+
}
|
|
535
542
|
|
|
536
|
-
//
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
[fps * 0.8, fps * 2.0, fps * (durationSec - 1.0)],
|
|
540
|
-
[0, -15, 5],
|
|
541
|
-
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
542
|
-
);
|
|
543
|
+
// Line timing — faster reveal in demo (more lines to show)
|
|
544
|
+
const lineDelay = isDemo ? fps * 0.08 : fps * 0.15;
|
|
545
|
+
const firstLineFrame = isDemo ? fps * 0.25 : fps * 0.35;
|
|
543
546
|
|
|
544
|
-
|
|
545
|
-
const lineDelay = fps * 0.15;
|
|
546
|
-
const firstLineFrame = fps * 0.35;
|
|
547
|
+
const lines = highlight.lines || [];
|
|
547
548
|
|
|
548
549
|
// Cursor tracking
|
|
549
|
-
const lastVisibleLineIdx =
|
|
550
|
+
const lastVisibleLineIdx = lines.findIndex((_, i) => {
|
|
550
551
|
return frame < firstLineFrame + (i + 1) * lineDelay;
|
|
551
552
|
});
|
|
552
553
|
const cursorLineIdx =
|
|
553
554
|
lastVisibleLineIdx === -1
|
|
554
|
-
?
|
|
555
|
+
? lines.length - 1
|
|
555
556
|
: Math.max(0, lastVisibleLineIdx - 1);
|
|
556
557
|
|
|
558
|
+
// Terminal sizing — wider in demo mode (landscape)
|
|
559
|
+
const termWidth = isDemo ? width - 160 : 820;
|
|
560
|
+
const termFontSize = isDemo ? 14 : 16;
|
|
561
|
+
const termMinHeight = isDemo ? 500 : 280;
|
|
562
|
+
const termPadding = isDemo ? "16px 20px" : "20px 24px";
|
|
563
|
+
|
|
557
564
|
return (
|
|
558
565
|
<AbsoluteFill style={{ opacity }}>
|
|
559
|
-
{/*
|
|
566
|
+
{/* Chapter label */}
|
|
560
567
|
<div
|
|
561
568
|
style={{
|
|
562
569
|
position: "absolute",
|
|
563
|
-
top: 45,
|
|
570
|
+
top: isDemo ? 24 : 45,
|
|
564
571
|
left: 0,
|
|
565
572
|
width: "100%",
|
|
566
573
|
textAlign: "center",
|
|
@@ -570,36 +577,31 @@ const HighlightClip: React.FC<{
|
|
|
570
577
|
<span
|
|
571
578
|
style={{
|
|
572
579
|
fontFamily: MONO,
|
|
573
|
-
fontSize: 13,
|
|
580
|
+
fontSize: isDemo ? 15 : 13,
|
|
574
581
|
color: ACCENT,
|
|
575
|
-
letterSpacing: 4,
|
|
582
|
+
letterSpacing: isDemo ? 3 : 4,
|
|
576
583
|
textTransform: "uppercase",
|
|
577
|
-
opacity: interpolate(enterSpring, [0, 1], [0, 0.6]),
|
|
584
|
+
opacity: interpolate(enterSpring, [0, 1], [0, isDemo ? 0.8 : 0.6]),
|
|
578
585
|
}}
|
|
579
586
|
>
|
|
580
587
|
{highlight.label}
|
|
581
588
|
</span>
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
i === index ? ACCENT : "rgba(255,255,255,0.12)",
|
|
599
|
-
}}
|
|
600
|
-
/>
|
|
601
|
-
))}
|
|
602
|
-
</div>
|
|
589
|
+
{/* Progress dots — reel only */}
|
|
590
|
+
{!isDemo && (
|
|
591
|
+
<div style={{ marginTop: 10, display: "flex", justifyContent: "center", gap: 8 }}>
|
|
592
|
+
{Array.from({ length: total }).map((_, i) => (
|
|
593
|
+
<div
|
|
594
|
+
key={i}
|
|
595
|
+
style={{
|
|
596
|
+
width: i === index ? 24 : 8,
|
|
597
|
+
height: 6,
|
|
598
|
+
borderRadius: 3,
|
|
599
|
+
backgroundColor: i === index ? ACCENT : "rgba(255,255,255,0.12)",
|
|
600
|
+
}}
|
|
601
|
+
/>
|
|
602
|
+
))}
|
|
603
|
+
</div>
|
|
604
|
+
)}
|
|
603
605
|
</div>
|
|
604
606
|
|
|
605
607
|
{/* Terminal window */}
|
|
@@ -607,20 +609,23 @@ const HighlightClip: React.FC<{
|
|
|
607
609
|
style={{
|
|
608
610
|
justifyContent: "center",
|
|
609
611
|
alignItems: "center",
|
|
610
|
-
padding: 40,
|
|
611
|
-
paddingTop: 100,
|
|
612
|
-
paddingBottom: 100,
|
|
612
|
+
padding: isDemo ? 20 : 40,
|
|
613
|
+
paddingTop: isDemo ? 60 : 100,
|
|
614
|
+
paddingBottom: isDemo ? 40 : 100,
|
|
613
615
|
}}
|
|
614
616
|
>
|
|
615
617
|
<div
|
|
616
618
|
style={{
|
|
617
|
-
transform:
|
|
619
|
+
transform: isDemo
|
|
620
|
+
? `scale(${enterSpring})`
|
|
621
|
+
: `scale(${entry.scale * zoom}) translate(${entry.x}px, ${entry.y + panY}px)`,
|
|
618
622
|
transformOrigin: "center center",
|
|
619
|
-
width:
|
|
620
|
-
borderRadius: 14,
|
|
623
|
+
width: termWidth,
|
|
624
|
+
borderRadius: isDemo ? 10 : 14,
|
|
621
625
|
overflow: "hidden",
|
|
622
|
-
boxShadow:
|
|
623
|
-
"0
|
|
626
|
+
boxShadow: isDemo
|
|
627
|
+
? "0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04)"
|
|
628
|
+
: "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
|
|
624
629
|
}}
|
|
625
630
|
>
|
|
626
631
|
{/* macOS title bar */}
|
|
@@ -633,30 +638,9 @@ const HighlightClip: React.FC<{
|
|
|
633
638
|
gap: 8,
|
|
634
639
|
}}
|
|
635
640
|
>
|
|
636
|
-
<div
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
height: 12,
|
|
640
|
-
borderRadius: 6,
|
|
641
|
-
backgroundColor: "#ff5555",
|
|
642
|
-
}}
|
|
643
|
-
/>
|
|
644
|
-
<div
|
|
645
|
-
style={{
|
|
646
|
-
width: 12,
|
|
647
|
-
height: 12,
|
|
648
|
-
borderRadius: 6,
|
|
649
|
-
backgroundColor: "#f1fa8c",
|
|
650
|
-
}}
|
|
651
|
-
/>
|
|
652
|
-
<div
|
|
653
|
-
style={{
|
|
654
|
-
width: 12,
|
|
655
|
-
height: 12,
|
|
656
|
-
borderRadius: 6,
|
|
657
|
-
backgroundColor: "#50fa7b",
|
|
658
|
-
}}
|
|
659
|
-
/>
|
|
641
|
+
<div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#ff5555" }} />
|
|
642
|
+
<div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#f1fa8c" }} />
|
|
643
|
+
<div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#50fa7b" }} />
|
|
660
644
|
<div style={{ flex: 1 }} />
|
|
661
645
|
</div>
|
|
662
646
|
|
|
@@ -664,11 +648,11 @@ const HighlightClip: React.FC<{
|
|
|
664
648
|
<div
|
|
665
649
|
style={{
|
|
666
650
|
backgroundColor: TERM_BG,
|
|
667
|
-
padding:
|
|
668
|
-
minHeight:
|
|
651
|
+
padding: termPadding,
|
|
652
|
+
minHeight: termMinHeight,
|
|
669
653
|
}}
|
|
670
654
|
>
|
|
671
|
-
{
|
|
655
|
+
{lines.map((line, lineIdx) => {
|
|
672
656
|
const lineFrame = firstLineFrame + lineIdx * lineDelay;
|
|
673
657
|
const lineSpring = spring({
|
|
674
658
|
fps,
|
|
@@ -676,19 +660,18 @@ const HighlightClip: React.FC<{
|
|
|
676
660
|
config: { damping: 20, stiffness: 120 },
|
|
677
661
|
});
|
|
678
662
|
const lineOpacity = interpolate(lineSpring, [0, 1], [0, 1]);
|
|
679
|
-
const lineX = interpolate(lineSpring, [0, 1], [12, 0]);
|
|
663
|
+
const lineX = isDemo ? 0 : interpolate(lineSpring, [0, 1], [12, 0]);
|
|
680
664
|
|
|
681
665
|
// Strip leading "$ " from text — renderer adds its own $ prefix
|
|
682
666
|
const cleanText = line.isPrompt ? line.text.replace(/^\$\s*/, "") : line.text;
|
|
683
667
|
let displayText = cleanText;
|
|
684
668
|
let isTyping = false;
|
|
685
669
|
if (line.isPrompt) {
|
|
686
|
-
const
|
|
670
|
+
const typingDur = isDemo ? 0.8 : 0.6;
|
|
671
|
+
const typingEnd = lineFrame + fps * typingDur;
|
|
687
672
|
if (frame < typingEnd) {
|
|
688
673
|
const progress = interpolate(
|
|
689
|
-
frame,
|
|
690
|
-
[lineFrame, typingEnd],
|
|
691
|
-
[0, 1],
|
|
674
|
+
frame, [lineFrame, typingEnd], [0, 1],
|
|
692
675
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
693
676
|
);
|
|
694
677
|
const chars = Math.floor(progress * cleanText.length);
|
|
@@ -697,14 +680,11 @@ const HighlightClip: React.FC<{
|
|
|
697
680
|
}
|
|
698
681
|
}
|
|
699
682
|
|
|
700
|
-
const isZoomed = highlight.zoomLine === lineIdx;
|
|
683
|
+
const isZoomed = !isDemo && highlight.zoomLine === lineIdx;
|
|
701
684
|
const lineZoom = isZoomed
|
|
702
|
-
? interpolate(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
[1, 1.05],
|
|
706
|
-
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
707
|
-
)
|
|
685
|
+
? interpolate(frame, [lineFrame + fps * 0.5, lineFrame + fps * 1], [1, 1.05], {
|
|
686
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
|
687
|
+
})
|
|
708
688
|
: 1;
|
|
709
689
|
|
|
710
690
|
const showCursor = lineIdx === cursorLineIdx;
|
|
@@ -717,8 +697,8 @@ const HighlightClip: React.FC<{
|
|
|
717
697
|
transform: `translateX(${lineX}px) scale(${lineZoom})`,
|
|
718
698
|
transformOrigin: "left center",
|
|
719
699
|
fontFamily: MONO,
|
|
720
|
-
fontSize:
|
|
721
|
-
lineHeight: 1.7,
|
|
700
|
+
fontSize: termFontSize,
|
|
701
|
+
lineHeight: isDemo ? 1.6 : 1.7,
|
|
722
702
|
color: line.dim ? DIM : line.color || WHITE,
|
|
723
703
|
fontWeight: line.bold ? 700 : 400,
|
|
724
704
|
whiteSpace: "pre",
|
|
@@ -738,11 +718,11 @@ const HighlightClip: React.FC<{
|
|
|
738
718
|
</div>
|
|
739
719
|
</AbsoluteFill>
|
|
740
720
|
|
|
741
|
-
{/* Mouse pointer —
|
|
742
|
-
<MousePointer />
|
|
721
|
+
{/* Mouse pointer — reel only */}
|
|
722
|
+
{!isDemo && <MousePointer />}
|
|
743
723
|
|
|
744
|
-
{/* Text overlay */}
|
|
745
|
-
{highlight.overlay && (
|
|
724
|
+
{/* Text overlay — reel only */}
|
|
725
|
+
{!isDemo && highlight.overlay && (
|
|
746
726
|
<TextOverlay text={highlight.overlay} durationSec={durationSec} />
|
|
747
727
|
)}
|
|
748
728
|
</AbsoluteFill>
|
|
@@ -757,7 +737,8 @@ const BrowserHighlightClip: React.FC<{
|
|
|
757
737
|
total: number;
|
|
758
738
|
transition: TransitionStyle;
|
|
759
739
|
durationSec: number;
|
|
760
|
-
|
|
740
|
+
isDemo?: boolean;
|
|
741
|
+
}> = ({ highlight, index, total, transition, durationSec, isDemo }) => {
|
|
761
742
|
const frame = useCurrentFrame();
|
|
762
743
|
const { fps } = useVideoConfig();
|
|
763
744
|
|
|
@@ -768,57 +749,50 @@ const BrowserHighlightClip: React.FC<{
|
|
|
768
749
|
});
|
|
769
750
|
const entry = getEntryTransform(transition, enterSpring);
|
|
770
751
|
|
|
771
|
-
const
|
|
752
|
+
const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
|
|
753
|
+
|
|
754
|
+
const fadeIn = interpolate(frame, [0, fps * timing.transition], [0, 1], {
|
|
772
755
|
extrapolateLeft: "clamp",
|
|
773
756
|
extrapolateRight: "clamp",
|
|
774
757
|
});
|
|
775
758
|
const fadeOut = interpolate(
|
|
776
759
|
frame,
|
|
777
|
-
[fps * (durationSec -
|
|
760
|
+
[fps * (durationSec - timing.transition), fps * durationSec],
|
|
778
761
|
[1, 0],
|
|
779
762
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
780
763
|
);
|
|
781
764
|
const opacity = Math.min(fadeIn, fadeOut);
|
|
782
765
|
|
|
783
|
-
// Focal zoom —
|
|
766
|
+
// Focal zoom — reel only
|
|
784
767
|
const fx = highlight.focusX ?? 0.5;
|
|
785
768
|
const fy = highlight.focusY ?? 0.5;
|
|
786
769
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
frame,
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
extrapolateRight: "clamp",
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
);
|
|
802
|
-
const focalZoom = frame < fps * 3.5 ? focalZoomIn : focalZoomOut;
|
|
803
|
-
|
|
804
|
-
// Entry pan
|
|
805
|
-
const panY = interpolate(
|
|
806
|
-
frame,
|
|
807
|
-
[fps * 1.0, fps * 3.0, fps * (durationSec - 1.0)],
|
|
808
|
-
[0, -10, 5],
|
|
809
|
-
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
810
|
-
);
|
|
770
|
+
let focalZoom = 1;
|
|
771
|
+
let panY = 0;
|
|
772
|
+
if (!isDemo) {
|
|
773
|
+
const focalZoomIn = interpolate(frame, [fps * 1.0, fps * 3.0], [1, 1.15], {
|
|
774
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic),
|
|
775
|
+
});
|
|
776
|
+
const focalZoomOut = interpolate(frame, [fps * 3.5, fps * (durationSec - 0.5)], [1.15, 1.02], {
|
|
777
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic),
|
|
778
|
+
});
|
|
779
|
+
focalZoom = frame < fps * 3.5 ? focalZoomIn : focalZoomOut;
|
|
780
|
+
panY = interpolate(frame, [fps * 1.0, fps * 3.0, fps * (durationSec - 1.0)], [0, -10, 5], {
|
|
781
|
+
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
|
782
|
+
});
|
|
783
|
+
}
|
|
811
784
|
|
|
812
785
|
const videoSrc = highlight.videoSrc!;
|
|
813
786
|
const startFrom = Math.round((highlight.videoStartSec || 0) * fps);
|
|
787
|
+
const browserWidth = isDemo ? 1600 : 880;
|
|
814
788
|
|
|
815
789
|
return (
|
|
816
790
|
<AbsoluteFill style={{ opacity }}>
|
|
817
|
-
{/*
|
|
791
|
+
{/* Chapter label */}
|
|
818
792
|
<div
|
|
819
793
|
style={{
|
|
820
794
|
position: "absolute",
|
|
821
|
-
top: 45,
|
|
795
|
+
top: isDemo ? 24 : 45,
|
|
822
796
|
left: 0,
|
|
823
797
|
width: "100%",
|
|
824
798
|
textAlign: "center",
|
|
@@ -828,36 +802,30 @@ const BrowserHighlightClip: React.FC<{
|
|
|
828
802
|
<span
|
|
829
803
|
style={{
|
|
830
804
|
fontFamily: MONO,
|
|
831
|
-
fontSize: 13,
|
|
805
|
+
fontSize: isDemo ? 15 : 13,
|
|
832
806
|
color: ACCENT,
|
|
833
|
-
letterSpacing: 4,
|
|
807
|
+
letterSpacing: isDemo ? 3 : 4,
|
|
834
808
|
textTransform: "uppercase",
|
|
835
|
-
opacity: interpolate(enterSpring, [0, 1], [0, 0.6]),
|
|
809
|
+
opacity: interpolate(enterSpring, [0, 1], [0, isDemo ? 0.8 : 0.6]),
|
|
836
810
|
}}
|
|
837
811
|
>
|
|
838
812
|
{highlight.label}
|
|
839
813
|
</span>
|
|
840
|
-
|
|
841
|
-
style={{
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
backgroundColor:
|
|
856
|
-
i === index ? ACCENT : "rgba(255,255,255,0.12)",
|
|
857
|
-
}}
|
|
858
|
-
/>
|
|
859
|
-
))}
|
|
860
|
-
</div>
|
|
814
|
+
{!isDemo && (
|
|
815
|
+
<div style={{ marginTop: 10, display: "flex", justifyContent: "center", gap: 8 }}>
|
|
816
|
+
{Array.from({ length: total }).map((_, i) => (
|
|
817
|
+
<div
|
|
818
|
+
key={i}
|
|
819
|
+
style={{
|
|
820
|
+
width: i === index ? 24 : 8,
|
|
821
|
+
height: 6,
|
|
822
|
+
borderRadius: 3,
|
|
823
|
+
backgroundColor: i === index ? ACCENT : "rgba(255,255,255,0.12)",
|
|
824
|
+
}}
|
|
825
|
+
/>
|
|
826
|
+
))}
|
|
827
|
+
</div>
|
|
828
|
+
)}
|
|
861
829
|
</div>
|
|
862
830
|
|
|
863
831
|
{/* Browser window */}
|
|
@@ -865,20 +833,23 @@ const BrowserHighlightClip: React.FC<{
|
|
|
865
833
|
style={{
|
|
866
834
|
justifyContent: "center",
|
|
867
835
|
alignItems: "center",
|
|
868
|
-
padding: 40,
|
|
869
|
-
paddingTop: 100,
|
|
870
|
-
paddingBottom: 100,
|
|
836
|
+
padding: isDemo ? 20 : 40,
|
|
837
|
+
paddingTop: isDemo ? 60 : 100,
|
|
838
|
+
paddingBottom: isDemo ? 40 : 100,
|
|
871
839
|
}}
|
|
872
840
|
>
|
|
873
841
|
<div
|
|
874
842
|
style={{
|
|
875
|
-
transform:
|
|
843
|
+
transform: isDemo
|
|
844
|
+
? `scale(${enterSpring})`
|
|
845
|
+
: `scale(${entry.scale}) translate(${entry.x}px, ${entry.y + panY}px)`,
|
|
876
846
|
transformOrigin: "center center",
|
|
877
|
-
width:
|
|
878
|
-
borderRadius: 14,
|
|
847
|
+
width: browserWidth,
|
|
848
|
+
borderRadius: isDemo ? 10 : 14,
|
|
879
849
|
overflow: "hidden",
|
|
880
|
-
boxShadow:
|
|
881
|
-
"0
|
|
850
|
+
boxShadow: isDemo
|
|
851
|
+
? "0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04)"
|
|
852
|
+
: "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
|
|
882
853
|
}}
|
|
883
854
|
>
|
|
884
855
|
{/* Browser chrome */}
|
|
@@ -946,8 +917,8 @@ const BrowserHighlightClip: React.FC<{
|
|
|
946
917
|
</div>
|
|
947
918
|
</AbsoluteFill>
|
|
948
919
|
|
|
949
|
-
{/* Text overlay */}
|
|
950
|
-
{highlight.overlay && (
|
|
920
|
+
{/* Text overlay — reel only */}
|
|
921
|
+
{!isDemo && highlight.overlay && (
|
|
951
922
|
<TextOverlay text={highlight.overlay} durationSec={durationSec} />
|
|
952
923
|
)}
|
|
953
924
|
</AbsoluteFill>
|
|
@@ -1080,9 +1051,10 @@ const BrowserCursor: React.FC<{
|
|
|
1080
1051
|
|
|
1081
1052
|
// ─── End Card (CTA) ───────────────────────────────────────
|
|
1082
1053
|
|
|
1083
|
-
const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
|
|
1054
|
+
const EndCard: React.FC<{ text: string; url?: string; isDemo?: boolean }> = ({ text, url, isDemo }) => {
|
|
1084
1055
|
const frame = useCurrentFrame();
|
|
1085
1056
|
const { fps } = useVideoConfig();
|
|
1057
|
+
const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
|
|
1086
1058
|
|
|
1087
1059
|
const cmdSpring = spring({ fps, frame, config: { damping: 14 } });
|
|
1088
1060
|
const urlSpring = spring({
|
|
@@ -1095,7 +1067,7 @@ const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
|
|
|
1095
1067
|
frame: Math.max(0, frame - 18),
|
|
1096
1068
|
config: { damping: 14 },
|
|
1097
1069
|
});
|
|
1098
|
-
const endZoom = interpolate(frame, [0, fps *
|
|
1070
|
+
const endZoom = isDemo ? 1 : interpolate(frame, [0, fps * timing.end], [1.05, 1], {
|
|
1099
1071
|
extrapolateLeft: "clamp",
|
|
1100
1072
|
extrapolateRight: "clamp",
|
|
1101
1073
|
easing: Easing.out(Easing.cubic),
|
|
@@ -1117,8 +1089,8 @@ const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
|
|
|
1117
1089
|
>
|
|
1118
1090
|
<div
|
|
1119
1091
|
style={{
|
|
1120
|
-
fontFamily: MONO,
|
|
1121
|
-
fontSize: 30,
|
|
1092
|
+
fontFamily: isDemo ? SANS : MONO,
|
|
1093
|
+
fontSize: isDemo ? 28 : 30,
|
|
1122
1094
|
color: WHITE,
|
|
1123
1095
|
padding: "18px 36px",
|
|
1124
1096
|
borderRadius: 14,
|
|
@@ -1127,9 +1099,9 @@ const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
|
|
|
1127
1099
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
|
1128
1100
|
}}
|
|
1129
1101
|
>
|
|
1130
|
-
<span style={{ color: ACCENT }}>$ </span>
|
|
1102
|
+
{!isDemo && <span style={{ color: ACCENT }}>$ </span>}
|
|
1131
1103
|
{text}
|
|
1132
|
-
<Cursor visible blink />
|
|
1104
|
+
{!isDemo && <Cursor visible blink />}
|
|
1133
1105
|
</div>
|
|
1134
1106
|
</div>
|
|
1135
1107
|
|
|
@@ -1140,7 +1112,7 @@ const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
|
|
|
1140
1112
|
opacity: urlSpring,
|
|
1141
1113
|
transform: `translateY(${interpolate(urlSpring, [0, 1], [15, 0])}px)`,
|
|
1142
1114
|
fontFamily: SANS,
|
|
1143
|
-
fontSize: 20,
|
|
1115
|
+
fontSize: isDemo ? 18 : 20,
|
|
1144
1116
|
color: DIM,
|
|
1145
1117
|
letterSpacing: 0.5,
|
|
1146
1118
|
}}
|
package/src/Root.tsx
CHANGED
|
@@ -2,26 +2,37 @@ import { Composition } from "remotion";
|
|
|
2
2
|
import { CastVideo } from "./CastVideo";
|
|
3
3
|
import { defaultProps, CastProps } from "./types";
|
|
4
4
|
|
|
5
|
+
// Duration constants per mode
|
|
6
|
+
const REEL = { title: 2.5, termHighlight: 4.5, browserHighlight: 7.0, end: 3.5 };
|
|
7
|
+
const DEMO = { title: 2.0, termHighlight: 12.0, browserHighlight: 10.0, end: 3.0 };
|
|
8
|
+
|
|
5
9
|
export const RemotionRoot: React.FC = () => {
|
|
6
10
|
return (
|
|
7
11
|
<Composition
|
|
8
12
|
id="CastVideo"
|
|
9
|
-
component={CastVideo}
|
|
13
|
+
component={CastVideo as unknown as React.FC<Record<string, unknown>>}
|
|
10
14
|
durationInFrames={450}
|
|
11
15
|
fps={30}
|
|
12
16
|
width={1080}
|
|
13
17
|
height={1080}
|
|
14
|
-
defaultProps={defaultProps}
|
|
15
|
-
calculateMetadata={({ props }
|
|
18
|
+
defaultProps={defaultProps as unknown as Record<string, unknown>}
|
|
19
|
+
calculateMetadata={({ props }) => {
|
|
20
|
+
const p = props as unknown as CastProps;
|
|
16
21
|
const fps = 30;
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
22
|
+
const isDemo = p.mode === "demo";
|
|
23
|
+
const timing = isDemo ? DEMO : REEL;
|
|
24
|
+
|
|
25
|
+
const titleFrames = Math.round(timing.title * fps);
|
|
26
|
+
const highlightFrames = p.highlights.reduce((sum, h) => {
|
|
27
|
+
const dur = h.videoSrc ? timing.browserHighlight : timing.termHighlight;
|
|
20
28
|
return sum + Math.round(dur * fps);
|
|
21
29
|
}, 0);
|
|
22
|
-
const endFrames = Math.round(
|
|
30
|
+
const endFrames = Math.round(timing.end * fps);
|
|
31
|
+
|
|
23
32
|
return {
|
|
24
33
|
durationInFrames: titleFrames + highlightFrames + endFrames,
|
|
34
|
+
width: isDemo ? 1920 : 1080,
|
|
35
|
+
height: isDemo ? 1080 : 1080,
|
|
25
36
|
};
|
|
26
37
|
}}
|
|
27
38
|
/>
|
package/src/types.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface CastProps {
|
|
|
38
38
|
endText?: string; // closing CTA command, e.g. "npx agentreel"
|
|
39
39
|
endUrl?: string; // URL shown under CTA, e.g. "github.com/islo-labs/agentreel"
|
|
40
40
|
gradient?: [string, string]; // background gradient colors
|
|
41
|
+
mode?: "reel" | "demo"; // "reel" = 1080x1080 marketing clip, "demo" = 1920x1080 chapter walkthrough
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export const defaultProps: CastProps = {
|