agentreel 0.1.7 → 0.2.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,6 +1,6 @@
1
1
  # agentreel
2
2
 
3
- Turn your Claude Code sessions into viral demo videos.
3
+ Turn your web apps and CLIs into viral clips.
4
4
 
5
5
  https://github.com/user-attachments/assets/474fd85d-3b35-48f4-82b8-1b337840fb51
6
6
 
@@ -15,25 +15,23 @@ npx agentreel
15
15
  ## Usage
16
16
 
17
17
  ```bash
18
- # After Claude builds something, just run:
19
- agentreel
18
+ # CLI demo:
19
+ agentreel --cmd "npx my-cli-tool"
20
20
 
21
- # It reads your session, detects what was built, records a demo,
22
- # picks the highlights, and renders a video. One command.
21
+ # Browser demo:
22
+ agentreel --url http://localhost:3000
23
23
 
24
- # Manual mode:
25
- agentreel --cmd "npx my-cli-tool" # CLI demo
26
- agentreel --url http://localhost:3000 # browser demo
24
+ # With context for smarter demo planning:
25
+ agentreel --cmd "npx my-tool" --prompt "A CLI that manages cron jobs"
27
26
  ```
28
27
 
29
28
  ## How it works
30
29
 
31
- 1. Reads your Claude Code session log
32
- 2. Detects what was built CLI tool or web app
33
- 3. Claude plans and executes a demo (terminal or browser)
34
- 4. Claude picks the 3-4 best highlight moments
35
- 5. Renders a polished video with music, transitions, and overlays
36
- 6. Prompts you to share on Twitter
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
37
35
 
38
36
  ## What you get
39
37
 
@@ -56,7 +54,7 @@ Ready for Twitter/X, LinkedIn, Reels.
56
54
 
57
55
  - Node.js 18+
58
56
  - Python 3.10+
59
- - Claude CLI (`claude`)
57
+ - Claude CLI (`claude`) — used to plan demo sequences
60
58
 
61
59
  ## Credits
62
60
 
package/bin/agentreel.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from "node:child_process";
4
- import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, copyFileSync, createReadStream } from "node:fs";
5
- import { join, dirname, basename, resolve } from "node:path";
6
- import { homedir, tmpdir } from "node:os";
4
+ import { readFileSync, statSync, existsSync, mkdirSync, copyFileSync } from "node:fs";
5
+ import { join, dirname, resolve } from "node:path";
6
+ import { tmpdir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createInterface } from "node:readline";
9
9
 
@@ -18,26 +18,28 @@ function parseArgs() {
18
18
  for (let i = 0; i < args.length; i++) {
19
19
  const arg = args[i];
20
20
  if (arg === "--help" || arg === "-h") { printUsage(); process.exit(0); }
21
- if (arg === "--version" || arg === "-v") { console.log("0.1.0"); process.exit(0); }
21
+ if (arg === "--version" || arg === "-v") {
22
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
23
+ console.log(pkg.version);
24
+ process.exit(0);
25
+ }
22
26
  if (arg === "--cmd" || arg === "-c") flags.cmd = args[++i];
23
27
  else if (arg === "--url" || arg === "-u") flags.url = args[++i];
24
28
  else if (arg === "--prompt" || arg === "-p") flags.prompt = args[++i];
25
29
  else if (arg === "--title" || arg === "-t") flags.title = args[++i];
26
30
  else if (arg === "--output" || arg === "-o") flags.output = args[++i];
27
31
  else if (arg === "--music") flags.music = args[++i];
28
- else if (arg === "--session") flags.session = args[++i];
29
32
  else if (arg === "--no-share") flags.noShare = true;
30
33
  }
31
34
  return flags;
32
35
  }
33
36
 
