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 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
- > 🔊 Turn on sound
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
- ## How it works
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
- 1. You provide a CLI command or URL
31
- 2. AI plans and executes a demo (terminal or browser)
32
- 3. AI picks the 3-4 best highlight moments
33
- 4. Renders a polished video with music, transitions, and overlays
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
- ## What you get
44
+ Requires [`gh` CLI](https://cli.github.com) to be installed and authenticated.
37
45
 
38
- A 15-20 second 1080x1080 video with:
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
- Ready for Twitter/X, LinkedIn, Reels.
48
+ The original mode — creates a short, polished clip for social media.
47
49
 
48
- ## Supports
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
- - **CLI demos** — records your tool in a terminal, shows the highlights
51
- - **Browser demos** — records your web app via Playwright, shows the key moments
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 === "--prompt" || arg === "-p") flags.prompt = args[++i];
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
- let demoCmd = flags.cmd;
410
- let demoURL = flags.url;
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
- let videoTitle = flags.title || demoCmd || demoURL;
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
- if (demoCmd) {
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(demoCmd, process.cwd(), prompt, flags.guidelines);
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, prompt, flags.guidelines);
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: prompt,
669
+ subtitle: description,
441
670
  highlights,
442
- endText: demoCmd,
671
+ endText: flags.cmd,
443
672
  }, output, flags.music);
444
673
 
445
674
  if (!noShare) {
446
- await shareFlow(resolve(output), videoTitle, prompt);
675
+ await shareFlow(resolve(output), videoTitle, description);
447
676
  }
448
677
  return;
449
678
  }
450
679
 
451
- if (demoURL) {
452
- const task = prompt || "Explore the main features of this app";
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(demoURL, task, flags.auth, flags.guidelines);
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: prompt,
703
+ subtitle: description,
479
704
  highlights,
480
- endText: demoURL,
481
- endUrl: demoURL,
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, prompt);
710
+ await shareFlow(resolve(output), videoTitle, description);
486
711
  }
487
712
  return;
488
713
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentreel",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Turn your web apps and CLIs into viral clips",
5
5
  "bin": {
6
6
  "agentreel": "./bin/agentreel.mjs"
@@ -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 3-4 highlight moments from the recorded session."""
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
- prompt = f"""You are creating a highlights reel for a CLI tool demo video. Here is the full terminal output:
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
- const TITLE_DUR = 2.5;
24
- const TERMINAL_HIGHLIGHT_DUR = 4.5;
25
- const BROWSER_HIGHLIGHT_DUR = 7.0;
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
- return h.videoSrc ? BROWSER_HIGHLIGHT_DUR : TERMINAL_HIGHLIGHT_DUR;
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(TITLE_DUR * fps);
96
- const endFrames = Math.round(END_DUR * fps);
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 = interpolate(frame, [0, durationInFrames], [125, 200], {
111
- extrapolateRight: "clamp",
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 in background */}
122
- <AnimatedBackground frame={frame} duration={durationInFrames} />
126
+ {/* Subtle animated glow blobs reel only */}
127
+ {!isDemo && <AnimatedBackground frame={frame} duration={durationInFrames} />}
123
128
 
124
- <MusicTrack />
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 * (TITLE_DUR - TRANSITION_DUR), fps * TITLE_DUR],
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 * TITLE_DUR], [1, 1.08], {
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
- }> = ({ highlight, index, total, transition, durationSec }) => {
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 — varies per clip
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 = getEntryTransform(transition, enterSpring);
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 * TRANSITION_DUR], [0, 1], {
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 - TRANSITION_DUR), 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 in/out cycle
514
- const zoomIn = interpolate(
515
- frame,
516
- [fps * 0.8, fps * 2.0],
517
- [1, 1.12],
518
- {
519
- extrapolateLeft: "clamp",
520
- extrapolateRight: "clamp",
521
- easing: Easing.out(Easing.cubic),
522
- }
523
- );
524
- const zoomOut = interpolate(
525
- frame,
526
- [fps * 2.5, fps * (durationSec - 0.5)],
527
- [1.12, 1.02],
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
- // Vertical pan
537
- const panY = interpolate(
538
- frame,
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
- // Line timing
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 = highlight.lines.findIndex((_, i) => {
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
- ? highlight.lines.length - 1
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
- {/* Label + progress dots */}
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
- <div
583
- style={{
584
- marginTop: 10,
585
- display: "flex",
586
- justifyContent: "center",
587
- gap: 8,
588
- }}
589
- >
590
- {Array.from({ length: total }).map((_, i) => (
591
- <div
592
- key={i}
593
- style={{
594
- width: i === index ? 24 : 8,
595
- height: 6,
596
- borderRadius: 3,
597
- backgroundColor:
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: `scale(${entry.scale * zoom}) translate(${entry.x}px, ${entry.y + panY}px)`,
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: 820,
620
- borderRadius: 14,
623
+ width: termWidth,
624
+ borderRadius: isDemo ? 10 : 14,
621
625
  overflow: "hidden",
622
- boxShadow:
623
- "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
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
- style={{
638
- width: 12,
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: "20px 24px",
668
- minHeight: 280,
651
+ padding: termPadding,
652
+ minHeight: termMinHeight,
669
653
  }}
670
654
  >
671
- {highlight.lines.map((line, lineIdx) => {
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 typingEnd = lineFrame + fps * 0.6;
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
- frame,
704
- [lineFrame + fps * 0.5, lineFrame + fps * 1],
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: 16,
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 — appears at start of each clip */}
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
- }> = ({ highlight, index, total, transition, durationSec }) => {
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 fadeIn = interpolate(frame, [0, fps * TRANSITION_DUR], [0, 1], {
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 - TRANSITION_DUR), 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 — applied to video content only, not browser chrome
766
+ // Focal zoom — reel only
784
767
  const fx = highlight.focusX ?? 0.5;
785
768
  const fy = highlight.focusY ?? 0.5;
786
769
 
787
- const focalZoomIn = interpolate(frame, [fps * 1.0, fps * 3.0], [1, 1.15], {
788
- extrapolateLeft: "clamp",
789
- extrapolateRight: "clamp",
790
- easing: Easing.out(Easing.cubic),
791
- });
792
- const focalZoomOut = interpolate(
793
- frame,
794
- [fps * 3.5, fps * (durationSec - 0.5)],
795
- [1.15, 1.02],
796
- {
797
- extrapolateLeft: "clamp",
798
- extrapolateRight: "clamp",
799
- easing: Easing.inOut(Easing.cubic),
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
- {/* Label + progress dots */}
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
- <div
841
- style={{
842
- marginTop: 10,
843
- display: "flex",
844
- justifyContent: "center",
845
- gap: 8,
846
- }}
847
- >
848
- {Array.from({ length: total }).map((_, i) => (
849
- <div
850
- key={i}
851
- style={{
852
- width: i === index ? 24 : 8,
853
- height: 6,
854
- borderRadius: 3,
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: `scale(${entry.scale}) translate(${entry.x}px, ${entry.y + panY}px)`,
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: 880,
878
- borderRadius: 14,
847
+ width: browserWidth,
848
+ borderRadius: isDemo ? 10 : 14,
879
849
  overflow: "hidden",
880
- boxShadow:
881
- "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
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 * END_DUR], [1.05, 1], {
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 }: { props: CastProps }) => {
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 titleFrames = Math.round(2.5 * fps);
18
- const highlightFrames = props.highlights.reduce((sum, h) => {
19
- const dur = h.videoSrc ? 7.0 : 4.5;
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(3.5 * fps);
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 = {