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 +13 -15
- package/bin/agentreel.mjs +40 -176
- package/package.json +5 -5
- package/scripts/browser_demo.py +37 -4
- package/src/CastVideo.tsx +231 -55
- 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",
|
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 +
|
|
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 }> = ({
|
|
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 * (
|
|
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
|
-
|
|
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 * (
|
|
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 * (
|
|
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 *
|
|
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 &&
|
|
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
|
-
|
|
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 * (
|
|
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
|
-
//
|
|
776
|
-
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], {
|
|
777
812
|
extrapolateLeft: "clamp",
|
|
778
813
|
extrapolateRight: "clamp",
|
|
779
814
|
easing: Easing.out(Easing.cubic),
|
|
780
815
|
});
|
|
781
|
-
const
|
|
816
|
+
const focalZoomOut = interpolate(
|
|
782
817
|
frame,
|
|
783
|
-
[fps *
|
|
784
|
-
[1.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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 &&
|
|
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 =
|
|
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 {
|