34
37
  function printUsage() {
35
- console.log(`agentreel — Turn Claude Code sessions into viral demo videos
38
+ console.log(`agentreel — Turn your web apps and CLIs into viral clips
36
39
 
37
40
  Usage:
38
- agentreel # auto-detect from session
39
- agentreel --cmd "npx @islo-labs/overtime" # manual CLI demo
40
- agentreel --url http://localhost:3000 # manual browser demo
41
+ agentreel --cmd "npx my-cli-tool" # CLI demo
42
+ agentreel --url http://localhost:3000 # browser demo
41
43
 
42
44
  Flags:
43
45
  -c, --cmd <command> CLI command to demo
@@ -46,155 +48,11 @@ Flags:
46
48
  -t, --title <text> video title
47
49
  -o, --output <file> output file (default: agentreel.mp4)
48
50
  --music <file> path to background music mp3
49
- --session <file> path to Claude Code session .jsonl
50
51
  --no-share skip the share prompt
51
52
  -h, --help show help
52
53
  -v, --version show version`);
53
54
  }
54
55
 
55
- // ── Session parser ──────────────────────────────────────────
56
-
57
- function findLatestSession() {
58
- const cwd = process.cwd();
59
- const projectKey = cwd.replaceAll("/", "-");
60
- const projectDir = join(homedir(), ".claude", "projects", projectKey);
61
-
62
- if (!existsSync(projectDir)) return null;
63
-
64
- let newest = null;
65
- let newestTime = 0;
66
- for (const entry of readdirSync(projectDir)) {
67
- if (!entry.endsWith(".jsonl")) continue;
68
- const full = join(projectDir, entry);
69
- const mtime = statSync(full).mtimeMs;
70
- if (mtime > newestTime) { newestTime = mtime; newest = full; }
71
- }
72
- return newest;
73
- }
74
-
75
- function parseSession(path) {
76
- const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean);
77
- const session = { prompt: "", title: "", actions: [], startTime: null, endTime: null };
78
-
79
- for (const line of lines) {
80
- let obj;
81
- try { obj = JSON.parse(line); } catch { continue; }
82
-
83
- const ts = obj.timestamp ? new Date(obj.timestamp) : null;
84
- if (ts && !isNaN(ts)) {
85
- if (!session.startTime || ts < session.startTime) session.startTime = ts;
86
- if (!session.endTime || ts > session.endTime) session.endTime = ts;
87
- }
88
-
89
- if (obj.type === "user" && !session.prompt) {
90
- session.prompt = extractPrompt(obj);
91
- }
92
- if (obj.type === "custom-title" && obj.customTitle) {
93
- session.title = obj.customTitle;
94
- }
95
- if (obj.type === "assistant") {
96
- const content = obj.message?.content;
97
- if (!Array.isArray(content)) continue;
98
- for (const block of content) {
99
- if (block.type !== "tool_use") continue;
100
- const action = parseToolUse(block.name, block.input, ts);
101
- if (action) session.actions.push(action);
102
- }
103
- }
104
- }
105
-
106
- session.actions.sort((a, b) => (a.time || 0) - (b.time || 0));
107
- if (session.startTime && session.endTime) {
108
- session.durationMs = session.endTime - session.startTime;
109
- }
110
- return session;
111
- }
112
-
113
- function extractPrompt(obj) {
114
- const content = obj.message?.content;
115
- if (typeof content === "string") return cleanPrompt(content);
116
- if (Array.isArray(content)) {
117
- for (const block of content) {
118
- if (block.type === "text" && block.text) return cleanPrompt(block.text);
119
- }
120
- }
121
- return "";
122
- }
123
-
124
- function cleanPrompt(s) {
125
- for (const line of s.split("\n")) {
126
- const trimmed = line.trim();
127
- if (!trimmed || /^[│├└─┌┐]/.test(trimmed)) continue;
128
- return trimmed.slice(0, 200);
129
- }
130
- return s.slice(0, 200);
131
- }
132
-
133
- function parseToolUse(name, input, ts) {
134
- if (!input) return null;
135
- switch (name) {
136
- case "Read": return { type: "read", filePath: input.file_path, time: ts };
137
- case "Write": return { type: "write", filePath: input.file_path, size: input.content?.length || 0, time: ts };
138
- case "Edit": return { type: "edit", filePath: input.file_path, time: ts };
139
- case "Bash": return { type: "bash", command: input.command, time: ts };
140
- case "Grep": case "Glob": return { type: "search", time: ts };
141
- case "Agent": return { type: "agent", time: ts };
142
- default: return null;
143
- }
144
- }
145
-
146
- // ── Detection ───────────────────────────────────────────────
147
-
148
- function detectResult(session) {
149
- const containsAny = (s, ...subs) => subs.some(sub => s.includes(sub));
150
-
151
- for (const a of session.actions) {
152
- if (a.type === "bash") {
153
- const cmd = (a.command || "").toLowerCase();
154
- if (containsAny(cmd, "npm run dev", "npm start", "npx next", "npx vite", "yarn dev", "pnpm dev", "flask run", "uvicorn")) {
155
- const url = extractURL(a.command) || "http://localhost:3000";
156
- return { type: "browser", command: url };
157
- }
158
- }
159
- }
160
-
161
- for (const a of session.actions) {
162
- if ((a.type === "write" || a.type === "edit") && a.filePath?.endsWith("package.json")) {
163
- try {
164
- const pkg = JSON.parse(readFileSync(a.filePath, "utf-8"));
165
- if (pkg.bin && pkg.name) return { type: "cli", command: `npx ${pkg.name} --help` };
166
- } catch { /* skip */ }
167
- }
168
- }
169
-
170
- for (const a of session.actions) {
171
- if (a.type === "bash" && a.command?.includes("go build")) {
172
- const parts = a.command.split(/\s+/);
173
- const oIdx = parts.indexOf("-o");
174
- if (oIdx !== -1 && parts[oIdx + 1]) return { type: "cli", command: `${parts[oIdx + 1]} --help` };
175
- }
176
- }
177
-
178
- for (let i = session.actions.length - 1; i >= 0; i--) {
179
- const a = session.actions[i];
180
- if (a.type !== "bash") continue;
181
- const cmd = (a.command || "").trim();
182
- if (containsAny(cmd, "go build", "go test", "npm install", "npm test", "git ", "mkdir", "ls ", "cat ")) continue;
183
- if (containsAny(cmd, "npx ", "./bin/", "./dist/", "go run", "python ", "node ")) {
184
- return { type: "cli", command: cmd };
185
- }
186
- }
187
-
188
- return { type: "unknown" };
189
- }
190
-
191
- function extractURL(cmd) {
192
- for (const part of (cmd || "").split(/\s+/)) {
193
- if (part.includes("localhost:")) return part.startsWith("http") ? part : `http://${part}`;
194
- }
195
- return null;
196
- }
197
-
198
56
  // ── Recording + Highlights ──────────────────────────────────
