agentreel 0.5.0 → 0.6.1

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/src/CastVideo.tsx CHANGED
@@ -3,7 +3,6 @@ import {
3
3
  AbsoluteFill,
4
4
  Audio,
5
5
  interpolate,
6
- spring,
7
6
  staticFile,
8
7
  useCurrentFrame,
9
8
  useVideoConfig,
@@ -13,68 +12,63 @@ import {
13
12
  } from "remotion";
14
13
  import { CastProps, Highlight, ClickEvent } from "./types";
15
14
 
16
- const ACCENT = "#50fa7b";
17
- const DIM = "#6272a4";
18
- const WHITE = "#f8f8f2";
19
- const TERM_BG = "#282a36";
20
- const TITLE_BAR = "#1e1f29";
21
- const CURSOR_COLOR = "#f8f8f2";
15
+ // ─── Theme ────────────────────────────────────────────────
22
16
 
23
- // Mode-specific timing (synced with Root.tsx calculateMetadata)
24
- const REEL_TIMING = { title: 2.5, termHighlight: 4.5, browserHighlight: 7.0, transition: 0.5, end: 3.5 };
25
- const DEMO_TIMING = { title: 2.0, termHighlight: 12.0, browserHighlight: 10.0, transition: 0.4, end: 3.0 };
17
+ const SURFACE = "#ffffff";
18
+ const TEXT = "#111111";
19
+ const TEXT_DIM = "#999999";
20
+ const ACCENT = "#22c55e";
21
+ const CARD_BORDER = "rgba(0,0,0,0.06)";
22
+ const CARD_SHADOW = "0 4px 24px rgba(0,0,0,0.06)";
26
23
 
27
- const VIEWPORT_W = 1280;
28
- const VIEWPORT_H = 800;
29
- const VIDEO_AREA_W = 880;
30
- const VIDEO_AREA_H = 550; // 880 * 10/16
24
+ // Terminal light theme
25
+ const TERM_BG = "#f8f8f8";
26
+ const TERM_BAR = "#f0f0f0";
27
+ const TERM_BORDER = "rgba(0,0,0,0.06)";
28
+ const TERM_ACCENT = "#16a34a";
29
+ const TERM_TEXT = "#1a1a1a";
30
+ const TERM_DIM = "#9ca3af";
31
+ const TERM_CURSOR = "#1a1a1a";
31
32
 
32
- function getHighlightDuration(h: Highlight, mode: "reel" | "demo" = "reel"): number {
33
- const t = mode === "demo" ? DEMO_TIMING : REEL_TIMING;
34
- return h.videoSrc ? t.browserHighlight : t.termHighlight;
35
- }
33
+ // ─── Fonts ────────────────────────────────────────────────
36
34
 
37
35
  const SANS =
38
36
  '-apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif';
39
37
  const MONO =
40
38
  '"SF Mono", "Fira Code", "Cascadia Code", "JetBrains Mono", monospace';
41
39
 
42
- // ─── Transition variants ──────────────────────────────────
43
- // Each highlight enters differently to keep the eye engaged.
44
-
45
- type TransitionStyle = "rise" | "zoomIn" | "slideLeft" | "drop";
46
- const TRANSITIONS: TransitionStyle[] = ["rise", "zoomIn", "slideLeft", "drop"];
47
-
48
- function getEntryTransform(
49
- style: TransitionStyle,
50
- progress: number // 0 → 1 spring
51
- ): { scale: number; x: number; y: number } {
52
- switch (style) {
53
- case "rise":
54
- return {
55
- scale: interpolate(progress, [0, 1], [0.82, 1]),
56
- x: 0,
57
- y: interpolate(progress, [0, 1], [80, 0]),
58
- };
59
- case "zoomIn":
60
- return {
61
- scale: interpolate(progress, [0, 1], [0.6, 1]),
62
- x: 0,
63
- y: 0,
64
- };
65
- case "slideLeft":
66
- return {
67
- scale: interpolate(progress, [0, 1], [0.9, 1]),
68
- x: interpolate(progress, [0, 1], [120, 0]),
69
- y: 0,
70
- };
71
- case "drop":
72
- return {
73
- scale: interpolate(progress, [0, 1], [0.85, 1]),
74
- x: 0,
75
- y: interpolate(progress, [0, 1], [-70, 0]),
76
- };
77
- }
40
+ // ─── Timing ───────────────────────────────────────────────
41
+
42
+ const TIMING = {
43
+ title: 2.5, termHighlight: 4.5, browserHighlight: 7.0,
44
+ textSlide: 3.5, diagram: 5.0, tree: 6.0, transition: 0.6, end: 3.5,
45
+ };
46
+
47
+ const VIEWPORT_W = 1280;
48
+ const VIEWPORT_H = 800;
49
+ const VIDEO_AREA_W = 880;
50
+ const VIDEO_AREA_H = 550;
51
+
52
+ function getHighlightDuration(h: Highlight): number {
53
+ if (h.statement) return TIMING.textSlide;
54
+ if (h.diagram) return TIMING.diagram;
55
+ if (h.panels) return TIMING.diagram;
56
+ if (h.tree) return TIMING.tree;
57
+ return h.videoSrc ? TIMING.browserHighlight : TIMING.termHighlight;
58
+ }
59
+
60
+ // Shared eased entry
61
+ function useEntry(fps: number, frame: number) {
62
+ const progress = interpolate(frame, [0, fps * 0.5], [0, 1], {
63
+ extrapolateLeft: "clamp",
64
+ extrapolateRight: "clamp",
65
+ easing: Easing.out(Easing.cubic),
66
+ });
67
+ return {
68
+ scale: interpolate(progress, [0, 1], [0.99, 1]),
69
+ y: interpolate(progress, [0, 1], [6, 0]),
70
+ opacity: progress,
71
+ };
78
72
  }
79
73
 
80
74
  // ─── Main Composition ─────────────────────────────────────
@@ -85,23 +79,13 @@ export const CastVideo: React.FC<CastProps> = ({
85
79
  highlights,
86
80
  endText,
87
81
  endUrl,
88
- gradient,
89
- mode: rawMode,
90
82
  }) => {
91
- const frame = useCurrentFrame();
92
- const { fps, durationInFrames } = useVideoConfig();
93
- const mode = rawMode || "reel";
94
- const isDemo = mode === "demo";
95
- const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
96
- const g = gradient || ["#0f0f1a", "#1a0f2e"];
83
+ const { fps } = useVideoConfig();
97
84
 
98
- const titleFrames = Math.round(timing.title * fps);
99
- const endFrames = Math.round(timing.end * fps);
85
+ const titleFrames = Math.round(TIMING.title * fps);
86
+ const endFrames = Math.round(TIMING.end * fps);
100
87
 
101
- // Compute per-highlight durations and cumulative offsets
102
- const hlDurations = highlights.map((h) =>
103
- Math.round(getHighlightDuration(h, mode) * fps)
104
- );
88
+ const hlDurations = highlights.map((h) => Math.round(getHighlightDuration(h) * fps));
105
89
  const hlOffsets: number[] = [];
106
90
  let cumulative = 0;
107
91
  for (const dur of hlDurations) {
@@ -109,125 +93,42 @@ export const CastVideo: React.FC<CastProps> = ({
109
93
  cumulative += dur;
110
94
  }
111
95
 
112
- // Animated gradient — hue rotates slowly over time
113
- const gradAngle = isDemo
114
- ? 145 // static angle for demo
115
- : interpolate(frame, [0, durationInFrames], [125, 200], {
116
- extrapolateRight: "clamp",
117
- });
118
-
119
96
  return (
120
- <AbsoluteFill
121
- style={{
122
- background: `linear-gradient(${gradAngle}deg, ${g[0]}, ${g[1]}, ${g[0]})`,
123
- backgroundSize: "200% 200%",
124
- }}
125
- >
126
- {/* Subtle animated glow blobs — reel only */}
127
- {!isDemo && <AnimatedBackground frame={frame} duration={durationInFrames} />}
128
-
129
- {/* Music — reel only */}
130
- {!isDemo && <MusicTrack />}
97
+ <AbsoluteFill style={{ backgroundColor: SURFACE }}>
98
+ <MusicTrack />
131
99
 
132
100
  <Sequence durationInFrames={titleFrames}>
133
- <TitleCard title={title} subtitle={subtitle} isDemo={isDemo} />
101
+ <TitleCard title={title} subtitle={subtitle} />
134
102
  </Sequence>
135
103
 
136
104
  {highlights.map((h, i) => {
137
- const dur = getHighlightDuration(h, mode);
105
+ const dur = getHighlightDuration(h);
138
106
  return (
139
- <Sequence
140
- key={i}
141
- from={titleFrames + hlOffsets[i]}
142
- durationInFrames={hlDurations[i]}
143
- >
144
- {h.videoSrc ? (
145
- <BrowserHighlightClip
146
- highlight={h}
147
- index={i}
148
- total={highlights.length}
149
- transition={TRANSITIONS[i % TRANSITIONS.length]}
150
- durationSec={dur}
151
- isDemo={isDemo}
152
- />
107
+ <Sequence key={i} from={titleFrames + hlOffsets[i]} durationInFrames={hlDurations[i]}>
108
+ {h.statement ? (
109
+ <TextSlideClip highlight={h} durationSec={dur} />
110
+ ) : h.panels ? (
111
+ <PanelsClip highlight={h} durationSec={dur} />
112
+ ) : h.tree ? (
113
+ <TreeClip highlight={h} durationSec={dur} />
114
+ ) : h.diagram ? (
115
+ <DiagramClip highlight={h} durationSec={dur} />
116
+ ) : h.videoSrc ? (
117
+ <BrowserHighlightClip highlight={h} durationSec={dur} />
153
118
  ) : (
154
- <HighlightClip
155
- highlight={h}
156
- index={i}
157
- total={highlights.length}
158
- transition={TRANSITIONS[i % TRANSITIONS.length]}
159
- durationSec={dur}
160
- isDemo={isDemo}
161
- />
119
+ <HighlightClip highlight={h} durationSec={dur} />
162
120
  )}
163
121
  </Sequence>
164
122
  );
165
123
  })}
166
124
 
167
- <Sequence
168
- from={titleFrames + cumulative}
169
- durationInFrames={endFrames}
170
- >
171
- <EndCard text={endText || title} url={endUrl} isDemo={isDemo} />
125
+ <Sequence from={titleFrames + cumulative} durationInFrames={endFrames}>
126
+ <EndCard text={endText || title} url={endUrl} />
172
127
  </Sequence>
173
128
  </AbsoluteFill>
174
129
  );
175
130
  };
176
131
 
177
- // ─── Animated Background ──────────────────────────────────
178
-
179
- const AnimatedBackground: React.FC<{
180
- frame: number;
181
- duration: number;
182
- }> = ({ frame, duration }) => {
183
- // Two soft gradient blobs that drift slowly
184
- const blob1X = interpolate(frame, [0, duration], [20, 60], {
185
- extrapolateRight: "clamp",
186
- });
187
- const blob1Y = interpolate(frame, [0, duration], [30, 50], {
188
- extrapolateRight: "clamp",
189
- });
190
- const blob2X = interpolate(frame, [0, duration], [70, 35], {
191
- extrapolateRight: "clamp",
192
- });
193
- const blob2Y = interpolate(frame, [0, duration], [60, 30], {
194
- extrapolateRight: "clamp",
195
- });
196
-
197
- return (
198
- <AbsoluteFill style={{ opacity: 0.3 }}>
199
- <div
200
- style={{
201
- position: "absolute",
202
- width: 500,
203
- height: 500,
204
- borderRadius: "50%",
205
- background:
206
- "radial-gradient(circle, rgba(80,250,123,0.15) 0%, transparent 70%)",
207
- left: `${blob1X}%`,
208
- top: `${blob1Y}%`,
209
- transform: "translate(-50%, -50%)",
210
- filter: "blur(80px)",
211
- }}
212
- />
213
- <div
214
- style={{
215
- position: "absolute",
216
- width: 400,
217
- height: 400,
218
- borderRadius: "50%",
219
- background:
220
- "radial-gradient(circle, rgba(189,147,249,0.12) 0%, transparent 70%)",
221
- left: `${blob2X}%`,
222
- top: `${blob2Y}%`,
223
- transform: "translate(-50%, -50%)",
224
- filter: "blur(80px)",
225
- }}
226
- />
227
- </AbsoluteFill>
228
- );
229
- };
230
-
231
132
  // ─── Music ────────────────────────────────────────────────
232
133
 
233
134
  const MusicTrack: React.FC = () => {
@@ -246,74 +147,12 @@ const MusicTrack: React.FC = () => {
246
147
  );
247
148
 
248
149
  try {
249
- return (
250
- <Audio src={staticFile("music.mp3")} volume={Math.min(fadeIn, fadeOut)} />
251
- );
150
+ return <Audio src={staticFile("music.mp3")} volume={Math.min(fadeIn, fadeOut)} />;
252
151
  } catch {
253
152
  return null;
254
153
  }
255
154
  };
256
155
 
257
- // ─── Mouse Pointer ────────────────────────────────────────
258
- // macOS-style cursor that moves to the terminal and "clicks" before typing starts.
259
-
260
- const MousePointer: React.FC = () => {
261
- const frame = useCurrentFrame();
262
- const { fps } = useVideoConfig();
263
-
264
- // Pointer moves in from bottom-right, arrives at terminal center, clicks, then fades
265
- const moveEnd = fps * 0.6;
266
- const clickFrame = fps * 0.7;
267
- const fadeStart = fps * 1.0;
268
- const fadeEnd = fps * 1.3;
269
-
270
- const moveProgress = interpolate(frame, [0, moveEnd], [0, 1], {
271
- extrapolateLeft: "clamp",
272
- extrapolateRight: "clamp",
273
- easing: Easing.out(Easing.cubic),
274
- });
275
-
276
- const x = interpolate(moveProgress, [0, 1], [750, 450]);
277
- const y = interpolate(moveProgress, [0, 1], [800, 480]);
278
-
279
- const opacity = interpolate(frame, [0, 4, fadeStart, fadeEnd], [0, 1, 1, 0], {
280
- extrapolateLeft: "clamp",
281
- extrapolateRight: "clamp",
282
- });
283
-
284
- // Click effect — brief scale down
285
- const isClicking = frame >= clickFrame && frame < clickFrame + 4;
286
- const clickScale = isClicking ? 0.85 : 1;
287
-
288
- if (opacity <= 0) return null;
289
-
290
- return (
291
- <div
292
- style={{
293
- position: "absolute",
294
- left: x,
295
- top: y,
296
- zIndex: 100,
297
- opacity,
298
- transform: `scale(${clickScale})`,
299
- transformOrigin: "top left",
300
- pointerEvents: "none",
301
- }}
302
- >
303
- {/* macOS cursor SVG */}
304
- <svg width="24" height="28" viewBox="0 0 24 28" fill="none">
305
- <path
306
- d="M2 2L2 22L7.5 16.5L12.5 26L16 24.5L11 15H19L2 2Z"
307
- fill="white"
308
- stroke="black"
309
- strokeWidth="1.5"
310
- strokeLinejoin="round"
311
- />
312
- </svg>
313
- </div>
314
- );
315
- };
316
-
317
156
  // ─── Cursor ───────────────────────────────────────────────
318
157
 
319
158
  const Cursor: React.FC<{ visible: boolean; blink?: boolean }> = ({
@@ -331,7 +170,7 @@ const Cursor: React.FC<{ visible: boolean; blink?: boolean }> = ({
331
170
  display: "inline-block",
332
171
  width: 9,
333
172
  height: 19,
334
- backgroundColor: blinkOn ? CURSOR_COLOR : "transparent",
173
+ backgroundColor: blinkOn ? TERM_CURSOR : "transparent",
335
174
  marginLeft: 1,
336
175
  verticalAlign: "text-bottom",
337
176
  borderRadius: 1,
@@ -340,7 +179,7 @@ const Cursor: React.FC<{ visible: boolean; blink?: boolean }> = ({
340
179
  );
341
180
  };
342
181
 
343
- // ─── Text Overlay (colored accent words) ──────────────────
182
+ // ─── Text Overlay (browser clips only) ───────────────────
344
183
 
345
184
  const TextOverlay: React.FC<{ text: string; durationSec: number }> = ({
346
185
  text,
@@ -352,57 +191,46 @@ const TextOverlay: React.FC<{ text: string; durationSec: number }> = ({
352
191
  const showAt = fps * 1.8;
353
192
  const hideAt = fps * (durationSec - 0.8);
354
193
 
355
- const enterProgress = spring({
356
- fps,
357
- frame: Math.max(0, frame - showAt),
358
- config: { damping: 16, stiffness: 100 },
194
+ const enterProgress = interpolate(frame, [showAt, showAt + fps * 0.5], [0, 1], {
195
+ extrapolateLeft: "clamp",
196
+ extrapolateRight: "clamp",
197
+ easing: Easing.out(Easing.cubic),
359
198
  });
360
199
  const exitOpacity = interpolate(frame, [hideAt, hideAt + fps * 0.3], [1, 0], {
361
200
  extrapolateLeft: "clamp",
362
201
  extrapolateRight: "clamp",
363
202
  });
364
203
 
365
- const opacity = Math.min(enterProgress, exitOpacity);
366
- const y = interpolate(enterProgress, [0, 1], [20, 0]);
367
- const scale = interpolate(enterProgress, [0, 1], [0.9, 1]);
368
-
369
204
  if (frame < showAt) return null;
370
205
 
371
- // Parse **bold** syntax for colored accent words
372
206
  const parts = text.split(/(\*\*.*?\*\*)/);
373
207
 
374
208
  return (
375
209
  <div
376
210
  style={{
377
211
  position: "absolute",
378
- bottom: 55,
212
+ bottom: 80,
379
213
  left: 0,
380
214
  width: "100%",
381
215
  textAlign: "center",
382
216
  zIndex: 20,
383
- opacity,
384
- transform: `translateY(${y}px) scale(${scale})`,
217
+ opacity: Math.min(enterProgress, exitOpacity),
385
218
  }}
386
219
  >
387
220
  <span
388
221
  style={{
389
222
  fontFamily: SANS,
390
- fontSize: 36,
391
- fontWeight: 700,
392
- color: WHITE,
393
- backgroundColor: "rgba(0,0,0,0.55)",
394
- backdropFilter: "blur(12px)",
395
- WebkitBackdropFilter: "blur(12px)",
396
- padding: "12px 30px",
397
- borderRadius: 12,
398
- letterSpacing: -0.5,
223
+ fontSize: 42,
224
+ fontWeight: 600,
225
+ color: TEXT,
226
+ letterSpacing: -1,
399
227
  display: "inline-block",
400
228
  }}
401
229
  >
402
230
  {parts.map((part, i) => {
403
231
  if (part.startsWith("**") && part.endsWith("**")) {
404
232
  return (
405
- <span key={i} style={{ color: ACCENT, fontWeight: 800 }}>
233
+ <span key={i} style={{ color: ACCENT, fontWeight: 700 }}>
406
234
  {part.slice(2, -2)}
407
235
  </span>
408
236
  );
@@ -416,301 +244,954 @@ const TextOverlay: React.FC<{ text: string; durationSec: number }> = ({
416
244
 
417
245
  // ─── Title Card ───────────────────────────────────────────
418
246
 
419
- const TitleCard: React.FC<{ title: string; subtitle?: string; isDemo?: boolean }> = ({
247
+ const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({
420
248
  title,
421
249
  subtitle,
422
- isDemo,
423
250
  }) => {
424
251
  const frame = useCurrentFrame();
425
252
  const { fps } = useVideoConfig();
426
- const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
427
253
 
428
- const titleSpring = spring({ fps, frame, config: { damping: 14 } });
429
- const subSpring = spring({
430
- fps,
431
- frame: Math.max(0, frame - 8),
432
- config: { damping: 14 },
433
- });
434
254
  const fadeOut = interpolate(
435
255
  frame,
436
- [fps * (timing.title - timing.transition), fps * timing.title],
256
+ [fps * (TIMING.title - TIMING.transition), fps * TIMING.title],
437
257
  [1, 0],
438
258
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
439
259
  );
440
- const titleZoom = isDemo ? 1 : interpolate(frame, [0, fps * timing.title], [1, 1.08], {
260
+
261
+ const words = title.split(" ");
262
+ const WORD_STAGGER = 5;
263
+ const WORD_DUR = 14;
264
+ const lastWordStart = (words.length - 1) * WORD_STAGGER;
265
+ const subtitleStart = lastWordStart + WORD_DUR + 4;
266
+
267
+ return (
268
+ <AbsoluteFill style={{ opacity: fadeOut, justifyContent: "center", alignItems: "center" }}>
269
+ <div
270
+ style={{
271
+ textAlign: "center",
272
+ maxWidth: "65%",
273
+ display: "flex",
274
+ flexWrap: "wrap",
275
+ justifyContent: "center",
276
+ gap: "0 16px",
277
+ }}
278
+ >
279
+ {words.map((word, i) => {
280
+ const start = i * WORD_STAGGER;
281
+ const progress = interpolate(frame, [start, start + WORD_DUR], [0, 1], {
282
+ extrapolateLeft: "clamp",
283
+ extrapolateRight: "clamp",
284
+ easing: Easing.out(Easing.cubic),
285
+ });
286
+ return (
287
+ <span
288
+ key={i}
289
+ style={{
290
+ fontFamily: SANS,
291
+ fontSize: 72,
292
+ fontWeight: 700,
293
+ color: TEXT,
294
+ letterSpacing: -3,
295
+ lineHeight: 1.1,
296
+ opacity: progress,
297
+ transform: `translateY(${interpolate(progress, [0, 1], [8, 0])}px)`,
298
+ display: "inline-block",
299
+ }}
300
+ >
301
+ {word}
302
+ </span>
303
+ );
304
+ })}
305
+ </div>
306
+
307
+ {subtitle && (
308
+ <div
309
+ style={{
310
+ marginTop: 28,
311
+ textAlign: "center",
312
+ opacity: interpolate(frame, [subtitleStart, subtitleStart + 14], [0, 1], {
313
+ extrapolateLeft: "clamp",
314
+ extrapolateRight: "clamp",
315
+ easing: Easing.out(Easing.cubic),
316
+ }),
317
+ transform: `translateY(${interpolate(frame, [subtitleStart, subtitleStart + 14], [6, 0], {
318
+ extrapolateLeft: "clamp",
319
+ extrapolateRight: "clamp",
320
+ easing: Easing.out(Easing.cubic),
321
+ })}px)`,
322
+ fontFamily: SANS,
323
+ fontSize: 22,
324
+ fontWeight: 400,
325
+ color: TEXT_DIM,
326
+ letterSpacing: 0.2,
327
+ }}
328
+ >
329
+ {subtitle}
330
+ </div>
331
+ )}
332
+ </AbsoluteFill>
333
+ );
334
+ };
335
+
336
+ // ─── Text Slide Clip ─────────────────────────────────────
337
+
338
+ const TextSlideClip: React.FC<{
339
+ highlight: Highlight;
340
+ durationSec: number;
341
+ }> = ({ highlight, durationSec }) => {
342
+ const frame = useCurrentFrame();
343
+ const { fps } = useVideoConfig();
344
+
345
+ const lines = (highlight.statement || "").split("\n");
346
+ const LINE_STAGGER = 8;
347
+ const LINE_DUR = 16;
348
+
349
+ const fadeIn = interpolate(frame, [0, fps * 0.2], [0, 1], {
441
350
  extrapolateLeft: "clamp",
442
351
  extrapolateRight: "clamp",
443
352
  });
353
+ const fadeOut = interpolate(
354
+ frame,
355
+ [fps * (durationSec - 0.5), fps * durationSec],
356
+ [1, 0],
357
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
358
+ );
444
359
 
445
360
  return (
446
361
  <AbsoluteFill
447
362
  style={{
448
- opacity: fadeOut,
363
+ opacity: Math.min(fadeIn, fadeOut),
449
364
  justifyContent: "center",
450
365
  alignItems: "center",
451
366
  }}
452
367
  >
368
+ <div style={{ textAlign: "center", maxWidth: "65%", display: "flex", flexDirection: "column", gap: 8 }}>
369
+ {lines.map((line, i) => {
370
+ const start = i * LINE_STAGGER;
371
+ const progress = interpolate(frame, [start, start + LINE_DUR], [0, 1], {
372
+ extrapolateLeft: "clamp",
373
+ extrapolateRight: "clamp",
374
+ easing: Easing.out(Easing.cubic),
375
+ });
376
+ return (
377
+ <div
378
+ key={i}
379
+ style={{
380
+ fontFamily: SANS,
381
+ fontSize: 56,
382
+ fontWeight: 600,
383
+ color: TEXT,
384
+ letterSpacing: -1.5,
385
+ lineHeight: 1.25,
386
+ opacity: progress,
387
+ transform: `translateY(${interpolate(progress, [0, 1], [8, 0])}px)`,
388
+ }}
389
+ >
390
+ {line}
391
+ </div>
392
+ );
393
+ })}
394
+ </div>
395
+ </AbsoluteFill>
396
+ );
397
+ };
398
+
399
+ // ─── Panels Clip (side-by-side) ──────────────────────────
400
+
401
+ const PanelsClip: React.FC<{
402
+ highlight: Highlight;
403
+ durationSec: number;
404
+ }> = ({ highlight, durationSec }) => {
405
+ const frame = useCurrentFrame();
406
+ const { fps } = useVideoConfig();
407
+ const panels = highlight.panels!;
408
+
409
+ const fadeIn = interpolate(frame, [0, fps * 0.3], [0, 1], {
410
+ extrapolateLeft: "clamp",
411
+ extrapolateRight: "clamp",
412
+ easing: Easing.out(Easing.cubic),
413
+ });
414
+ const fadeOut = interpolate(
415
+ frame,
416
+ [fps * (durationSec - 0.5), fps * durationSec],
417
+ [1, 0],
418
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
419
+ );
420
+
421
+ const leftProgress = interpolate(frame, [fps * 0.15, fps * 0.6], [0, 1], {
422
+ extrapolateLeft: "clamp",
423
+ extrapolateRight: "clamp",
424
+ easing: Easing.out(Easing.cubic),
425
+ });
426
+ const rightProgress = interpolate(frame, [fps * 0.35, fps * 0.8], [0, 1], {
427
+ extrapolateLeft: "clamp",
428
+ extrapolateRight: "clamp",
429
+ easing: Easing.out(Easing.cubic),
430
+ });
431
+
432
+ const renderPanel = (
433
+ panel: { title: string; stat?: string; statLabel?: string; content: string; color?: string },
434
+ progress: number,
435
+ lineBaseDelay: number,
436
+ ) => {
437
+ const contentLines = panel.content.split("\n");
438
+ const accent = panel.color || ACCENT;
439
+
440
+ // Counting stat — parse numeric value and count up synced with checkmarks
441
+ const statNum = panel.stat ? parseInt(panel.stat, 10) : NaN;
442
+ const isNumericStat = !isNaN(statNum) && statNum > 0;
443
+ const lastCheckDelay = lineBaseDelay + (contentLines.length - 1) * fps * 0.12 + fps * 0.15;
444
+ const countDisplay = isNumericStat
445
+ ? Math.min(
446
+ statNum,
447
+ Math.ceil(
448
+ interpolate(
449
+ frame,
450
+ [lineBaseDelay, lastCheckDelay + fps * 0.1],
451
+ [0, statNum],
452
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
453
+ )
454
+ )
455
+ )
456
+ : null;
457
+
458
+ return (
453
459
  <div
454
460
  style={{
455
- transform: `scale(${titleSpring * titleZoom}) translateY(${interpolate(titleSpring, [0, 1], [30, 0])}px)`,
456
- textAlign: "center",
461
+ width: 460,
462
+ opacity: progress,
463
+ transform: `translateY(${interpolate(progress, [0, 1], [12, 0])}px)`,
464
+ borderRadius: 24,
465
+ backgroundColor: SURFACE,
466
+ boxShadow: "0 8px 40px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.04)",
467
+ padding: "40px 44px",
468
+ display: "flex",
469
+ flexDirection: "column",
457
470
  }}
458
471
  >
472
+ {/* Big counting stat */}
473
+ {panel.stat && (
474
+ <div style={{ marginBottom: 8 }}>
475
+ <span
476
+ style={{
477
+ fontFamily: SANS,
478
+ fontSize: 72,
479
+ fontWeight: 800,
480
+ color: accent,
481
+ letterSpacing: -3,
482
+ lineHeight: 1,
483
+ }}
484
+ >
485
+ {countDisplay !== null ? countDisplay : panel.stat}
486
+ </span>
487
+ {panel.statLabel && (
488
+ <span
489
+ style={{
490
+ fontFamily: SANS,
491
+ fontSize: 22,
492
+ fontWeight: 400,
493
+ color: TEXT_DIM,
494
+ marginLeft: 12,
495
+ letterSpacing: -0.3,
496
+ }}
497
+ >
498
+ {panel.statLabel}
499
+ </span>
500
+ )}
501
+ </div>
502
+ )}
503
+
504
+ {/* Title */}
459
505
  <div
460
506
  style={{
461
507
  fontFamily: SANS,
462
- fontSize: isDemo ? 56 : 76,
463
- fontWeight: 800,
464
- color: WHITE,
465
- letterSpacing: -3,
508
+ fontSize: 14,
509
+ fontWeight: 500,
510
+ color: TEXT_DIM,
511
+ letterSpacing: 3,
512
+ textTransform: "uppercase",
513
+ marginBottom: 28,
514
+ marginTop: panel.stat ? 4 : 0,
466
515
  }}
467
516
  >
468
- {title}
517
+ {panel.title}
518
+ </div>
519
+
520
+ {/* Content lines with checkmarks */}
521
+ <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
522
+ {contentLines.map((line, i) => {
523
+ const lineDelay = lineBaseDelay + i * fps * 0.12;
524
+ const lineProgress = interpolate(
525
+ frame, [lineDelay, lineDelay + fps * 0.25], [0, 1],
526
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) }
527
+ );
528
+ const checkDelay = lineDelay + fps * 0.15;
529
+ const checkProgress = interpolate(
530
+ frame, [checkDelay, checkDelay + fps * 0.2], [0, 1],
531
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) }
532
+ );
533
+ return (
534
+ <div
535
+ key={i}
536
+ style={{
537
+ display: "flex",
538
+ alignItems: "center",
539
+ gap: 14,
540
+ opacity: lineProgress,
541
+ }}
542
+ >
543
+ {/* Animated checkmark */}
544
+ <div
545
+ style={{
546
+ width: 22,
547
+ height: 22,
548
+ borderRadius: 11,
549
+ backgroundColor: `${accent}12`,
550
+ border: `1.5px solid ${accent}30`,
551
+ display: "flex",
552
+ alignItems: "center",
553
+ justifyContent: "center",
554
+ flexShrink: 0,
555
+ transform: `scale(${checkProgress})`,
556
+ }}
557
+ >
558
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"
559
+ style={{ opacity: checkProgress }}
560
+ >
561
+ <path
562
+ d="M2.5 6L5 8.5L9.5 3.5"
563
+ stroke={accent}
564
+ strokeWidth="1.5"
565
+ strokeLinecap="round"
566
+ strokeLinejoin="round"
567
+ />
568
+ </svg>
569
+ </div>
570
+ <div
571
+ style={{
572
+ fontFamily: SANS,
573
+ fontSize: 20,
574
+ fontWeight: 400,
575
+ color: "rgba(0,0,0,0.6)",
576
+ lineHeight: 1.4,
577
+ }}
578
+ >
579
+ {line}
580
+ </div>
581
+ </div>
582
+ );
583
+ })}
469
584
  </div>
470
- {subtitle && (
471
- <div
472
- style={{
473
- transform: `translateY(${interpolate(subSpring, [0, 1], [20, 0])}px)`,
474
- opacity: subSpring,
475
- fontFamily: SANS,
476
- fontSize: isDemo ? 24 : 28,
477
- color: DIM,
478
- marginTop: 16,
479
- letterSpacing: 1,
480
- }}
481
- >
482
- {subtitle}
483
- </div>
484
- )}
585
+ </div>
586
+ );
587
+ };
588
+
589
+ return (
590
+ <AbsoluteFill
591
+ style={{
592
+ opacity: Math.min(fadeIn, fadeOut),
593
+ justifyContent: "center",
594
+ alignItems: "center",
595
+ }}
596
+ >
597
+ <div style={{ display: "flex", gap: 40, alignItems: "flex-start" }}>
598
+ {renderPanel(panels.left, leftProgress, fps * 0.4)}
599
+ {renderPanel(panels.right, rightProgress, fps * 0.6)}
485
600
  </div>
486
601
  </AbsoluteFill>
487
602
  );
488
603
  };
489
604
 
490
- // ─── Highlight Clip ───────────────────────────────────────
605
+ // ─── Diagram Clip ────────────────────────────────────────
491
606
 
492
- const HighlightClip: React.FC<{
607
+ const DiagramClip: React.FC<{
493
608
  highlight: Highlight;
494
- index: number;
495
- total: number;
496
- transition: TransitionStyle;
497
609
  durationSec: number;
498
- isDemo?: boolean;
499
- }> = ({ highlight, index, total, transition, durationSec, isDemo }) => {
610
+ }> = ({ highlight, durationSec }) => {
500
611
  const frame = useCurrentFrame();
501
- const { fps, width } = useVideoConfig();
502
- const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
612
+ const { fps } = useVideoConfig();
613
+ const diagram = highlight.diagram!;
503
614
 
504
- // Entry animation — simpler in demo mode
505
- const enterSpring = spring({
506
- fps,
507
- frame,
508
- config: isDemo ? { damping: 22, stiffness: 120 } : { damping: 18, stiffness: 80 },
509
- });
510
- const entry = isDemo
511
- ? { scale: 1, x: 0, y: 0 } // no transform in demo
512
- : getEntryTransform(transition, enterSpring);
615
+ const containerW = 700;
616
+ const containerH = 500;
617
+
618
+ const sortedNodes = [...diagram.nodes].sort((a, b) => a.x - b.x);
619
+ const nodeIndexMap = new Map(sortedNodes.map((n, i) => [n.id, i]));
513
620
 
514
- // Fade transitions
515
- const fadeIn = interpolate(frame, [0, fps * timing.transition], [0, 1], {
621
+ const NODE_STAGGER = fps * 0.12;
622
+ const NODE_DUR = fps * 0.35;
623
+ const FIRST_NODE = fps * 0.4;
624
+
625
+ const fadeIn = interpolate(frame, [0, fps * 0.3], [0, 1], {
516
626
  extrapolateLeft: "clamp",
517
627
  extrapolateRight: "clamp",
628
+ easing: Easing.out(Easing.cubic),
518
629
  });
519
630
  const fadeOut = interpolate(
520
631
  frame,
521
- [fps * (durationSec - timing.transition), fps * durationSec],
632
+ [fps * (durationSec - 0.5), fps * durationSec],
522
633
  [1, 0],
523
634
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
524
635
  );
525
- const opacity = Math.min(fadeIn, fadeOut);
526
-
527
- // Zoom/pan — reel only
528
- let zoom = 1;
529
- let panY = 0;
530
- if (!isDemo) {
531
- const zoomIn = interpolate(frame, [fps * 0.8, fps * 2.0], [1, 1.12], {
532
- extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic),
533
- });
534
- const zoomOut = interpolate(frame, [fps * 2.5, fps * (durationSec - 0.5)], [1.12, 1.02], {
535
- extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic),
536
- });
537
- zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
538
- panY = interpolate(frame, [fps * 0.8, fps * 2.0, fps * (durationSec - 1.0)], [0, -15, 5], {
539
- extrapolateLeft: "clamp", extrapolateRight: "clamp",
540
- });
636
+
637
+ return (
638
+ <AbsoluteFill
639
+ style={{
640
+ opacity: Math.min(fadeIn, fadeOut),
641
+ justifyContent: "center",
642
+ alignItems: "center",
643
+ }}
644
+ >
645
+ <div style={{ position: "relative", width: containerW, height: containerH }}>
646
+ <svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}>
647
+ {diagram.edges.map((edge, i) => {
648
+ const fromNode = diagram.nodes.find((n) => n.id === edge.from);
649
+ const toNode = diagram.nodes.find((n) => n.id === edge.to);
650
+ if (!fromNode || !toNode) return null;
651
+
652
+ const x1 = fromNode.x * containerW;
653
+ const y1 = fromNode.y * containerH;
654
+ const x2 = toNode.x * containerW;
655
+ const y2 = toNode.y * containerH;
656
+ const length = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
657
+
658
+ const fromIdx = nodeIndexMap.get(edge.from) ?? 0;
659
+ const toIdx = nodeIndexMap.get(edge.to) ?? 0;
660
+ const edgeStart = FIRST_NODE + Math.max(fromIdx, toIdx) * NODE_STAGGER + fps * 0.15;
661
+
662
+ const edgeProgress = interpolate(frame, [edgeStart, edgeStart + fps * 0.5], [0, 1], {
663
+ extrapolateLeft: "clamp",
664
+ extrapolateRight: "clamp",
665
+ easing: Easing.out(Easing.cubic),
666
+ });
667
+
668
+ return (
669
+ <line
670
+ key={i}
671
+ x1={x1} y1={y1} x2={x2} y2={y2}
672
+ stroke="rgba(0,0,0,0.08)"
673
+ strokeWidth={1}
674
+ strokeDasharray={length}
675
+ strokeDashoffset={length * (1 - edgeProgress)}
676
+ strokeLinecap="round"
677
+ />
678
+ );
679
+ })}
680
+ </svg>
681
+
682
+ {sortedNodes.map((node, i) => {
683
+ const nodeStart = FIRST_NODE + i * NODE_STAGGER;
684
+ const nodeProgress = interpolate(frame, [nodeStart, nodeStart + NODE_DUR], [0, 1], {
685
+ extrapolateLeft: "clamp",
686
+ extrapolateRight: "clamp",
687
+ easing: Easing.out(Easing.cubic),
688
+ });
689
+
690
+ return (
691
+ <div
692
+ key={node.id}
693
+ style={{
694
+ position: "absolute",
695
+ left: node.x * containerW,
696
+ top: node.y * containerH,
697
+ transform: `translate(-50%, -50%) scale(${interpolate(nodeProgress, [0, 1], [0.4, 1])})`,
698
+ opacity: nodeProgress,
699
+ display: "flex",
700
+ flexDirection: "column",
701
+ alignItems: "center",
702
+ gap: 10,
703
+ }}
704
+ >
705
+ <div
706
+ style={{
707
+ width: 8,
708
+ height: 8,
709
+ borderRadius: 4,
710
+ backgroundColor: node.color || ACCENT,
711
+ }}
712
+ />
713
+ <span
714
+ style={{
715
+ fontFamily: SANS,
716
+ fontSize: 14,
717
+ fontWeight: 500,
718
+ color: TEXT,
719
+ whiteSpace: "nowrap",
720
+ letterSpacing: 0.3,
721
+ opacity: 0.5,
722
+ }}
723
+ >
724
+ {node.label}
725
+ </span>
726
+ </div>
727
+ );
728
+ })}
729
+ </div>
730
+ </AbsoluteFill>
731
+ );
732
+ };
733
+
734
+ // ─── Tree Clip (full-screen scrolling organic tree) ──────
735
+
736
+ function seededRand(seed: number): number {
737
+ const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
738
+ return x - Math.floor(x);
739
+ }
740
+
741
+ interface OBranch {
742
+ x1: number; y1: number;
743
+ x2: number; y2: number;
744
+ cx: number; cy: number;
745
+ depth: number;
746
+ }
747
+
748
+ interface ONode {
749
+ x: number; y: number;
750
+ depth: number;
751
+ label?: string;
752
+ idx: number;
753
+ }
754
+
755
+ function generateOrganicTree(
756
+ rootLabel: string,
757
+ depth: number,
758
+ branching: number | number[],
759
+ nodeLabels: string[][] | undefined,
760
+ W: number,
761
+ ) {
762
+ const branches: OBranch[] = [];
763
+ const nodes: ONode[] = [];
764
+ let nodeCount = 0;
765
+ let seedCounter = 0;
766
+ const rand = () => seededRand(seedCounter++);
767
+
768
+ const branchAt = (level: number) => {
769
+ if (typeof branching === "number") return branching;
770
+ return branching[Math.min(level, branching.length - 1)] ?? 3;
771
+ };
772
+
773
+ const labelCounters: number[] = [];
774
+ const labelAt = (level: number): string | undefined => {
775
+ if (!labelCounters[level]) labelCounters[level] = 0;
776
+ const idx = labelCounters[level]++;
777
+ const lvlLabels = nodeLabels?.[level];
778
+ if (!lvlLabels) return undefined;
779
+ return lvlLabels[idx % lvlLabels.length];
780
+ };
781
+
782
+ const rootX = W / 2;
783
+ const rootY = 60;
784
+ nodes.push({ x: rootX, y: rootY, depth: 0, label: rootLabel, idx: nodeCount++ });
785
+
786
+ function grow(px: number, py: number, angle: number, level: number, spreadMul: number) {
787
+ if (level >= depth) return;
788
+ const b = branchAt(level);
789
+
790
+ // Tall branches — tree must exceed the frame height for scroll
791
+ const baseLengths = [550, 400, 280, 190, 130];
792
+ const baseLen = baseLengths[Math.min(level, baseLengths.length - 1)];
793
+ const spreadAngle = (1.8 - level * 0.15) * spreadMul;
794
+
795
+ for (let i = 0; i < b; i++) {
796
+ const t = b === 1 ? 0 : (i / (b - 1)) * 2 - 1;
797
+ const childAngle = angle + t * spreadAngle * 0.5;
798
+ const len = baseLen * (0.85 + rand() * 0.3);
799
+
800
+ const ex = px + Math.sin(childAngle) * len;
801
+ const ey = py + Math.cos(childAngle) * len;
802
+
803
+ const mx = (px + ex) / 2;
804
+ const my = (py + ey) / 2;
805
+ const perpX = -(ey - py);
806
+ const perpY = ex - px;
807
+ const perpLen = Math.sqrt(perpX * perpX + perpY * perpY) || 1;
808
+ const curveAmt = (rand() - 0.5) * len * 0.3;
809
+ const cx = mx + (perpX / perpLen) * curveAmt;
810
+ const cy = my + (perpY / perpLen) * curveAmt;
811
+
812
+ branches.push({ x1: px, y1: py, x2: ex, y2: ey, cx, cy, depth: level + 1 });
813
+
814
+ const label = level === 0 ? labelAt(level) : undefined;
815
+ nodes.push({ x: ex, y: ey, depth: level + 1, label, idx: nodeCount++ });
816
+
817
+ grow(ex, ey, childAngle, level + 1, spreadMul * 0.65);
818
+ }
541
819
  }
542
820
 
543
- // Line timing faster reveal in demo (more lines to show)
544
- const lineDelay = isDemo ? fps * 0.08 : fps * 0.15;
545
- const firstLineFrame = isDemo ? fps * 0.25 : fps * 0.35;
821
+ grow(rootX, rootY, 0, 0, 1);
546
822
 
547
- const lines = highlight.lines || [];
823
+ // Compute total height
824
+ let maxY = rootY;
825
+ for (const n of nodes) { if (n.y > maxY) maxY = n.y; }
548
826
 
549
- // Cursor tracking
550
- const lastVisibleLineIdx = lines.findIndex((_, i) => {
551
- return frame < firstLineFrame + (i + 1) * lineDelay;
827
+ return { branches, nodes, totalHeight: maxY + 80 };
828
+ }
829
+
830
+ const TreeClip: React.FC<{
831
+ highlight: Highlight;
832
+ durationSec: number;
833
+ }> = ({ highlight, durationSec }) => {
834
+ const frame = useCurrentFrame();
835
+ const { fps, width, height } = useVideoConfig();
836
+ const tree = highlight.tree!;
837
+
838
+ // Generate tree at full frame width
839
+ const { branches, nodes, totalHeight } = generateOrganicTree(
840
+ tree.root, tree.depth, tree.branching, tree.nodeLabels, width
841
+ );
842
+
843
+ // Outro text area below the tree — positioned so it lands centered in frame
844
+ const hasOutro = !!tree.outro;
845
+ const outroY = totalHeight + height / 2 - 40; // center of a frame-height area below tree
846
+ const fullHeight = hasOutro ? outroY + height / 2 + 40 : totalHeight + 80;
847
+
848
+ // Scroll: tree + outro taller than frame, camera pans down
849
+ const scrollDistance = Math.max(0, fullHeight - height);
850
+
851
+ const scrollStart = fps * 0.3;
852
+ const scrollEnd = fps * (durationSec - (hasOutro ? 1.2 : 0.5));
853
+ const scrollY = scrollDistance > 0
854
+ ? interpolate(frame, [scrollStart, scrollEnd], [0, scrollDistance], {
855
+ extrapolateLeft: "clamp",
856
+ extrapolateRight: "clamp",
857
+ easing: Easing.inOut(Easing.quad),
858
+ })
859
+ : 0;
860
+
861
+ // Fade in only — no abrupt fadeout, the scroll carries us into the next beat
862
+ const fadeIn = interpolate(frame, [0, fps * 0.3], [0, 1], {
863
+ extrapolateLeft: "clamp", extrapolateRight: "clamp",
552
864
  });
553
- const cursorLineIdx =
554
- lastVisibleLineIdx === -1
555
- ? lines.length - 1
556
- : Math.max(0, lastVisibleLineIdx - 1);
865
+ const fadeOut = hasOutro
866
+ ? 1 // no fadeout when outro handles the exit
867
+ : interpolate(frame, [fps * (durationSec - 0.5), fps * durationSec], [1, 0], {
868
+ extrapolateLeft: "clamp", extrapolateRight: "clamp",
869
+ });
870
+
871
+ // Nodes/branches reveal based on Y position relative to viewport
872
+ const viewBottom = scrollY + height;
557
873
 
558
- // Terminal sizing wider in demo mode (landscape)
559
- const termWidth = isDemo ? width - 160 : 820;
560
- const termFontSize = isDemo ? 14 : 16;
561
- const termMinHeight = isDemo ? 500 : 280;
562
- const termPadding = isDemo ? "16px 20px" : "20px 24px";
874
+ const dotSize = (d: number) => [14, 8, 5, 3.5, 2.5][Math.min(d, 4)];
875
+ const strokeW = (d: number) => [1.5, 1, 0.6, 0.35, 0.25][Math.min(d, 4)];
876
+ const strokeOp = (d: number) => [0.20, 0.15, 0.10, 0.07, 0.05][Math.min(d, 4)];
563
877
 
564
878
  return (
565
- <AbsoluteFill style={{ opacity }}>
566
- {/* Chapter label */}
879
+ <AbsoluteFill style={{ opacity: Math.min(fadeIn, fadeOut), overflow: "hidden" }}>
567
880
  <div
568
881
  style={{
569
882
  position: "absolute",
570
- top: isDemo ? 24 : 45,
571
883
  left: 0,
572
- width: "100%",
573
- textAlign: "center",
574
- zIndex: 10,
884
+ top: 0,
885
+ width: width,
886
+ height: fullHeight,
887
+ transform: `translateY(${-scrollY}px)`,
575
888
  }}
576
889
  >
577
- <span
578
- style={{
579
- fontFamily: MONO,
580
- fontSize: isDemo ? 15 : 13,
581
- color: ACCENT,
582
- letterSpacing: isDemo ? 3 : 4,
583
- textTransform: "uppercase",
584
- opacity: interpolate(enterSpring, [0, 1], [0, isDemo ? 0.8 : 0.6]),
585
- }}
586
- >
587
- {highlight.label}
588
- </span>
589
- {/* Progress dots — reel only */}
590
- {!isDemo && (
591
- <div style={{ marginTop: 10, display: "flex", justifyContent: "center", gap: 8 }}>
592
- {Array.from({ length: total }).map((_, i) => (
890
+ <svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}>
891
+ {branches.map((b, i) => {
892
+ // Reveal when the endpoint scrolls into view
893
+ const revealY = b.y2;
894
+ const revealProgress = interpolate(
895
+ viewBottom,
896
+ [revealY - 80, revealY + 40],
897
+ [0, 1],
898
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
899
+ );
900
+
901
+ if (revealProgress <= 0) return null;
902
+
903
+ const dx = b.x2 - b.x1;
904
+ const dy = b.y2 - b.y1;
905
+ const approxLen = Math.sqrt(dx * dx + dy * dy) * 1.15;
906
+
907
+ return (
908
+ <path
909
+ key={`b-${i}`}
910
+ d={`M ${b.x1} ${b.y1} Q ${b.cx} ${b.cy} ${b.x2} ${b.y2}`}
911
+ fill="none"
912
+ stroke={`rgba(0,0,0,${strokeOp(b.depth)})`}
913
+ strokeWidth={strokeW(b.depth)}
914
+ strokeLinecap="round"
915
+ strokeDasharray={approxLen}
916
+ strokeDashoffset={approxLen * (1 - revealProgress)}
917
+ />
918
+ );
919
+ })}
920
+ </svg>
921
+
922
+ {nodes.map((node) => {
923
+ const revealProgress = interpolate(
924
+ viewBottom,
925
+ [node.y - 60, node.y + 40],
926
+ [0, 1],
927
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
928
+ );
929
+
930
+ if (revealProgress <= 0) return null;
931
+
932
+ const size = dotSize(node.depth);
933
+
934
+ return (
935
+ <div
936
+ key={`n-${node.idx}`}
937
+ style={{
938
+ position: "absolute",
939
+ left: node.x,
940
+ top: node.y,
941
+ transform: `translate(-50%, -50%) scale(${revealProgress})`,
942
+ opacity: revealProgress,
943
+ display: "flex",
944
+ flexDirection: "column",
945
+ alignItems: "center",
946
+ gap: 8,
947
+ }}
948
+ >
593
949
  <div
594
- key={i}
595
950
  style={{
596
- width: i === index ? 24 : 8,
597
- height: 6,
598
- borderRadius: 3,
599
- backgroundColor: i === index ? ACCENT : "rgba(255,255,255,0.12)",
951
+ width: size,
952
+ height: size,
953
+ borderRadius: size / 2,
954
+ backgroundColor: node.depth === 0
955
+ ? TEXT
956
+ : `rgba(0,0,0,${0.28 - node.depth * 0.05})`,
600
957
  }}
601
958
  />
602
- ))}
603
- </div>
604
- )}
959
+ {node.label && (
960
+ <span
961
+ style={{
962
+ fontFamily: SANS,
963
+ fontSize: node.depth === 0 ? 18 : 13,
964
+ fontWeight: node.depth === 0 ? 600 : 400,
965
+ color: node.depth === 0 ? TEXT : TEXT_DIM,
966
+ whiteSpace: "nowrap",
967
+ letterSpacing: node.depth === 0 ? -0.3 : 0.2,
968
+ }}
969
+ >
970
+ {node.label}
971
+ </span>
972
+ )}
973
+ </div>
974
+ );
975
+ })}
976
+
977
+ {/* Outro text — scrolls in from below the tree */}
978
+ {hasOutro && (() => {
979
+ const outroLines = tree.outro!.split("\n");
980
+ const outroReveal = interpolate(
981
+ viewBottom,
982
+ [outroY - 100, outroY + 60],
983
+ [0, 1],
984
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
985
+ );
986
+ return (
987
+ <div
988
+ style={{
989
+ position: "absolute",
990
+ left: 0,
991
+ top: outroY,
992
+ width: "100%",
993
+ display: "flex",
994
+ flexDirection: "column",
995
+ alignItems: "center",
996
+ gap: 8,
997
+ opacity: outroReveal,
998
+ transform: `translateY(${interpolate(outroReveal, [0, 1], [30, 0])}px)`,
999
+ }}
1000
+ >
1001
+ {outroLines.map((line, i) => (
1002
+ <div
1003
+ key={i}
1004
+ style={{
1005
+ fontFamily: SANS,
1006
+ fontSize: 56,
1007
+ fontWeight: 600,
1008
+ color: TEXT,
1009
+ letterSpacing: -1.5,
1010
+ lineHeight: 1.25,
1011
+ textAlign: "center",
1012
+ }}
1013
+ >
1014
+ {line}
1015
+ </div>
1016
+ ))}
1017
+ </div>
1018
+ );
1019
+ })()}
605
1020
  </div>
1021
+ </AbsoluteFill>
1022
+ );
1023
+ };
1024
+
1025
+ // ─── Mouse Pointer ───────────────────────────────────────
606
1026
 
607
- {/* Terminal window */}
1027
+ const MousePointer: React.FC = () => {
1028
+ const frame = useCurrentFrame();
1029
+ const { fps } = useVideoConfig();
1030
+
1031
+ const moveEnd = fps * 0.6;
1032
+ const clickFrame = fps * 0.7;
1033
+ const fadeStart = fps * 1.0;
1034
+ const fadeEnd = fps * 1.3;
1035
+
1036
+ const moveProgress = interpolate(frame, [0, moveEnd], [0, 1], {
1037
+ extrapolateLeft: "clamp",
1038
+ extrapolateRight: "clamp",
1039
+ easing: Easing.out(Easing.cubic),
1040
+ });
1041
+
1042
+ const x = interpolate(moveProgress, [0, 1], [750, 450]);
1043
+ const y = interpolate(moveProgress, [0, 1], [800, 480]);
1044
+
1045
+ const opacity = interpolate(frame, [0, 4, fadeStart, fadeEnd], [0, 1, 1, 0], {
1046
+ extrapolateLeft: "clamp",
1047
+ extrapolateRight: "clamp",
1048
+ });
1049
+
1050
+ const isClicking = frame >= clickFrame && frame < clickFrame + 4;
1051
+
1052
+ if (opacity <= 0) return null;
1053
+
1054
+ return (
1055
+ <div
1056
+ style={{
1057
+ position: "absolute",
1058
+ left: x,
1059
+ top: y,
1060
+ zIndex: 100,
1061
+ opacity,
1062
+ transform: `scale(${isClicking ? 0.85 : 1})`,
1063
+ transformOrigin: "top left",
1064
+ pointerEvents: "none",
1065
+ }}
1066
+ >
1067
+ <svg width="24" height="28" viewBox="0 0 24 28" fill="none">
1068
+ <path
1069
+ d="M2 2L2 22L7.5 16.5L12.5 26L16 24.5L11 15H19L2 2Z"
1070
+ fill={TEXT}
1071
+ stroke="white"
1072
+ strokeWidth="1.5"
1073
+ strokeLinejoin="round"
1074
+ />
1075
+ </svg>
1076
+ </div>
1077
+ );
1078
+ };
1079
+
1080
+ // ─── Highlight Clip (terminal) ────────────────────────────
1081
+
1082
+ const HighlightClip: React.FC<{
1083
+ highlight: Highlight;
1084
+ durationSec: number;
1085
+ }> = ({ highlight, durationSec }) => {
1086
+ const frame = useCurrentFrame();
1087
+ const { fps } = useVideoConfig();
1088
+
1089
+ const entry = useEntry(fps, frame);
1090
+
1091
+ const fadeIn = interpolate(frame, [0, fps * TIMING.transition], [0, 1], {
1092
+ extrapolateLeft: "clamp",
1093
+ extrapolateRight: "clamp",
1094
+ });
1095
+ const fadeOut = interpolate(
1096
+ frame,
1097
+ [fps * (durationSec - TIMING.transition), fps * durationSec],
1098
+ [1, 0],
1099
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
1100
+ );
1101
+
1102
+ const lineDelay = fps * 0.12;
1103
+ const firstLineFrame = fps * 0.3;
1104
+ const lines = highlight.lines || [];
1105
+
1106
+ const lastVisibleLineIdx = lines.findIndex((_, i) => frame < firstLineFrame + (i + 1) * lineDelay);
1107
+ const cursorLineIdx = lastVisibleLineIdx === -1 ? lines.length - 1 : Math.max(0, lastVisibleLineIdx - 1);
1108
+
1109
+ return (
1110
+ <AbsoluteFill style={{ opacity: Math.min(fadeIn, fadeOut) }}>
608
1111
  <AbsoluteFill
609
1112
  style={{
610
1113
  justifyContent: "center",
611
1114
  alignItems: "center",
612
- padding: isDemo ? 20 : 40,
613
- paddingTop: isDemo ? 60 : 100,
614
- paddingBottom: isDemo ? 40 : 100,
1115
+ padding: 50,
1116
+ paddingTop: 70,
1117
+ paddingBottom: 90,
615
1118
  }}
616
1119
  >
617
1120
  <div
618
1121
  style={{
619
- transform: isDemo
620
- ? `scale(${enterSpring})`
621
- : `scale(${entry.scale * zoom}) translate(${entry.x}px, ${entry.y + panY}px)`,
1122
+ transform: `scale(${entry.scale}) translateY(${entry.y}px)`,
622
1123
  transformOrigin: "center center",
623
- width: termWidth,
624
- borderRadius: isDemo ? 10 : 14,
1124
+ width: 840,
1125
+ borderRadius: 16,
625
1126
  overflow: "hidden",
626
- boxShadow: isDemo
627
- ? "0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04)"
628
- : "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
1127
+ boxShadow: `${CARD_SHADOW}, 0 0 0 1px ${CARD_BORDER}`,
629
1128
  }}
630
1129
  >
631
- {/* macOS title bar */}
632
1130
  <div
633
1131
  style={{
634
- backgroundColor: TITLE_BAR,
635
- padding: "12px 16px",
1132
+ backgroundColor: TERM_BAR,
1133
+ padding: "10px 20px",
1134
+ borderBottom: `1px solid ${TERM_BORDER}`,
636
1135
  display: "flex",
637
1136
  alignItems: "center",
638
- gap: 8,
639
1137
  }}
640
1138
  >
641
- <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#ff5555" }} />
642
- <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#f1fa8c" }} />
643
- <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#50fa7b" }} />
644
- <div style={{ flex: 1 }} />
1139
+ <div style={{ display: "flex", gap: 6 }}>
1140
+ <div style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.06)" }} />
1141
+ <div style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.06)" }} />
1142
+ <div style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.06)" }} />
1143
+ </div>
645
1144
  </div>
646
1145
 
647
- {/* Terminal body */}
648
1146
  <div
649
1147
  style={{
650
1148
  backgroundColor: TERM_BG,
651
- padding: termPadding,
652
- minHeight: termMinHeight,
1149
+ padding: "24px 28px",
1150
+ minHeight: 320,
653
1151
  }}
654
1152
  >
655
1153
  {lines.map((line, lineIdx) => {
656
1154
  const lineFrame = firstLineFrame + lineIdx * lineDelay;
657
- const lineSpring = spring({
658
- fps,
659
- frame: Math.max(0, frame - lineFrame),
660
- config: { damping: 20, stiffness: 120 },
1155
+
1156
+ const lineProgress = interpolate(frame, [lineFrame, lineFrame + fps * 0.25], [0, 1], {
1157
+ extrapolateLeft: "clamp",
1158
+ extrapolateRight: "clamp",
1159
+ easing: Easing.out(Easing.cubic),
661
1160
  });
662
- const lineOpacity = interpolate(lineSpring, [0, 1], [0, 1]);
663
- const lineX = isDemo ? 0 : interpolate(lineSpring, [0, 1], [12, 0]);
664
1161
 
665
- // Strip leading "$ " from text — renderer adds its own $ prefix
666
1162
  const cleanText = line.isPrompt ? line.text.replace(/^\$\s*/, "") : line.text;
667
1163
  let displayText = cleanText;
668
1164
  let isTyping = false;
669
1165
  if (line.isPrompt) {
670
- const typingDur = isDemo ? 0.8 : 0.6;
671
- const typingEnd = lineFrame + fps * typingDur;
1166
+ const typingEnd = lineFrame + fps * 0.6;
672
1167
  if (frame < typingEnd) {
673
- const progress = interpolate(
674
- frame, [lineFrame, typingEnd], [0, 1],
675
- { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
676
- );
677
- const chars = Math.floor(progress * cleanText.length);
678
- displayText = cleanText.slice(0, chars);
679
- isTyping = chars < cleanText.length;
1168
+ const progress = interpolate(frame, [lineFrame, typingEnd], [0, 1], {
1169
+ extrapolateLeft: "clamp",
1170
+ extrapolateRight: "clamp",
1171
+ });
1172
+ displayText = cleanText.slice(0, Math.floor(progress * cleanText.length));
1173
+ isTyping = Math.floor(progress * cleanText.length) < cleanText.length;
680
1174
  }
681
1175
  }
682
1176
 
683
- const isZoomed = !isDemo && highlight.zoomLine === lineIdx;
684
- const lineZoom = isZoomed
685
- ? interpolate(frame, [lineFrame + fps * 0.5, lineFrame + fps * 1], [1, 1.05], {
686
- extrapolateLeft: "clamp", extrapolateRight: "clamp",
687
- })
688
- : 1;
689
-
690
- const showCursor = lineIdx === cursorLineIdx;
691
-
692
1177
  return (
693
1178
  <div
694
1179
  key={lineIdx}
695
1180
  style={{
696
- opacity: lineOpacity,
697
- transform: `translateX(${lineX}px) scale(${lineZoom})`,
698
- transformOrigin: "left center",
1181
+ opacity: lineProgress,
699
1182
  fontFamily: MONO,
700
- fontSize: termFontSize,
701
- lineHeight: isDemo ? 1.6 : 1.7,
702
- color: line.dim ? DIM : line.color || WHITE,
1183
+ fontSize: 15,
1184
+ lineHeight: 1.75,
1185
+ color: line.dim ? TERM_DIM : line.color || TERM_TEXT,
703
1186
  fontWeight: line.bold ? 700 : 400,
704
1187
  whiteSpace: "pre",
705
1188
  display: "flex",
706
1189
  alignItems: "center",
707
1190
  }}
708
1191
  >
709
- {line.isPrompt && (
710
- <span style={{ color: ACCENT, marginRight: 8 }}>$</span>
711
- )}
1192
+ {line.isPrompt && <span style={{ color: TERM_ACCENT, marginRight: 8 }}>$</span>}
712
1193
  <span>{displayText}</span>
713
- {showCursor && <Cursor visible blink={!isTyping} />}
1194
+ {lineIdx === cursorLineIdx && <Cursor visible blink={!isTyping} />}
714
1195
  </div>
715
1196
  );
716
1197
  })}
@@ -718,13 +1199,7 @@ const HighlightClip: React.FC<{
718
1199
  </div>
719
1200
  </AbsoluteFill>
720
1201
 
721
- {/* Mouse pointer — reel only */}
722
- {!isDemo && <MousePointer />}
723
-
724
- {/* Text overlay — reel only */}
725
- {!isDemo && highlight.overlay && (
726
- <TextOverlay text={highlight.overlay} durationSec={durationSec} />
727
- )}
1202
+ <MousePointer />
728
1203
  </AbsoluteFill>
729
1204
  );
730
1205
  };
@@ -733,155 +1208,86 @@ const HighlightClip: React.FC<{
733
1208
 
734
1209
  const BrowserHighlightClip: React.FC<{
735
1210
  highlight: Highlight;
736
- index: number;
737
- total: number;
738
- transition: TransitionStyle;
739
1211
  durationSec: number;
740
- isDemo?: boolean;
741
- }> = ({ highlight, index, total, transition, durationSec, isDemo }) => {
1212
+ }> = ({ highlight, durationSec }) => {
742
1213
  const frame = useCurrentFrame();
743
1214
  const { fps } = useVideoConfig();
744
1215
 
745
- const enterSpring = spring({
746
- fps,
747
- frame,
748
- config: { damping: 18, stiffness: 80 },
749
- });
750
- const entry = getEntryTransform(transition, enterSpring);
751
-
752
- const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
1216
+ const entry = useEntry(fps, frame);
753
1217
 
754
- const fadeIn = interpolate(frame, [0, fps * timing.transition], [0, 1], {
1218
+ const fadeIn = interpolate(frame, [0, fps * TIMING.transition], [0, 1], {
755
1219
  extrapolateLeft: "clamp",
756
1220
  extrapolateRight: "clamp",
757
1221
  });
758
1222
  const fadeOut = interpolate(
759
1223
  frame,
760
- [fps * (durationSec - timing.transition), fps * durationSec],
1224
+ [fps * (durationSec - TIMING.transition), fps * durationSec],
761
1225
  [1, 0],
762
1226
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
763
1227
  );
764
- const opacity = Math.min(fadeIn, fadeOut);
765
1228
 
766
- // Focal zoom — reel only
767
1229
  const fx = highlight.focusX ?? 0.5;
768
1230
  const fy = highlight.focusY ?? 0.5;
769
-
770
- let focalZoom = 1;
771
- let panY = 0;
772
- if (!isDemo) {
773
- const focalZoomIn = interpolate(frame, [fps * 1.0, fps * 3.0], [1, 1.15], {
774
- extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic),
775
- });
776
- const focalZoomOut = interpolate(frame, [fps * 3.5, fps * (durationSec - 0.5)], [1.15, 1.02], {
777
- extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic),
778
- });
779
- focalZoom = frame < fps * 3.5 ? focalZoomIn : focalZoomOut;
780
- panY = interpolate(frame, [fps * 1.0, fps * 3.0, fps * (durationSec - 1.0)], [0, -10, 5], {
781
- extrapolateLeft: "clamp", extrapolateRight: "clamp",
782
- });
783
- }
1231
+ const focalZoom = interpolate(frame, [0, fps * durationSec], [1, 1.04], {
1232
+ extrapolateLeft: "clamp",
1233
+ extrapolateRight: "clamp",
1234
+ });
784
1235
 
785
1236
  const videoSrc = highlight.videoSrc!;
786
1237
  const startFrom = Math.round((highlight.videoStartSec || 0) * fps);
787
- const browserWidth = isDemo ? 1600 : 880;
788
1238
 
789
1239
  return (
790
- <AbsoluteFill style={{ opacity }}>
791
- {/* Chapter label */}
792
- <div
793
- style={{
794
- position: "absolute",
795
- top: isDemo ? 24 : 45,
796
- left: 0,
797
- width: "100%",
798
- textAlign: "center",
799
- zIndex: 10,
800
- }}
801
- >
802
- <span
803
- style={{
804
- fontFamily: MONO,
805
- fontSize: isDemo ? 15 : 13,
806
- color: ACCENT,
807
- letterSpacing: isDemo ? 3 : 4,
808
- textTransform: "uppercase",
809
- opacity: interpolate(enterSpring, [0, 1], [0, isDemo ? 0.8 : 0.6]),
810
- }}
811
- >
812
- {highlight.label}
813
- </span>
814
- {!isDemo && (
815
- <div style={{ marginTop: 10, display: "flex", justifyContent: "center", gap: 8 }}>
816
- {Array.from({ length: total }).map((_, i) => (
817
- <div
818
- key={i}
819
- style={{
820
- width: i === index ? 24 : 8,
821
- height: 6,
822
- borderRadius: 3,
823
- backgroundColor: i === index ? ACCENT : "rgba(255,255,255,0.12)",
824
- }}
825
- />
826
- ))}
827
- </div>
828
- )}
829
- </div>
830
-
831
- {/* Browser window */}
1240
+ <AbsoluteFill style={{ opacity: Math.min(fadeIn, fadeOut) }}>
832
1241
  <AbsoluteFill
833
1242
  style={{
834
1243
  justifyContent: "center",
835
1244
  alignItems: "center",
836
- padding: isDemo ? 20 : 40,
837
- paddingTop: isDemo ? 60 : 100,
838
- paddingBottom: isDemo ? 40 : 100,
1245
+ padding: 50,
1246
+ paddingTop: 70,
1247
+ paddingBottom: 90,
839
1248
  }}
840
1249
  >
841
1250
  <div
842
1251
  style={{
843
- transform: isDemo
844
- ? `scale(${enterSpring})`
845
- : `scale(${entry.scale}) translate(${entry.x}px, ${entry.y + panY}px)`,
1252
+ transform: `scale(${entry.scale}) translateY(${entry.y}px)`,
846
1253
  transformOrigin: "center center",
847
- width: browserWidth,
848
- borderRadius: isDemo ? 10 : 14,
1254
+ width: 880,
1255
+ borderRadius: 16,
849
1256
  overflow: "hidden",
850
- boxShadow: isDemo
851
- ? "0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04)"
852
- : "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
1257
+ boxShadow: `${CARD_SHADOW}, 0 0 0 1px ${CARD_BORDER}`,
853
1258
  }}
854
1259
  >
855
- {/* Browser chrome */}
856
1260
  <div
857
1261
  style={{
858
- backgroundColor: TITLE_BAR,
859
- padding: "10px 16px",
1262
+ backgroundColor: TERM_BAR,
1263
+ padding: "10px 20px",
1264
+ borderBottom: `1px solid ${TERM_BORDER}`,
860
1265
  display: "flex",
861
1266
  alignItems: "center",
862
1267
  gap: 8,
863
1268
  }}
864
1269
  >
865
- <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#ff5555" }} />
866
- <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#f1fa8c" }} />
867
- <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#50fa7b" }} />
1270
+ <div style={{ display: "flex", gap: 6 }}>
1271
+ <div style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.06)" }} />
1272
+ <div style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.06)" }} />
1273
+ <div style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.06)" }} />
1274
+ </div>
868
1275
  <div
869
1276
  style={{
870
1277
  flex: 1,
871
- marginLeft: 8,
872
- backgroundColor: "rgba(255,255,255,0.06)",
1278
+ marginLeft: 4,
1279
+ backgroundColor: "rgba(0,0,0,0.03)",
873
1280
  borderRadius: 6,
874
- padding: "6px 12px",
1281
+ padding: "5px 12px",
875
1282
  fontFamily: SANS,
876
- fontSize: 12,
877
- color: "rgba(255,255,255,0.4)",
1283
+ fontSize: 11,
1284
+ color: "rgba(0,0,0,0.25)",
878
1285
  }}
879
1286
  >
880
- {highlight.videoSrc ? "localhost:3000" : ""}
1287
+ localhost:3000
881
1288
  </div>
882
1289
  </div>
883
1290
 
884
- {/* Video content — focal zoom applied here, chrome stays static */}
885
1291
  <div
886
1292
  style={{
887
1293
  width: "100%",
@@ -905,32 +1311,25 @@ const BrowserHighlightClip: React.FC<{
905
1311
  startFrom={startFrom}
906
1312
  style={{ width: "100%", height: "100%", objectFit: "cover" }}
907
1313
  />
908
- {/* Click cursor — inside zoom container so it tracks with content */}
909
1314
  {highlight.clicks && highlight.clicks.length > 0 && (
910
- <BrowserCursor
911
- clicks={highlight.clicks}
912
- durationSec={durationSec}
913
- />
1315
+ <BrowserCursor clicks={highlight.clicks} durationSec={durationSec} />
914
1316
  )}
915
1317
  </div>
916
1318
  </div>
917
1319
  </div>
918
1320
  </AbsoluteFill>
919
1321
 
920
- {/* Text overlay reel only */}
921
- {!isDemo && highlight.overlay && (
922
- <TextOverlay text={highlight.overlay} durationSec={durationSec} />
923
- )}
1322
+ {highlight.overlay && <TextOverlay text={highlight.overlay} durationSec={durationSec} />}
924
1323
  </AbsoluteFill>
925
1324
  );
926
1325
  };
927
1326
 
928
- // ─── Browser Cursor (click-tracking) ─────────────────────
1327
+ // ─── Browser Cursor ──────────────────────────────────────
929
1328
 
930
- const BrowserCursor: React.FC<{
931
- clicks: ClickEvent[];
932
- durationSec: number;
933
- }> = ({ clicks, durationSec }) => {
1329
+ const BrowserCursor: React.FC<{ clicks: ClickEvent[]; durationSec: number }> = ({
1330
+ clicks,
1331
+ durationSec,
1332
+ }) => {
934
1333
  const frame = useCurrentFrame();
935
1334
  const { fps } = useVideoConfig();
936
1335
 
@@ -940,20 +1339,16 @@ const BrowserCursor: React.FC<{
940
1339
  const scaleX = VIDEO_AREA_W / VIEWPORT_W;
941
1340
  const scaleY = VIDEO_AREA_H / VIEWPORT_H;
942
1341
 
943
- // Determine cursor position by interpolating between clicks
944
1342
  let targetX: number;
945
1343
  let targetY: number;
946
1344
 
947
1345
  if (currentSec <= clicks[0].timeSec) {
948
- // Before first click — hold at first position
949
1346
  targetX = clicks[0].x * scaleX;
950
1347
  targetY = clicks[0].y * scaleY;
951
1348
  } else if (currentSec >= clicks[clicks.length - 1].timeSec) {
952
- // After last click — hold at last position
953
1349
  targetX = clicks[clicks.length - 1].x * scaleX;
954
1350
  targetY = clicks[clicks.length - 1].y * scaleY;
955
1351
  } else {
956
- // Between clicks — interpolate with easing
957
1352
  let prevIdx = 0;
958
1353
  for (let i = 1; i < clicks.length; i++) {
959
1354
  if (clicks[i].timeSec > currentSec) break;
@@ -968,40 +1363,23 @@ const BrowserCursor: React.FC<{
968
1363
  targetY = interpolate(eased, [0, 1], [prev.y * scaleY, next.y * scaleY]);
969
1364
  }
970
1365
 
971
- // Click detection — within 3 frames of a click event
972
1366
  const clickWindow = 3 / fps;
973
- const isClicking = clicks.some(
974
- (c) => Math.abs(currentSec - c.timeSec) < clickWindow
975
- );
1367
+ const isClicking = clicks.some((c) => Math.abs(currentSec - c.timeSec) < clickWindow);
976
1368
 
977
- // Fade in over first 0.3s, fade out after last click
978
1369
  const lastClickTime = clicks[clicks.length - 1].timeSec;
979
- const fadeIn = interpolate(currentSec, [0, 0.3], [0, 1], {
980
- extrapolateLeft: "clamp",
981
- extrapolateRight: "clamp",
982
- });
983
- const fadeOut = interpolate(
984
- currentSec,
985
- [lastClickTime + 0.3, lastClickTime + 0.8],
986
- [1, 0],
987
- { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
1370
+ const cursorOpacity = Math.min(
1371
+ interpolate(currentSec, [0, 0.3], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }),
1372
+ interpolate(currentSec, [lastClickTime + 0.3, lastClickTime + 0.8], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })
988
1373
  );
989
- const opacity = Math.min(fadeIn, fadeOut);
990
1374
 
991
- if (opacity <= 0) return null;
1375
+ if (cursorOpacity <= 0) return null;
992
1376
 
993
1377
  return (
994
1378
  <>
995
- {/* Click ripples */}
996
1379
  {clicks.map((click, i) => {
997
1380
  const rippleDuration = 0.4;
998
- if (currentSec < click.timeSec || currentSec > click.timeSec + rippleDuration)
999
- return null;
1000
-
1381
+ if (currentSec < click.timeSec || currentSec > click.timeSec + rippleDuration) return null;
1001
1382
  const progress = (currentSec - click.timeSec) / rippleDuration;
1002
- const rippleScale = interpolate(progress, [0, 1], [0.5, 2.5]);
1003
- const rippleOpacity = interpolate(progress, [0, 0.3, 1], [0.6, 0.4, 0]);
1004
-
1005
1383
  return (
1006
1384
  <div
1007
1385
  key={i}
@@ -1013,23 +1391,21 @@ const BrowserCursor: React.FC<{
1013
1391
  height: 30,
1014
1392
  borderRadius: "50%",
1015
1393
  border: `2px solid ${ACCENT}`,
1016
- transform: `scale(${rippleScale})`,
1017
- opacity: rippleOpacity,
1394
+ transform: `scale(${interpolate(progress, [0, 1], [0.5, 2.5])})`,
1395
+ opacity: interpolate(progress, [0, 0.3, 1], [0.6, 0.4, 0]),
1018
1396
  pointerEvents: "none",
1019
1397
  zIndex: 49,
1020
1398
  }}
1021
1399
  />
1022
1400
  );
1023
1401
  })}
1024
-
1025
- {/* Cursor */}
1026
1402
  <div
1027
1403
  style={{
1028
1404
  position: "absolute",
1029
1405
  left: targetX,
1030
1406
  top: targetY,
1031
1407
  zIndex: 50,
1032
- opacity,
1408
+ opacity: cursorOpacity,
1033
1409
  transform: `scale(${isClicking ? 0.85 : 1})`,
1034
1410
  transformOrigin: "top left",
1035
1411
  pointerEvents: "none",
@@ -1038,8 +1414,8 @@ const BrowserCursor: React.FC<{
1038
1414
  <svg width="24" height="28" viewBox="0 0 24 28" fill="none">
1039
1415
  <path
1040
1416
  d="M2 2L2 22L7.5 16.5L12.5 26L16 24.5L11 15H19L2 2Z"
1041
- fill="white"
1042
- stroke="black"
1417
+ fill={TEXT}
1418
+ stroke="white"
1043
1419
  strokeWidth="1.5"
1044
1420
  strokeLinejoin="round"
1045
1421
  />
@@ -1049,93 +1425,60 @@ const BrowserCursor: React.FC<{
1049
1425
  );
1050
1426
  };
1051
1427
 
1052
- // ─── End Card (CTA) ───────────────────────────────────────
1428
+ // ─── End Card ─────────────────────────────────────────────
1053
1429
 
1054
- const EndCard: React.FC<{ text: string; url?: string; isDemo?: boolean }> = ({ text, url, isDemo }) => {
1430
+ const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
1055
1431
  const frame = useCurrentFrame();
1056
1432
  const { fps } = useVideoConfig();
1057
- const timing = isDemo ? DEMO_TIMING : REEL_TIMING;
1058
1433
 
1059
- const cmdSpring = spring({ fps, frame, config: { damping: 14 } });
1060
- const urlSpring = spring({
1061
- fps,
1062
- frame: Math.max(0, frame - 10),
1063
- config: { damping: 14 },
1064
- });
1065
- const brandSpring = spring({
1066
- fps,
1067
- frame: Math.max(0, frame - 18),
1068
- config: { damping: 14 },
1434
+ const enterProgress = interpolate(frame, [0, fps * 0.6], [0, 1], {
1435
+ extrapolateLeft: "clamp",
1436
+ extrapolateRight: "clamp",
1437
+ easing: Easing.out(Easing.cubic),
1069
1438
  });
1070
- const endZoom = isDemo ? 1 : interpolate(frame, [0, fps * timing.end], [1.05, 1], {
1439
+ const urlProgress = interpolate(frame, [fps * 0.3, fps * 0.8], [0, 1], {
1071
1440
  extrapolateLeft: "clamp",
1072
1441
  extrapolateRight: "clamp",
1073
1442
  easing: Easing.out(Easing.cubic),
1074
1443
  });
1075
1444
 
1076
1445
  return (
1077
- <AbsoluteFill
1078
- style={{
1079
- justifyContent: "center",
1080
- alignItems: "center",
1081
- transform: `scale(${endZoom})`,
1082
- }}
1083
- >
1446
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
1084
1447
  <div
1085
1448
  style={{
1086
- transform: `scale(${cmdSpring}) translateY(${interpolate(cmdSpring, [0, 1], [25, 0])}px)`,
1449
+ opacity: enterProgress,
1450
+ transform: `scale(${interpolate(enterProgress, [0, 1], [0.99, 1])}) translateY(${interpolate(enterProgress, [0, 1], [6, 0])}px)`,
1087
1451
  textAlign: "center",
1088
1452
  }}
1089
1453
  >
1090
1454
  <div
1091
1455
  style={{
1092
- fontFamily: isDemo ? SANS : MONO,
1093
- fontSize: isDemo ? 28 : 30,
1094
- color: WHITE,
1095
- padding: "18px 36px",
1096
- borderRadius: 14,
1097
- backgroundColor: "rgba(255,255,255,0.05)",
1098
- border: "1px solid rgba(255,255,255,0.1)",
1099
- boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
1456
+ fontFamily: SANS,
1457
+ fontSize: 40,
1458
+ fontWeight: 600,
1459
+ color: TEXT,
1460
+ letterSpacing: -1,
1100
1461
  }}
1101
1462
  >
1102
- {!isDemo && <span style={{ color: ACCENT }}>$ </span>}
1103
1463
  {text}
1104
- {!isDemo && <Cursor visible blink />}
1105
1464
  </div>
1106
1465
  </div>
1107
1466
 
1108
1467
  {url && (
1109
1468
  <div
1110
1469
  style={{
1111
- marginTop: 24,
1112
- opacity: urlSpring,
1113
- transform: `translateY(${interpolate(urlSpring, [0, 1], [15, 0])}px)`,
1470
+ marginTop: 20,
1471
+ opacity: urlProgress,
1472
+ transform: `translateY(${interpolate(urlProgress, [0, 1], [6, 0])}px)`,
1114
1473
  fontFamily: SANS,
1115
- fontSize: isDemo ? 18 : 20,
1116
- color: DIM,
1474
+ fontSize: 18,
1475
+ color: TEXT_DIM,
1117
1476
  letterSpacing: 0.5,
1118
1477
  }}
1119
1478
  >
1120
1479
  {url}
1121
1480
  </div>
1122
1481
  )}
1123
-
1124
- <div
1125
- style={{
1126
- position: "absolute",
1127
- top: 24,
1128
- right: 28,
1129
- opacity: brandSpring * 0.4,
1130
- transform: `translateY(${interpolate(brandSpring, [0, 1], [-10, 0])}px)`,
1131
- fontFamily: MONO,
1132
- fontSize: 16,
1133
- color: DIM,
1134
- letterSpacing: 3,
1135
- }}
1136
- >
1137
- made with agentreel
1138
- </div>
1139
1482
  </AbsoluteFill>
1140
1483
  );
1141
1484
  };