agentreel 0.1.6 → 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.6",
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",
@@ -105,13 +125,12 @@ export const CastVideo: React.FC<CastProps> = ({
105
125
  <div
106
126
  style={{
107
127
  position: "absolute",
108
- bottom: 28,
109
- width: "100%",
110
- textAlign: "center",
128
+ top: 16,
129
+ right: 20,
111
130
  zIndex: 5,
112
131
  fontFamily: MONO,
113
- fontSize: 12,
114
- color: "rgba(255,255,255,0.18)",
132
+ fontSize: 11,
133
+ color: "rgba(255,255,255,0.2)",
115
134
  letterSpacing: 2,
116
135
  }}
117
136
  >
@@ -124,32 +143,37 @@ export const CastVideo: React.FC<CastProps> = ({
124
143
  <TitleCard title={title} subtitle={subtitle} />
125
144
  </Sequence>
126
145
 
127
- {highlights.map((h, i) => (
128
- <Sequence
129
- key={i}
130
- from={titleFrames + i * highlightFrames}
131
- durationInFrames={highlightFrames}
132
- >
133
- {h.videoSrc ? (
134
- <BrowserHighlightClip
135
- highlight={h}
136
- index={i}
137
- total={highlights.length}
138
- transition={TRANSITIONS[i % TRANSITIONS.length]}
139
- />
140
- ) : (
141
- <HighlightClip
142
- highlight={h}
143
- index={i}
144
- total={highlights.length}
145
- transition={TRANSITIONS[i % TRANSITIONS.length]}
146
- />
147
- )}
148
- </Sequence>
149
- ))}
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
+ })}
150
174
 
151
175
  <Sequence
152
- from={titleFrames + highlights.length * highlightFrames}
176
+ from={titleFrames + cumulative}
153
177
  durationInFrames={endFrames}
154
178
  >
155
179
  <EndCard text={endText || title} url={endUrl} />
@@ -326,12 +350,15 @@ const Cursor: React.FC<{ visible: boolean; blink?: boolean }> = ({
326
350
 
327
351
  // ─── Text Overlay (colored accent words) ──────────────────
328
352
 
329
- const TextOverlay: React.FC<{ text: string }> = ({ text }) => {
353
+ const TextOverlay: React.FC<{ text: string; durationSec: number }> = ({
354
+ text,
355
+ durationSec,
356
+ }) => {
330
357
  const frame = useCurrentFrame();
331
358
  const { fps } = useVideoConfig();
332
359
 
333
360
  const showAt = fps * 1.8;
334
- const hideAt = fps * (HIGHLIGHT_DUR - 0.8);
361
+ const hideAt = fps * (durationSec - 0.8);
335
362
 
336
363
  const enterProgress = spring({
337
364
  fps,
@@ -473,7 +500,8 @@ const HighlightClip: React.FC<{
473
500
  index: number;
474
501
  total: number;
475
502
  transition: TransitionStyle;
476
- }> = ({ highlight, index, total, transition }) => {
503
+ durationSec: number;
504
+ }> = ({ highlight, index, total, transition, durationSec }) => {
477
505
  const frame = useCurrentFrame();
478
506
  const { fps } = useVideoConfig();
479
507
 
@@ -492,7 +520,7 @@ const HighlightClip: React.FC<{
492
520
  });
493
521
  const fadeOut = interpolate(
494
522
  frame,
495
- [fps * (HIGHLIGHT_DUR - TRANSITION_DUR), fps * HIGHLIGHT_DUR],
523
+ [fps * (durationSec - TRANSITION_DUR), fps * durationSec],
496
524
  [1, 0],
497
525
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
498
526
  );
@@ -511,7 +539,7 @@ const HighlightClip: React.FC<{
511
539
  );
512
540
  const zoomOut = interpolate(
513
541
  frame,
514
- [fps * 2.5, fps * (HIGHLIGHT_DUR - 0.5)],
542
+ [fps * 2.5, fps * (durationSec - 0.5)],
515
543
  [1.12, 1.02],
516
544
  {
517
545
  extrapolateLeft: "clamp",
@@ -524,7 +552,7 @@ const HighlightClip: React.FC<{
524
552
  // Vertical pan
525
553
  const panY = interpolate(
526
554
  frame,
527
- [fps * 0.8, fps * 2.0, fps * 3.5],
555
+ [fps * 0.8, fps * 2.0, fps * (durationSec - 1.0)],
528
556
  [0, -15, 5],
529
557
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
530
558
  );
@@ -738,7 +766,9 @@ const HighlightClip: React.FC<{
738
766
  <MousePointer />
739
767
 
740
768
  {/* Text overlay */}
741
- {highlight.overlay && <TextOverlay text={highlight.overlay} />}
769
+ {highlight.overlay && (
770
+ <TextOverlay text={highlight.overlay} durationSec={durationSec} />
771
+ )}
742
772
  </AbsoluteFill>
743
773
  );
744
774
  };
@@ -750,7 +780,8 @@ const BrowserHighlightClip: React.FC<{
750
780
  index: number;
751
781
  total: number;
752
782
  transition: TransitionStyle;
753
- }> = ({ highlight, index, total, transition }) => {
783
+ durationSec: number;
784
+ }> = ({ highlight, index, total, transition, durationSec }) => {
754
785
  const frame = useCurrentFrame();
755
786
  const { fps } = useVideoConfig();
756
787
 
@@ -767,33 +798,37 @@ const BrowserHighlightClip: React.FC<{
767
798
  });
768
799
  const fadeOut = interpolate(
769
800
  frame,
770
- [fps * (HIGHLIGHT_DUR - TRANSITION_DUR), fps * HIGHLIGHT_DUR],
801
+ [fps * (durationSec - TRANSITION_DUR), fps * durationSec],
771
802
  [1, 0],
772
803
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
773
804
  );
774
805
  const opacity = Math.min(fadeIn, fadeOut);
775
806
 
776
- // Zoom in/out cycle
777
- 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], {
778
812
  extrapolateLeft: "clamp",
779
813
  extrapolateRight: "clamp",
780
814
  easing: Easing.out(Easing.cubic),
781
815
  });
782
- const zoomOut = interpolate(
816
+ const focalZoomOut = interpolate(
783
817
  frame,
784
- [fps * 2.5, fps * (HIGHLIGHT_DUR - 0.5)],
785
- [1.08, 1.01],
818
+ [fps * 3.5, fps * (durationSec - 0.5)],
819
+ [1.15, 1.02],
786
820
  {
787
821
  extrapolateLeft: "clamp",
788
822
  extrapolateRight: "clamp",
789
823
  easing: Easing.inOut(Easing.cubic),
790
824
  }
791
825
  );
792
- const zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
826
+ const focalZoom = frame < fps * 3.5 ? focalZoomIn : focalZoomOut;
793
827
 
828
+ // Entry pan
794
829
  const panY = interpolate(
795
830
  frame,
796
- [fps * 0.8, fps * 2.0, fps * 3.5],
831
+ [fps * 1.0, fps * 3.0, fps * (durationSec - 1.0)],
797
832
  [0, -10, 5],
798
833
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
799
834
  );
@@ -861,7 +896,7 @@ const BrowserHighlightClip: React.FC<{
861
896
  >
862
897
  <div
863
898
  style={{
864
- 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)`,
865
900
  transformOrigin: "center center",
866
901
  width: 880,
867
902
  borderRadius: 14,
@@ -883,7 +918,6 @@ const BrowserHighlightClip: React.FC<{
883
918
  <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#ff5555" }} />
884
919
  <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#f1fa8c" }} />
885
920
  <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#50fa7b" }} />
886
- {/* Address bar */}
887
921
  <div
888
922
  style={{
889
923
  flex: 1,
@@ -900,33 +934,174 @@ const BrowserHighlightClip: React.FC<{
900
934
  </div>
901
935
  </div>
902
936
 
903
- {/* Video content */}
937
+ {/* Video content — focal zoom applied here, chrome stays static */}
904
938
  <div
905
939
  style={{
906
940
  width: "100%",
907
941
  aspectRatio: "16/10",
908
942
  backgroundColor: "#fff",
909
943
  overflow: "hidden",
944
+ position: "relative",
910
945
  }}
911
946
  >
912
- <OffthreadVideo
913
- src={staticFile(videoSrc)}
914
- startFrom={startFrom}
915
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
916
- />
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>
917
969
  </div>
918
970
  </div>
919
971
  </AbsoluteFill>
920
972
 
921
- {/* Mouse pointer */}
922
- <MousePointer />
923
-
924
973
  {/* Text overlay */}
925
- {highlight.overlay && <TextOverlay text={highlight.overlay} />}
974
+ {highlight.overlay && (
975
+ <TextOverlay text={highlight.overlay} durationSec={durationSec} />
976
+ )}
926
977
  </AbsoluteFill>
927
978
  );
928
979
  };
929
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
+
930
1105
  // ─── End Card (CTA) ───────────────────────────────────────
931
1106
 
932
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 {