199
57
 
200
58
  function findPython() {
@@ -371,30 +229,10 @@ async function main() {
371
229
  let demoURL = flags.url;
372
230
  let prompt = flags.prompt;
373
231
 
374
- // Auto-detect from Claude session if no manual flags
375
232
  if (!demoCmd && !demoURL) {
376
- const sessionPath = flags.session || findLatestSession();
377
- if (!sessionPath) {
378
- console.error("No session found and no --cmd or --url provided.\n");
379
- printUsage();
380
- process.exit(1);
381
- }
382
-
383
- console.error(`Reading session: ${basename(sessionPath)}`);
384
- const session = parseSession(sessionPath);
385
- if (!prompt) prompt = session.prompt;
386
-
387
- const detected = detectResult(session);
388
- if (detected.type === "cli") {
389
- demoCmd = detected.command;
390
- console.error(`Detected CLI: ${demoCmd}`);
391
- } else if (detected.type === "browser") {
392
- demoURL = detected.command;
393
- console.error(`Detected browser: ${demoURL}`);
394
- } else {
395
- console.error("Couldn't detect what was built. Use --cmd or --url.");
396
- process.exit(1);
397
- }
233
+ console.error("Please provide --cmd or --url.\n");
234
+ printUsage();
235
+ process.exit(1);
398
236
  }
399
237
 
400
238
  let videoTitle = flags.title || demoCmd || demoURL;
@@ -438,6 +276,32 @@ async function main() {
438
276
  const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
439
277
  console.error(` ${highlights.length} highlights extracted`);
440
278
 
279
+ // Merge click data into highlights
280
+ const clicksPath = videoPath.replace(".mp4", "-clicks.json");
281
+ let allClicks = [];
282
+ if (existsSync(clicksPath)) {
283
+ allClicks = JSON.parse(readFileSync(clicksPath, "utf-8"));
284
+ console.error(` ${allClicks.length} clicks captured`);
285
+ }
286
+
287
+ for (const h of highlights) {
288
+ const startSec = h.videoStartSec || 0;
289
+ const endSec = h.videoEndSec || (startSec + 7);
290
+
291
+ h.clicks = allClicks
292
+ .filter(c => c.timeSec >= startSec && c.timeSec <= endSec)
293
+ .map(c => ({
294
+ x: Math.max(0, Math.min(1280, c.x)),
295
+ y: Math.max(0, Math.min(800, c.y)),
296
+ timeSec: c.timeSec - startSec,
297
+ }));
298
+
299
+ if (h.clicks.length > 0) {
300
+ h.focusX = h.clicks.reduce((s, c) => s + c.x, 0) / h.clicks.length / 1280;
301
+ h.focusY = h.clicks.reduce((s, c) => s + c.y, 0) / h.clicks.length / 800;
302
+ }
303
+ }
304
+
441
305
  console.error("Step 3/3: Rendering video...");
442
306
  await renderVideo({
443
307
  title: videoTitle,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentreel",
3
- "version": "0.1.7",
4
- "description": "Turn Claude Code sessions into viral demo videos",
3
+ "version": "0.2.0",
4
+ "description": "Turn your web apps and CLIs into viral clips",
5
5
  "bin": {
6
6
  "agentreel": "./bin/agentreel.mjs"
7
7
  },
@@ -38,8 +38,8 @@
38
38
  "cli",
39
39
  "demo",
40
40
  "video",
41
- "claude",
42
- "agent",
43
- "remotion"
41
+ "remotion",
42
+ "screencast",
43
+ "web-app"
44
44
  ]
45
45
  }
@@ -12,6 +12,7 @@ import os
12
12
  import subprocess
13
13
  import sys
14
14
  import tempfile
15
+ import time
15
16
 
16
17
 
17
18
  def find_claude():
@@ -74,7 +75,7 @@ def extract_highlights(video_path, task):
74
75
  f"Suggest 3-4 highlight moments as a JSON array. Each highlight has: "
75
76
  f'"label" (1-2 words), "overlay" (short caption with **bold** for accent), '
76
77
  f'"videoStartSec" (start time in seconds), "videoEndSec" (end time). '
77
- f"Each clip should be 3-5 seconds. Cover: page load, key interaction, result. "
78
+ f"Each clip should be 5-8 seconds to show the full interaction. Cover: page load, key interaction, result. "
78
79
  f"Return ONLY the JSON array."
79
80
  )
80
81
 
@@ -114,6 +115,7 @@ async def record_browser_demo(url, task, output_path):
114
115
  print(f"Script ready ({len(script_code)} chars)", file=sys.stderr)
115
116
 
116
117
  video_dir = tempfile.mkdtemp()
118
+ recording_start_ms = int(time.time() * 1000)
117
119
 
118
120
  async with async_playwright() as p:
119
121
  browser = await p.chromium.launch(headless=True)
@@ -122,6 +124,22 @@ async def record_browser_demo(url, task, output_path):
122
124
  record_video_dir=video_dir,
123
125
  record_video_size={"width": 1280, "height": 800},
124
126
  )
127
+
128
+ # Inject click tracker — persists across navigations
129
+ click_tracker_js = (
130
+ "if (!window.__agentreel_clicks) {"
131
+ " window.__agentreel_clicks = [];"
132
+ " document.addEventListener('click', function(e) {"
133
+ " window.__agentreel_clicks.push({"
134
+ " x: e.clientX,"
135
+ " y: e.clientY,"
136
+ f" timestamp: Date.now() - {recording_start_ms}"
137
+ " });"
138
+ " }, true);"
139
+ "}"
140
+ )
141
+ await context.add_init_script(click_tracker_js)
142
+
125
143
  page = await context.new_page()
126
144
 
127
145
  # Navigate first
@@ -134,11 +152,11 @@ async def record_browser_demo(url, task, output_path):
134
152
 
135
153
  await page.wait_for_timeout(1000)
136
154
 
137
- # Execute the generated demo
155
+ # Run the generated demo
138
156
  try:
139
157
  local_ns = {}
140
- full_code = f"import asyncio\n{script_code}"
141
- compiled = compile(full_code, "<demo>", "exec")
158
+ full_code = "import asyncio\n" + script_code
159
+ compiled = compile(full_code, "<demo>", "exec") # noqa: S102
142
160
  exec(compiled, local_ns) # noqa: S102
143
161
 
144
162
  if "demo" in local_ns:
@@ -156,6 +174,21 @@ async def record_browser_demo(url, task, output_path):
156
174
  await page.evaluate("window.scrollTo({ top: 0, behavior: 'smooth' })")
157
175
  await page.wait_for_timeout(2000)
158
176
 
177
+ # Extract click data before closing
178
+ try:
179
+ clicks_raw = await page.evaluate("window.__agentreel_clicks || []")
180
+ except Exception:
181
+ clicks_raw = []
182
+
183
+ clicks = [
184
+ {"x": c["x"], "y": c["y"], "timeSec": round(c["timestamp"] / 1000.0, 3)}
185
+ for c in clicks_raw
186
+ ]
187
+ clicks_path = output_path.replace(".mp4", "-clicks.json")
188
+ with open(clicks_path, "w") as f:
189
+ json.dump(clicks, f, indent=2)
190
+ print(f"Captured {len(clicks)} clicks -> {clicks_path}", file=sys.stderr)
191
+
159
192
  # Get the video path before closing
160
193
  video = page.video
161
194
  await page.close()
package/src/CastVideo.tsx CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  Easing,
12
12
  OffthreadVideo,
13
13
  } from "remotion";
