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 +13 -15
- package/bin/agentreel.mjs +40 -176
- package/package.json +5 -5
- package/scripts/browser_demo.py +37 -4
- package/src/CastVideo.tsx +235 -60
- package/src/Root.tsx +5 -2
- package/src/types.ts +9 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# agentreel
|
|
2
2
|
|
|
3
|
-
Turn your
|
|
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
|
-
#
|
|
19
|
-
agentreel
|
|
18
|
+
# CLI demo:
|
|
19
|
+
agentreel --cmd "npx my-cli-tool"
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
|
|
21
|
+
# Browser demo:
|
|
22
|
+
agentreel --url http://localhost:3000
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
agentreel --cmd "npx my-
|
|
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.
|
|
32
|
-
2.
|
|
33
|
-
3.
|
|
34
|
-
4.
|
|
35
|
-
5.
|
|
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,
|
|
5
|
-
import { join, dirname,
|
|
6
|
-
import {
|
|
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") {
|
|
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
|
|
38
|
+
console.log(`agentreel — Turn your web apps and CLIs into viral clips
|
|
36
39
|
|
|
37
40
|
Usage:
|
|
38
|
-
agentreel
|
|
39
|
-
agentreel --
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
4
|
-
"description": "Turn
|
|
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
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
41
|
+
"remotion",
|
|
42
|
+
"screencast",
|
|
43
|
+
"web-app"
|
|
44
44
|
]
|
|
45
45
|
}
|
package/scripts/browser_demo.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
155
|
+
# Run the generated demo
|
|
138
156
|
try:
|
|
139
157
|
local_ns = {}
|
|
140
|
-
full_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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
textAlign: "center",
|
|
128
|
+
top: 16,
|
|
129
|
+
right: 20,
|
|
111
130
|
zIndex: 5,
|
|
112
131
|
fontFamily: MONO,
|
|
113
|
-
fontSize:
|
|
114
|
-
color: "rgba(255,255,255,0.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 +
|
|
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 }> = ({
|
|
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 * (
|
|
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
|
-
|
|
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 * (
|
|
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 * (
|
|
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 *
|
|
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 &&
|
|
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
|
-
|
|
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 * (
|
|
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
|
-
//
|
|
777
|
-
const
|
|
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
|
|
816
|
+
const focalZoomOut = interpolate(
|
|
783
817
|
frame,
|
|
784
|
-
[fps *
|
|
785
|
-
[1.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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 &&
|
|
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 =
|
|
19
|
-
|
|
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 {
|