14
- import { CastProps, Highlight } from "./types";
14
+ import { CastProps, Highlight, ClickEvent } from "./types";
15
15
 
16
16
  const ACCENT = "#50fa7b";
17
17
  const DIM = "#6272a4";
@@ -21,10 +21,20 @@ const TITLE_BAR = "#1e1f29";
21
21
  const CURSOR_COLOR = "#f8f8f2";
22
22
 
23
23
  const TITLE_DUR = 2.5;
24
- const HIGHLIGHT_DUR = 4.5;
24
+ const TERMINAL_HIGHLIGHT_DUR = 4.5;
25
+ const BROWSER_HIGHLIGHT_DUR = 7.0;
25
26
  const TRANSITION_DUR = 0.5;
26
27
  const END_DUR = 3.5;
27
28
 
29
+ const VIEWPORT_W = 1280;
30
+ const VIEWPORT_H = 800;
31
+ const VIDEO_AREA_W = 880;
32
+ const VIDEO_AREA_H = 550; // 880 * 10/16
33
+
34
+ function getHighlightDuration(h: Highlight): number {
35
+ return h.videoSrc ? BROWSER_HIGHLIGHT_DUR : TERMINAL_HIGHLIGHT_DUR;
36
+ }
37
+
28
38
  const SANS =
29
39
  '-apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif';
30
40
  const MONO =
@@ -83,9 +93,19 @@ export const CastVideo: React.FC<CastProps> = ({
83
93
  const g = gradient || ["#0f0f1a", "#1a0f2e"];
84
94
 
85
95
  const titleFrames = Math.round(TITLE_DUR * fps);
86
- const highlightFrames = Math.round(HIGHLIGHT_DUR * fps);
87
96
  const endFrames = Math.round(END_DUR * fps);
88
97
 
98
+ // Compute per-highlight durations and cumulative offsets
99
+ const hlDurations = highlights.map((h) =>
100
+ Math.round(getHighlightDuration(h) * fps)
101
+ );
102
+ const hlOffsets: number[] = [];
103
+ let cumulative = 0;
104
+ for (const dur of hlDurations) {
105
+ hlOffsets.push(cumulative);
106
+ cumulative += dur;
107
+ }
108
+
89
109
  // Animated gradient — hue rotates slowly over time
90
110
  const gradAngle = interpolate(frame, [0, durationInFrames], [125, 200], {
91
111
  extrapolateRight: "clamp",
@@ -123,32 +143,37 @@ export const CastVideo: React.FC<CastProps> = ({
123
143
  <TitleCard title={title} subtitle={subtitle} />
124
144
  </Sequence>
125
145
 
126
- {highlights.map((h, i) => (
127
- <Sequence
128
- key={i}
129
- from={titleFrames + i * highlightFrames}
130
- durationInFrames={highlightFrames}
131
- >
132
- {h.videoSrc ? (
133
- <BrowserHighlightClip
134
- highlight={h}
135
- index={i}
136
- total={highlights.length}
137
- transition={TRANSITIONS[i % TRANSITIONS.length]}
138
- />
139
- ) : (
140
- <HighlightClip
141
- highlight={h}
142
- index={i}
143
- total={highlights.length}
144
- transition={TRANSITIONS[i % TRANSITIONS.length]}
145
- />
146
- )}
147
- </Sequence>
148
- ))}
146
+ {highlights.map((h, i) => {
147
+ const dur = getHighlightDuration(h);
148
+ return (
149
+ <Sequence
150
+ key={i}
151
+ from={titleFrames + hlOffsets[i]}
152
+ durationInFrames={hlDurations[i]}
153
+ >
154
+ {h.videoSrc ? (
155
+ <BrowserHighlightClip
156
+ highlight={h}
157
+ index={i}
158
+ total={highlights.length}
159
+ transition={TRANSITIONS[i % TRANSITIONS.length]}
160
+ durationSec={dur}
161
+ />
162
+ ) : (
163
+ <HighlightClip
164
+ highlight={h}
165
+ index={i}
166
+ total={highlights.length}
167
+ transition={TRANSITIONS[i % TRANSITIONS.length]}
168
+ durationSec={dur}
169
+ />
170
+ )}
171
+ </Sequence>
172
+ );
173
+ })}
149
174
 
150
175
  <Sequence
151
- from={titleFrames + highlights.length * highlightFrames}
176
+ from={titleFrames + cumulative}
152
177
  durationInFrames={endFrames}
153
178
  >
154
179
  <EndCard text={endText || title} url={endUrl} />
@@ -325,12 +350,15 @@ const Cursor: React.FC<{ visible: boolean; blink?: boolean }> = ({
325
350
 
326
351
  // ─── Text Overlay (colored accent words) ──────────────────
327
352
 
328
- const TextOverlay: React.FC<{ text: string }> = ({ text }) => {
353
+ const TextOverlay: React.FC<{ text: string; durationSec: number }> = ({
354
+ text,
355
+ durationSec,
356
+ }) => {
329
357
  const frame = useCurrentFrame();
330
358
  const { fps } = useVideoConfig();
331
359
 
332
360
  const showAt = fps * 1.8;
333
- const hideAt = fps * (HIGHLIGHT_DUR - 0.8);
361
+ const hideAt = fps * (durationSec - 0.8);
334
362
 
335
363
  const enterProgress = spring({
336
364
  fps,
@@ -472,7 +500,8 @@ const HighlightClip: React.FC<{
472
500
  index: number;
473
501
  total: number;
474
502
  transition: TransitionStyle;
475
- }> = ({ highlight, index, total, transition }) => {
503
+ durationSec: number;
504
+ }> = ({ highlight, index, total, transition, durationSec }) => {
476
505
  const frame = useCurrentFrame();
477
506
  const { fps } = useVideoConfig();
478
507
 
@@ -491,7 +520,7 @@ const HighlightClip: React.FC<{
491
520
  });
492
521
  const fadeOut = interpolate(
493
522
  frame,
494
- [fps * (HIGHLIGHT_DUR - TRANSITION_DUR), fps * HIGHLIGHT_DUR],
523
+ [fps * (durationSec - TRANSITION_DUR), fps * durationSec],
495
524
  [1, 0],
496
525
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
497
526
  );
@@ -510,7 +539,7 @@ const HighlightClip: React.FC<{
510
539
  );
511
540
  const zoomOut = interpolate(
512
541
  frame,
513
- [fps * 2.5, fps * (HIGHLIGHT_DUR - 0.5)],
542
+ [fps * 2.5, fps * (durationSec - 0.5)],
514
543
  [1.12, 1.02],
515
544
  {
516
545
  extrapolateLeft: "clamp",
@@ -523,7 +552,7 @@ const HighlightClip: React.FC<{
523
552
  // Vertical pan
524
553
  const panY = interpolate(
525
554
  frame,
526
- [fps * 0.8, fps * 2.0, fps * 3.5],
555
+ [fps * 0.8, fps * 2.0, fps * (durationSec - 1.0)],
527
556
  [0, -15, 5],
528
557
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
529
558
  );
@@ -737,7 +766,9 @@ const HighlightClip: React.FC<{
737
766
  <MousePointer />
738
767
 
739
768
  {/* Text overlay */}
740
- {highlight.overlay && <TextOverlay text={highlight.overlay} />}
769
+ {highlight.overlay && (
770
+ <TextOverlay text={highlight.overlay} durationSec={durationSec} />
771
+ )}
741
772
  </AbsoluteFill>
742
773
  );
743
774
  };
@@ -749,7 +780,8 @@ const BrowserHighlightClip: React.FC<{
749
780
  index: number;
750
781
  total: number;
751
782
  transition: TransitionStyle;
752
- }> = ({ highlight, index, total, transition }) => {
783
+ durationSec: number;
784
+ }> = ({ highlight, index, total, transition, durationSec }) => {
753
785
  const frame = useCurrentFrame();
754
786
  const { fps } = useVideoConfig();
755
787
 
@@ -766,33 +798,37 @@ const BrowserHighlightClip: React.FC<{
766
798
  });
767
799
  const fadeOut = interpolate(
768
800
  frame,
769
- [fps * (HIGHLIGHT_DUR - TRANSITION_DUR), fps * HIGHLIGHT_DUR],
801
+ [fps * (durationSec - TRANSITION_DUR), fps * durationSec],
770
802
  [1, 0],
771
803
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
772
804
  );
773
805
  const opacity = Math.min(fadeIn, fadeOut);
774
806
 
775
- // Zoom in/out cycle
776
- const zoomIn = interpolate(frame, [fps * 0.8, fps * 2.0], [1, 1.08], {
807
+ // Focal zoom — applied to video content only, not browser chrome
808
+ const fx = highlight.focusX ?? 0.5;
809
+ const fy = highlight.focusY ?? 0.5;
810
+
811
+ const focalZoomIn = interpolate(frame, [fps * 1.0, fps * 3.0], [1, 1.15], {
777
812
  extrapolateLeft: "clamp",
778
813
  extrapolateRight: "clamp",
779
814
  easing: Easing.out(Easing.cubic),
780
815
  });
781
- const zoomOut = interpolate(
816
+ const focalZoomOut = interpolate(
782
817
  frame,
783
- [fps * 2.5, fps * (HIGHLIGHT_DUR - 0.5)],
784
- [1.08, 1.01],
818
+ [fps * 3.5, fps * (durationSec - 0.5)],
819
+ [1.15, 1.02],
785
820
  {
786
821
  extrapolateLeft: "clamp",
787
822
  extrapolateRight: "clamp",
788
823
  easing: Easing.inOut(Easing.cubic),
789
824
  }
790
825
  );
791
- const zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
826
+ const focalZoom = frame < fps * 3.5 ? focalZoomIn : focalZoomOut;
792
827
 
828
+ // Entry pan
793
829
  const panY = interpolate(
794
830
  frame,
795
- [fps * 0.8, fps * 2.0, fps * 3.5],
831
+ [fps * 1.0, fps * 3.0, fps * (durationSec - 1.0)],
796
832
  [0, -10, 5],
797
833
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
798
834
  );
@@ -860,7 +896,7 @@ const BrowserHighlightClip: React.FC<{
860
896
  >
861
897
  <div
862
898
  style={{
863
- transform: `scale(${entry.scale * zoom}) translate(${entry.x}px, ${entry.y + panY}px)`,
899
+ transform: `scale(${entry.scale}) translate(${entry.x}px, ${entry.y + panY}px)`,
864
900
  transformOrigin: "center center",
865
901
  width: 880,
866
902
  borderRadius: 14,
@@ -882,7 +918,6 @@ const BrowserHighlightClip: React.FC<{
882
918
  <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#ff5555" }} />
883
919
  <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#f1fa8c" }} />
884
920
  <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#50fa7b" }} />
885
- {/* Address bar */}
886
921
  <div
887
922
  style={{
888
923
  flex: 1,
@@ -899,33 +934,174 @@ const BrowserHighlightClip: React.FC<{
899
934
  </div>
900
935
  </div>
901
936
 
902
- {/* Video content */}
937
+ {/* Video content — focal zoom applied here, chrome stays static */}
903
938
  <div
904
939
  style={{
905
940
  width: "100%",
906
941
  aspectRatio: "16/10",
907
942
  backgroundColor: "#fff",
908
943
  overflow: "hidden",
944
+ position: "relative",
909
945
  }}
910
946
  >
911
- <OffthreadVideo
912
- src={staticFile(videoSrc)}
913
- startFrom={startFrom}
914
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
915
- />
947
+ <div
948
+ style={{
949
+ width: "100%",
950
+ height: "100%",
951
+ transform: `scale(${focalZoom})`,
952
+ transformOrigin: `${fx * 100}% ${fy * 100}%`,
953
+ position: "relative",
954
+ }}
955
+ >
956
+ <OffthreadVideo
957
+ src={staticFile(videoSrc)}
958
+ startFrom={startFrom}
959
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
960
+ />
961
+ {/* Click cursor — inside zoom container so it tracks with content */}
962
+ {highlight.clicks && highlight.clicks.length > 0 && (
963
+ <BrowserCursor
964
+ clicks={highlight.clicks}
965
+ durationSec={durationSec}
966
+ />
967
+ )}
968
+ </div>
916
969
  </div>
917
970
  </div>
918
971
  </AbsoluteFill>
919
972
 
920
- {/* Mouse pointer */}
921
- <MousePointer />
922
-
923
973
  {/* Text overlay */}
924
- {highlight.overlay && <TextOverlay text={highlight.overlay} />}
974
+ {highlight.overlay && (
975
+ <TextOverlay text={highlight.overlay} durationSec={durationSec} />
976
+ )}
925
977
  </AbsoluteFill>
926
978
  );
927
979
  };
928
980
 
981
+ // ─── Browser Cursor (click-tracking) ─────────────────────
982
+
983
+ const BrowserCursor: React.FC<{
984
+ clicks: ClickEvent[];
985
+ durationSec: number;
986
+ }> = ({ clicks, durationSec }) => {
987
+ const frame = useCurrentFrame();
988
+ const { fps } = useVideoConfig();
989
+
990
+ if (!clicks || clicks.length === 0) return null;
991
+
992
+ const currentSec = frame / fps;
993
+ const scaleX = VIDEO_AREA_W / VIEWPORT_W;
994
+ const scaleY = VIDEO_AREA_H / VIEWPORT_H;
995
+
996
+ // Determine cursor position by interpolating between clicks
997
+ let targetX: number;
998
+ let targetY: number;
999
+
1000
+ if (currentSec <= clicks[0].timeSec) {
1001
+ // Before first click — hold at first position
1002
+ targetX = clicks[0].x * scaleX;
1003
+ targetY = clicks[0].y * scaleY;
1004
+ } else if (currentSec >= clicks[clicks.length - 1].timeSec) {
1005
+ // After last click — hold at last position
1006
+ targetX = clicks[clicks.length - 1].x * scaleX;
1007
+ targetY = clicks[clicks.length - 1].y * scaleY;
1008
+ } else {
1009
+ // Between clicks — interpolate with easing
1010
+ let prevIdx = 0;
1011
+ for (let i = 1; i < clicks.length; i++) {
1012
+ if (clicks[i].timeSec > currentSec) break;
1013
+ prevIdx = i;
1014
+ }
1015
+ const nextIdx = Math.min(prevIdx + 1, clicks.length - 1);
1016
+ const prev = clicks[prevIdx];
1017
+ const next = clicks[nextIdx];
1018
+ const t = (currentSec - prev.timeSec) / (next.timeSec - prev.timeSec || 1);
1019
+ const eased = Easing.inOut(Easing.cubic)(Math.min(1, t));
1020
+ targetX = interpolate(eased, [0, 1], [prev.x * scaleX, next.x * scaleX]);
1021
+ targetY = interpolate(eased, [0, 1], [prev.y * scaleY, next.y * scaleY]);
1022
+ }
1023
+
1024
+ // Click detection — within 3 frames of a click event
1025
+ const clickWindow = 3 / fps;
1026
+ const isClicking = clicks.some(
1027
+ (c) => Math.abs(currentSec - c.timeSec) < clickWindow
1028
+ );
1029
+
1030
+ // Fade in over first 0.3s, fade out after last click
1031
+ const lastClickTime = clicks[clicks.length - 1].timeSec;
1032
+ const fadeIn = interpolate(currentSec, [0, 0.3], [0, 1], {
1033
+ extrapolateLeft: "clamp",
1034
+ extrapolateRight: "clamp",
1035
+ });
1036
+ const fadeOut = interpolate(
1037
+ currentSec,
1038
+ [lastClickTime + 0.3, lastClickTime + 0.8],
1039
+ [1, 0],
1040
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
1041
+ );
1042
+ const opacity = Math.min(fadeIn, fadeOut);
1043
+
1044
+ if (opacity <= 0) return null;
1045
+
1046
+ return (
1047
+ <>
1048
+ {/* Click ripples */}
1049
+ {clicks.map((click, i) => {
1050
+ const rippleDuration = 0.4;
1051
+ if (currentSec < click.timeSec || currentSec > click.timeSec + rippleDuration)
1052
+ return null;
1053
+
1054
+ const progress = (currentSec - click.timeSec) / rippleDuration;
1055
+ const rippleScale = interpolate(progress, [0, 1], [0.5, 2.5]);
1056
+ const rippleOpacity = interpolate(progress, [0, 0.3, 1], [0.6, 0.4, 0]);
1057
+
1058
+ return (
1059
+ <div
1060
+ key={i}
1061
+ style={{
1062
+ position: "absolute",
1063
+ left: click.x * scaleX - 15,
1064
+ top: click.y * scaleY - 15,
1065
+ width: 30,
1066
+ height: 30,
1067
+ borderRadius: "50%",
1068
+ border: `2px solid ${ACCENT}`,
1069
+ transform: `scale(${rippleScale})`,
1070
+ opacity: rippleOpacity,
1071
+ pointerEvents: "none",
1072
+ zIndex: 49,
1073
+ }}
1074
+ />
1075
+ );
1076
+ })}
1077
+
1078
+ {/* Cursor */}
1079
+ <div
1080
+ style={{
1081
+ position: "absolute",
1082
+ left: targetX,
1083
+ top: targetY,
1084
+ zIndex: 50,
1085
+ opacity,
1086
+ transform: `scale(${isClicking ? 0.85 : 1})`,
1087
+ transformOrigin: "top left",
1088
+ pointerEvents: "none",
1089
+ }}
1090
+ >
1091
+ <svg width="24" height="28" viewBox="0 0 24 28" fill="none">
1092
+ <path
1093
+ d="M2 2L2 22L7.5 16.5L12.5 26L16 24.5L11 15H19L2 2Z"
1094
+ fill="white"
1095
+ stroke="black"
1096
+ strokeWidth="1.5"
1097
+ strokeLinejoin="round"
1098
+ />
1099
+ </svg>
1100
+ </div>
1101
+ </>
1102
+ );
1103
+ };
1104
+
929
1105
  // ─── End Card (CTA) ───────────────────────────────────────
930
1106
 
931
1107
  const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
package/src/Root.tsx CHANGED
@@ -15,8 +15,11 @@ export const RemotionRoot: React.FC = () => {
15
15
  calculateMetadata={({ props }: { props: CastProps }) => {
16
16
  const fps = 30;
17
17
  const titleFrames = Math.round(2.5 * fps);
18
- const highlightFrames = Math.round(4 * fps) * props.highlights.length;
19
- const endFrames = Math.round(2.5 * fps);
18
+ const highlightFrames = props.highlights.reduce((sum, h) => {
19
+ const dur = h.videoSrc ? 7.0 : 4.5;
20
+ return sum + Math.round(dur * fps);
21
+ }, 0);
22
+ const endFrames = Math.round(3.5 * fps);
20
23
  return {
21
24
  durationInFrames: titleFrames + highlightFrames + endFrames,
22
25
  };
package/src/types.ts CHANGED
@@ -1,3 +1,9 @@
1
+ export interface ClickEvent {
2
+ x: number; // viewport X (0-1280)
3
+ y: number; // viewport Y (0-800)
4
+ timeSec: number; // seconds relative to highlight start
5
+ }
6
+
1
7
  // A highlight is one "moment" in the demo.
2
8
  // Either terminal lines (CLI demo) or a video clip (browser demo).
3
9
  export interface Highlight {
@@ -12,6 +18,9 @@ export interface Highlight {
12
18
  videoSrc?: string; // path to video file (served via staticFile)
13
19
  videoStartSec?: number; // trim: start time in seconds
14
20
  videoEndSec?: number; // trim: end time in seconds
21
+ focusX?: number; // 0-1, focal point X for zoom (default 0.5)
22
+ focusY?: number; // 0-1, focal point Y for zoom (default 0.5)
23
+ clicks?: ClickEvent[]; // click positions for cursor animation
15
24
  }
16
25
 
17
26
  export interface TermLine {