agentreel 0.1.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.
@@ -0,0 +1,1000 @@
1
+ import React from "react";
2
+ import {
3
+ AbsoluteFill,
4
+ Audio,
5
+ interpolate,
6
+ spring,
7
+ staticFile,
8
+ useCurrentFrame,
9
+ useVideoConfig,
10
+ Sequence,
11
+ Easing,
12
+ OffthreadVideo,
13
+ } from "remotion";
14
+ import { CastProps, Highlight } from "./types";
15
+
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";
22
+
23
+ const TITLE_DUR = 2.5;
24
+ const HIGHLIGHT_DUR = 4.5;
25
+ const TRANSITION_DUR = 0.5;
26
+ const END_DUR = 3.5;
27
+
28
+ const SANS =
29
+ '-apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif';
30
+ const MONO =
31
+ '"SF Mono", "Fira Code", "Cascadia Code", "JetBrains Mono", monospace';
32
+
33
+ // ─── Transition variants ──────────────────────────────────
34
+ // Each highlight enters differently to keep the eye engaged.
35
+
36
+ type TransitionStyle = "rise" | "zoomIn" | "slideLeft" | "drop";
37
+ const TRANSITIONS: TransitionStyle[] = ["rise", "zoomIn", "slideLeft", "drop"];
38
+
39
+ function getEntryTransform(
40
+ style: TransitionStyle,
41
+ progress: number // 0 → 1 spring
42
+ ): { scale: number; x: number; y: number } {
43
+ switch (style) {
44
+ case "rise":
45
+ return {
46
+ scale: interpolate(progress, [0, 1], [0.82, 1]),
47
+ x: 0,
48
+ y: interpolate(progress, [0, 1], [80, 0]),
49
+ };
50
+ case "zoomIn":
51
+ return {
52
+ scale: interpolate(progress, [0, 1], [0.6, 1]),
53
+ x: 0,
54
+ y: 0,
55
+ };
56
+ case "slideLeft":
57
+ return {
58
+ scale: interpolate(progress, [0, 1], [0.9, 1]),
59
+ x: interpolate(progress, [0, 1], [120, 0]),
60
+ y: 0,
61
+ };
62
+ case "drop":
63
+ return {
64
+ scale: interpolate(progress, [0, 1], [0.85, 1]),
65
+ x: 0,
66
+ y: interpolate(progress, [0, 1], [-70, 0]),
67
+ };
68
+ }
69
+ }
70
+
71
+ // ─── Main Composition ─────────────────────────────────────
72
+
73
+ export const CastVideo: React.FC<CastProps> = ({
74
+ title,
75
+ subtitle,
76
+ highlights,
77
+ endText,
78
+ endUrl,
79
+ gradient,
80
+ }) => {
81
+ const frame = useCurrentFrame();
82
+ const { fps, durationInFrames } = useVideoConfig();
83
+ const g = gradient || ["#0f0f1a", "#1a0f2e"];
84
+
85
+ const titleFrames = Math.round(TITLE_DUR * fps);
86
+ const highlightFrames = Math.round(HIGHLIGHT_DUR * fps);
87
+ const endFrames = Math.round(END_DUR * fps);
88
+
89
+ // Animated gradient — hue rotates slowly over time
90
+ const gradAngle = interpolate(frame, [0, durationInFrames], [125, 200], {
91
+ extrapolateRight: "clamp",
92
+ });
93
+
94
+ return (
95
+ <AbsoluteFill
96
+ style={{
97
+ background: `linear-gradient(${gradAngle}deg, ${g[0]}, ${g[1]}, ${g[0]})`,
98
+ backgroundSize: "200% 200%",
99
+ }}
100
+ >
101
+ {/* Subtle animated glow blobs in background */}
102
+ <AnimatedBackground frame={frame} duration={durationInFrames} />
103
+
104
+ <MusicTrack />
105
+
106
+ <Sequence durationInFrames={titleFrames}>
107
+ <TitleCard title={title} subtitle={subtitle} />
108
+ </Sequence>
109
+
110
+ {highlights.map((h, i) => (
111
+ <Sequence
112
+ key={i}
113
+ from={titleFrames + i * highlightFrames}
114
+ durationInFrames={highlightFrames}
115
+ >
116
+ {h.videoSrc ? (
117
+ <BrowserHighlightClip
118
+ highlight={h}
119
+ index={i}
120
+ total={highlights.length}
121
+ transition={TRANSITIONS[i % TRANSITIONS.length]}
122
+ />
123
+ ) : (
124
+ <HighlightClip
125
+ highlight={h}
126
+ index={i}
127
+ total={highlights.length}
128
+ transition={TRANSITIONS[i % TRANSITIONS.length]}
129
+ />
130
+ )}
131
+ </Sequence>
132
+ ))}
133
+
134
+ <Sequence
135
+ from={titleFrames + highlights.length * highlightFrames}
136
+ durationInFrames={endFrames}
137
+ >
138
+ <EndCard text={endText || title} url={endUrl} />
139
+ </Sequence>
140
+ </AbsoluteFill>
141
+ );
142
+ };
143
+
144
+ // ─── Animated Background ──────────────────────────────────
145
+
146
+ const AnimatedBackground: React.FC<{
147
+ frame: number;
148
+ duration: number;
149
+ }> = ({ frame, duration }) => {
150
+ // Two soft gradient blobs that drift slowly
151
+ const blob1X = interpolate(frame, [0, duration], [20, 60], {
152
+ extrapolateRight: "clamp",
153
+ });
154
+ const blob1Y = interpolate(frame, [0, duration], [30, 50], {
155
+ extrapolateRight: "clamp",
156
+ });
157
+ const blob2X = interpolate(frame, [0, duration], [70, 35], {
158
+ extrapolateRight: "clamp",
159
+ });
160
+ const blob2Y = interpolate(frame, [0, duration], [60, 30], {
161
+ extrapolateRight: "clamp",
162
+ });
163
+
164
+ return (
165
+ <AbsoluteFill style={{ opacity: 0.3 }}>
166
+ <div
167
+ style={{
168
+ position: "absolute",
169
+ width: 500,
170
+ height: 500,
171
+ borderRadius: "50%",
172
+ background:
173
+ "radial-gradient(circle, rgba(80,250,123,0.15) 0%, transparent 70%)",
174
+ left: `${blob1X}%`,
175
+ top: `${blob1Y}%`,
176
+ transform: "translate(-50%, -50%)",
177
+ filter: "blur(80px)",
178
+ }}
179
+ />
180
+ <div
181
+ style={{
182
+ position: "absolute",
183
+ width: 400,
184
+ height: 400,
185
+ borderRadius: "50%",
186
+ background:
187
+ "radial-gradient(circle, rgba(189,147,249,0.12) 0%, transparent 70%)",
188
+ left: `${blob2X}%`,
189
+ top: `${blob2Y}%`,
190
+ transform: "translate(-50%, -50%)",
191
+ filter: "blur(80px)",
192
+ }}
193
+ />
194
+ </AbsoluteFill>
195
+ );
196
+ };
197
+
198
+ // ─── Music ────────────────────────────────────────────────
199
+
200
+ const MusicTrack: React.FC = () => {
201
+ const frame = useCurrentFrame();
202
+ const { fps, durationInFrames } = useVideoConfig();
203
+
204
+ const fadeIn = interpolate(frame, [0, fps], [0, 0.35], {
205
+ extrapolateLeft: "clamp",
206
+ extrapolateRight: "clamp",
207
+ });
208
+ const fadeOut = interpolate(
209
+ frame,
210
+ [durationInFrames - fps * 2, durationInFrames],
211
+ [0.35, 0],
212
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
213
+ );
214
+
215
+ try {
216
+ return (
217
+ <Audio src={staticFile("music.mp3")} volume={Math.min(fadeIn, fadeOut)} />
218
+ );
219
+ } catch {
220
+ return null;
221
+ }
222
+ };
223
+
224
+ // ─── Mouse Pointer ────────────────────────────────────────
225
+ // macOS-style cursor that moves to the terminal and "clicks" before typing starts.
226
+
227
+ const MousePointer: React.FC = () => {
228
+ const frame = useCurrentFrame();
229
+ const { fps } = useVideoConfig();
230
+
231
+ // Pointer moves in from bottom-right, arrives at terminal center, clicks, then fades
232
+ const moveEnd = fps * 0.6;
233
+ const clickFrame = fps * 0.7;
234
+ const fadeStart = fps * 1.0;
235
+ const fadeEnd = fps * 1.3;
236
+
237
+ const moveProgress = interpolate(frame, [0, moveEnd], [0, 1], {
238
+ extrapolateLeft: "clamp",
239
+ extrapolateRight: "clamp",
240
+ easing: Easing.out(Easing.cubic),
241
+ });
242
+
243
+ const x = interpolate(moveProgress, [0, 1], [750, 450]);
244
+ const y = interpolate(moveProgress, [0, 1], [800, 480]);
245
+
246
+ const opacity = interpolate(frame, [0, 4, fadeStart, fadeEnd], [0, 1, 1, 0], {
247
+ extrapolateLeft: "clamp",
248
+ extrapolateRight: "clamp",
249
+ });
250
+
251
+ // Click effect — brief scale down
252
+ const isClicking = frame >= clickFrame && frame < clickFrame + 4;
253
+ const clickScale = isClicking ? 0.85 : 1;
254
+
255
+ if (opacity <= 0) return null;
256
+
257
+ return (
258
+ <div
259
+ style={{
260
+ position: "absolute",
261
+ left: x,
262
+ top: y,
263
+ zIndex: 100,
264
+ opacity,
265
+ transform: `scale(${clickScale})`,
266
+ transformOrigin: "top left",
267
+ pointerEvents: "none",
268
+ }}
269
+ >
270
+ {/* macOS cursor SVG */}
271
+ <svg width="24" height="28" viewBox="0 0 24 28" fill="none">
272
+ <path
273
+ d="M2 2L2 22L7.5 16.5L12.5 26L16 24.5L11 15H19L2 2Z"
274
+ fill="white"
275
+ stroke="black"
276
+ strokeWidth="1.5"
277
+ strokeLinejoin="round"
278
+ />
279
+ </svg>
280
+ </div>
281
+ );
282
+ };
283
+
284
+ // ─── Cursor ───────────────────────────────────────────────
285
+
286
+ const Cursor: React.FC<{ visible: boolean; blink?: boolean }> = ({
287
+ visible,
288
+ blink = true,
289
+ }) => {
290
+ const frame = useCurrentFrame();
291
+ const { fps } = useVideoConfig();
292
+ if (!visible) return null;
293
+ const blinkOn = blink ? frame % Math.round(fps / 2) < fps / 4 : true;
294
+
295
+ return (
296
+ <span
297
+ style={{
298
+ display: "inline-block",
299
+ width: 9,
300
+ height: 19,
301
+ backgroundColor: blinkOn ? CURSOR_COLOR : "transparent",
302
+ marginLeft: 1,
303
+ verticalAlign: "text-bottom",
304
+ borderRadius: 1,
305
+ }}
306
+ />
307
+ );
308
+ };
309
+
310
+ // ─── Text Overlay (colored accent words) ──────────────────
311
+
312
+ const TextOverlay: React.FC<{ text: string }> = ({ text }) => {
313
+ const frame = useCurrentFrame();
314
+ const { fps } = useVideoConfig();
315
+
316
+ const showAt = fps * 1.8;
317
+ const hideAt = fps * (HIGHLIGHT_DUR - 0.8);
318
+
319
+ const enterProgress = spring({
320
+ fps,
321
+ frame: Math.max(0, frame - showAt),
322
+ config: { damping: 16, stiffness: 100 },
323
+ });
324
+ const exitOpacity = interpolate(frame, [hideAt, hideAt + fps * 0.3], [1, 0], {
325
+ extrapolateLeft: "clamp",
326
+ extrapolateRight: "clamp",
327
+ });
328
+
329
+ const opacity = Math.min(enterProgress, exitOpacity);
330
+ const y = interpolate(enterProgress, [0, 1], [20, 0]);
331
+ const scale = interpolate(enterProgress, [0, 1], [0.9, 1]);
332
+
333
+ if (frame < showAt) return null;
334
+
335
+ // Parse **bold** syntax for colored accent words
336
+ const parts = text.split(/(\*\*.*?\*\*)/);
337
+
338
+ return (
339
+ <div
340
+ style={{
341
+ position: "absolute",
342
+ bottom: 55,
343
+ left: 0,
344
+ width: "100%",
345
+ textAlign: "center",
346
+ zIndex: 20,
347
+ opacity,
348
+ transform: `translateY(${y}px) scale(${scale})`,
349
+ }}
350
+ >
351
+ <span
352
+ style={{
353
+ fontFamily: SANS,
354
+ fontSize: 36,
355
+ fontWeight: 700,
356
+ color: WHITE,
357
+ backgroundColor: "rgba(0,0,0,0.55)",
358
+ backdropFilter: "blur(12px)",
359
+ WebkitBackdropFilter: "blur(12px)",
360
+ padding: "12px 30px",
361
+ borderRadius: 12,
362
+ letterSpacing: -0.5,
363
+ display: "inline-block",
364
+ }}
365
+ >
366
+ {parts.map((part, i) => {
367
+ if (part.startsWith("**") && part.endsWith("**")) {
368
+ return (
369
+ <span key={i} style={{ color: ACCENT, fontWeight: 800 }}>
370
+ {part.slice(2, -2)}
371
+ </span>
372
+ );
373
+ }
374
+ return <span key={i}>{part}</span>;
375
+ })}
376
+ </span>
377
+ </div>
378
+ );
379
+ };
380
+
381
+ // ─── Title Card ───────────────────────────────────────────
382
+
383
+ const TitleCard: React.FC<{ title: string; subtitle?: string }> = ({
384
+ title,
385
+ subtitle,
386
+ }) => {
387
+ const frame = useCurrentFrame();
388
+ const { fps } = useVideoConfig();
389
+
390
+ const titleSpring = spring({ fps, frame, config: { damping: 14 } });
391
+ const subSpring = spring({
392
+ fps,
393
+ frame: Math.max(0, frame - 8),
394
+ config: { damping: 14 },
395
+ });
396
+ const fadeOut = interpolate(
397
+ frame,
398
+ [fps * (TITLE_DUR - TRANSITION_DUR), fps * TITLE_DUR],
399
+ [1, 0],
400
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
401
+ );
402
+ const titleZoom = interpolate(frame, [0, fps * TITLE_DUR], [1, 1.08], {
403
+ extrapolateLeft: "clamp",
404
+ extrapolateRight: "clamp",
405
+ });
406
+
407
+ return (
408
+ <AbsoluteFill
409
+ style={{
410
+ opacity: fadeOut,
411
+ justifyContent: "center",
412
+ alignItems: "center",
413
+ }}
414
+ >
415
+ <div
416
+ style={{
417
+ transform: `scale(${titleSpring * titleZoom}) translateY(${interpolate(titleSpring, [0, 1], [30, 0])}px)`,
418
+ textAlign: "center",
419
+ }}
420
+ >
421
+ <div
422
+ style={{
423
+ fontFamily: SANS,
424
+ fontSize: 76,
425
+ fontWeight: 800,
426
+ color: WHITE,
427
+ letterSpacing: -3,
428
+ }}
429
+ >
430
+ {title}
431
+ </div>
432
+ {subtitle && (
433
+ <div
434
+ style={{
435
+ transform: `translateY(${interpolate(subSpring, [0, 1], [20, 0])}px)`,
436
+ opacity: subSpring,
437
+ fontFamily: SANS,
438
+ fontSize: 28,
439
+ color: DIM,
440
+ marginTop: 16,
441
+ letterSpacing: 1,
442
+ }}
443
+ >
444
+ {subtitle}
445
+ </div>
446
+ )}
447
+ </div>
448
+ </AbsoluteFill>
449
+ );
450
+ };
451
+
452
+ // ─── Highlight Clip ───────────────────────────────────────
453
+
454
+ const HighlightClip: React.FC<{
455
+ highlight: Highlight;
456
+ index: number;
457
+ total: number;
458
+ transition: TransitionStyle;
459
+ }> = ({ highlight, index, total, transition }) => {
460
+ const frame = useCurrentFrame();
461
+ const { fps } = useVideoConfig();
462
+
463
+ // Entry animation — varies per clip
464
+ const enterSpring = spring({
465
+ fps,
466
+ frame,
467
+ config: { damping: 18, stiffness: 80 },
468
+ });
469
+ const entry = getEntryTransform(transition, enterSpring);
470
+
471
+ // Fade transitions
472
+ const fadeIn = interpolate(frame, [0, fps * TRANSITION_DUR], [0, 1], {
473
+ extrapolateLeft: "clamp",
474
+ extrapolateRight: "clamp",
475
+ });
476
+ const fadeOut = interpolate(
477
+ frame,
478
+ [fps * (HIGHLIGHT_DUR - TRANSITION_DUR), fps * HIGHLIGHT_DUR],
479
+ [1, 0],
480
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
481
+ );
482
+ const opacity = Math.min(fadeIn, fadeOut);
483
+
484
+ // Zoom in/out cycle
485
+ const zoomIn = interpolate(
486
+ frame,
487
+ [fps * 0.8, fps * 2.0],
488
+ [1, 1.12],
489
+ {
490
+ extrapolateLeft: "clamp",
491
+ extrapolateRight: "clamp",
492
+ easing: Easing.out(Easing.cubic),
493
+ }
494
+ );
495
+ const zoomOut = interpolate(
496
+ frame,
497
+ [fps * 2.5, fps * (HIGHLIGHT_DUR - 0.5)],
498
+ [1.12, 1.02],
499
+ {
500
+ extrapolateLeft: "clamp",
501
+ extrapolateRight: "clamp",
502
+ easing: Easing.inOut(Easing.cubic),
503
+ }
504
+ );
505
+ const zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
506
+
507
+ // Vertical pan
508
+ const panY = interpolate(
509
+ frame,
510
+ [fps * 0.8, fps * 2.0, fps * 3.5],
511
+ [0, -15, 5],
512
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
513
+ );
514
+
515
+ // Line timing
516
+ const lineDelay = fps * 0.15;
517
+ const firstLineFrame = fps * 0.35;
518
+
519
+ // Cursor tracking
520
+ const lastVisibleLineIdx = highlight.lines.findIndex((_, i) => {
521
+ return frame < firstLineFrame + (i + 1) * lineDelay;
522
+ });
523
+ const cursorLineIdx =
524
+ lastVisibleLineIdx === -1
525
+ ? highlight.lines.length - 1
526
+ : Math.max(0, lastVisibleLineIdx - 1);
527
+
528
+ return (
529
+ <AbsoluteFill style={{ opacity }}>
530
+ {/* Label + progress dots */}
531
+ <div
532
+ style={{
533
+ position: "absolute",
534
+ top: 45,
535
+ left: 0,
536
+ width: "100%",
537
+ textAlign: "center",
538
+ zIndex: 10,
539
+ }}
540
+ >
541
+ <span
542
+ style={{
543
+ fontFamily: MONO,
544
+ fontSize: 13,
545
+ color: ACCENT,
546
+ letterSpacing: 4,
547
+ textTransform: "uppercase",
548
+ opacity: interpolate(enterSpring, [0, 1], [0, 0.6]),
549
+ }}
550
+ >
551
+ {highlight.label}
552
+ </span>
553
+ <div
554
+ style={{
555
+ marginTop: 10,
556
+ display: "flex",
557
+ justifyContent: "center",
558
+ gap: 8,
559
+ }}
560
+ >
561
+ {Array.from({ length: total }).map((_, i) => (
562
+ <div
563
+ key={i}
564
+ style={{
565
+ width: i === index ? 24 : 8,
566
+ height: 6,
567
+ borderRadius: 3,
568
+ backgroundColor:
569
+ i === index ? ACCENT : "rgba(255,255,255,0.12)",
570
+ }}
571
+ />
572
+ ))}
573
+ </div>
574
+ </div>
575
+
576
+ {/* Terminal window */}
577
+ <AbsoluteFill
578
+ style={{
579
+ justifyContent: "center",
580
+ alignItems: "center",
581
+ padding: 40,
582
+ paddingTop: 100,
583
+ paddingBottom: 100,
584
+ }}
585
+ >
586
+ <div
587
+ style={{
588
+ transform: `scale(${entry.scale * zoom}) translate(${entry.x}px, ${entry.y + panY}px)`,
589
+ transformOrigin: "center center",
590
+ width: 820,
591
+ borderRadius: 14,
592
+ overflow: "hidden",
593
+ boxShadow:
594
+ "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
595
+ }}
596
+ >
597
+ {/* macOS title bar */}
598
+ <div
599
+ style={{
600
+ backgroundColor: TITLE_BAR,
601
+ padding: "12px 16px",
602
+ display: "flex",
603
+ alignItems: "center",
604
+ gap: 8,
605
+ }}
606
+ >
607
+ <div
608
+ style={{
609
+ width: 12,
610
+ height: 12,
611
+ borderRadius: 6,
612
+ backgroundColor: "#ff5555",
613
+ }}
614
+ />
615
+ <div
616
+ style={{
617
+ width: 12,
618
+ height: 12,
619
+ borderRadius: 6,
620
+ backgroundColor: "#f1fa8c",
621
+ }}
622
+ />
623
+ <div
624
+ style={{
625
+ width: 12,
626
+ height: 12,
627
+ borderRadius: 6,
628
+ backgroundColor: "#50fa7b",
629
+ }}
630
+ />
631
+ <div
632
+ style={{
633
+ flex: 1,
634
+ textAlign: "center",
635
+ fontFamily: MONO,
636
+ fontSize: 12,
637
+ color: "rgba(255,255,255,0.25)",
638
+ }}
639
+ >
640
+ Terminal
641
+ </div>
642
+ </div>
643
+
644
+ {/* Terminal body */}
645
+ <div
646
+ style={{
647
+ backgroundColor: TERM_BG,
648
+ padding: "20px 24px",
649
+ minHeight: 280,
650
+ }}
651
+ >
652
+ {highlight.lines.map((line, lineIdx) => {
653
+ const lineFrame = firstLineFrame + lineIdx * lineDelay;
654
+ const lineSpring = spring({
655
+ fps,
656
+ frame: Math.max(0, frame - lineFrame),
657
+ config: { damping: 20, stiffness: 120 },
658
+ });
659
+ const lineOpacity = interpolate(lineSpring, [0, 1], [0, 1]);
660
+ const lineX = interpolate(lineSpring, [0, 1], [12, 0]);
661
+
662
+ let displayText = line.text;
663
+ let isTyping = false;
664
+ if (line.isPrompt) {
665
+ const typingEnd = lineFrame + fps * 0.6;
666
+ if (frame < typingEnd) {
667
+ const progress = interpolate(
668
+ frame,
669
+ [lineFrame, typingEnd],
670
+ [0, 1],
671
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
672
+ );
673
+ const chars = Math.floor(progress * line.text.length);
674
+ displayText = line.text.slice(0, chars);
675
+ isTyping = chars < line.text.length;
676
+ }
677
+ }
678
+
679
+ const isZoomed = highlight.zoomLine === lineIdx;
680
+ const lineZoom = isZoomed
681
+ ? interpolate(
682
+ frame,
683
+ [lineFrame + fps * 0.5, lineFrame + fps * 1],
684
+ [1, 1.05],
685
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
686
+ )
687
+ : 1;
688
+
689
+ const showCursor = lineIdx === cursorLineIdx;
690
+
691
+ return (
692
+ <div
693
+ key={lineIdx}
694
+ style={{
695
+ opacity: lineOpacity,
696
+ transform: `translateX(${lineX}px) scale(${lineZoom})`,
697
+ transformOrigin: "left center",
698
+ fontFamily: MONO,
699
+ fontSize: 16,
700
+ lineHeight: 1.7,
701
+ color: line.dim ? DIM : line.color || WHITE,
702
+ fontWeight: line.bold ? 700 : 400,
703
+ whiteSpace: "pre",
704
+ display: "flex",
705
+ alignItems: "center",
706
+ }}
707
+ >
708
+ {line.isPrompt && (
709
+ <span style={{ color: ACCENT, marginRight: 8 }}>$</span>
710
+ )}
711
+ <span>{displayText}</span>
712
+ {showCursor && <Cursor visible blink={!isTyping} />}
713
+ </div>
714
+ );
715
+ })}
716
+ </div>
717
+ </div>
718
+ </AbsoluteFill>
719
+
720
+ {/* Mouse pointer — appears at start of each clip */}
721
+ <MousePointer />
722
+
723
+ {/* Text overlay */}
724
+ {highlight.overlay && <TextOverlay text={highlight.overlay} />}
725
+ </AbsoluteFill>
726
+ );
727
+ };
728
+
729
+ // ─── Browser Highlight Clip ───────────────────────────────
730
+
731
+ const BrowserHighlightClip: React.FC<{
732
+ highlight: Highlight;
733
+ index: number;
734
+ total: number;
735
+ transition: TransitionStyle;
736
+ }> = ({ highlight, index, total, transition }) => {
737
+ const frame = useCurrentFrame();
738
+ const { fps } = useVideoConfig();
739
+
740
+ const enterSpring = spring({
741
+ fps,
742
+ frame,
743
+ config: { damping: 18, stiffness: 80 },
744
+ });
745
+ const entry = getEntryTransform(transition, enterSpring);
746
+
747
+ const fadeIn = interpolate(frame, [0, fps * TRANSITION_DUR], [0, 1], {
748
+ extrapolateLeft: "clamp",
749
+ extrapolateRight: "clamp",
750
+ });
751
+ const fadeOut = interpolate(
752
+ frame,
753
+ [fps * (HIGHLIGHT_DUR - TRANSITION_DUR), fps * HIGHLIGHT_DUR],
754
+ [1, 0],
755
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
756
+ );
757
+ const opacity = Math.min(fadeIn, fadeOut);
758
+
759
+ // Zoom in/out cycle
760
+ const zoomIn = interpolate(frame, [fps * 0.8, fps * 2.0], [1, 1.08], {
761
+ extrapolateLeft: "clamp",
762
+ extrapolateRight: "clamp",
763
+ easing: Easing.out(Easing.cubic),
764
+ });
765
+ const zoomOut = interpolate(
766
+ frame,
767
+ [fps * 2.5, fps * (HIGHLIGHT_DUR - 0.5)],
768
+ [1.08, 1.01],
769
+ {
770
+ extrapolateLeft: "clamp",
771
+ extrapolateRight: "clamp",
772
+ easing: Easing.inOut(Easing.cubic),
773
+ }
774
+ );
775
+ const zoom = frame < fps * 2.5 ? zoomIn : zoomOut;
776
+
777
+ const panY = interpolate(
778
+ frame,
779
+ [fps * 0.8, fps * 2.0, fps * 3.5],
780
+ [0, -10, 5],
781
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
782
+ );
783
+
784
+ const videoSrc = highlight.videoSrc!;
785
+ const startFrom = Math.round((highlight.videoStartSec || 0) * fps);
786
+
787
+ return (
788
+ <AbsoluteFill style={{ opacity }}>
789
+ {/* Label + progress dots */}
790
+ <div
791
+ style={{
792
+ position: "absolute",
793
+ top: 45,
794
+ left: 0,
795
+ width: "100%",
796
+ textAlign: "center",
797
+ zIndex: 10,
798
+ }}
799
+ >
800
+ <span
801
+ style={{
802
+ fontFamily: MONO,
803
+ fontSize: 13,
804
+ color: ACCENT,
805
+ letterSpacing: 4,
806
+ textTransform: "uppercase",
807
+ opacity: interpolate(enterSpring, [0, 1], [0, 0.6]),
808
+ }}
809
+ >
810
+ {highlight.label}
811
+ </span>
812
+ <div
813
+ style={{
814
+ marginTop: 10,
815
+ display: "flex",
816
+ justifyContent: "center",
817
+ gap: 8,
818
+ }}
819
+ >
820
+ {Array.from({ length: total }).map((_, i) => (
821
+ <div
822
+ key={i}
823
+ style={{
824
+ width: i === index ? 24 : 8,
825
+ height: 6,
826
+ borderRadius: 3,
827
+ backgroundColor:
828
+ i === index ? ACCENT : "rgba(255,255,255,0.12)",
829
+ }}
830
+ />
831
+ ))}
832
+ </div>
833
+ </div>
834
+
835
+ {/* Browser window */}
836
+ <AbsoluteFill
837
+ style={{
838
+ justifyContent: "center",
839
+ alignItems: "center",
840
+ padding: 40,
841
+ paddingTop: 100,
842
+ paddingBottom: 100,
843
+ }}
844
+ >
845
+ <div
846
+ style={{
847
+ transform: `scale(${entry.scale * zoom}) translate(${entry.x}px, ${entry.y + panY}px)`,
848
+ transformOrigin: "center center",
849
+ width: 880,
850
+ borderRadius: 14,
851
+ overflow: "hidden",
852
+ boxShadow:
853
+ "0 40px 120px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)",
854
+ }}
855
+ >
856
+ {/* Browser chrome */}
857
+ <div
858
+ style={{
859
+ backgroundColor: TITLE_BAR,
860
+ padding: "10px 16px",
861
+ display: "flex",
862
+ alignItems: "center",
863
+ gap: 8,
864
+ }}
865
+ >
866
+ <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#ff5555" }} />
867
+ <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#f1fa8c" }} />
868
+ <div style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#50fa7b" }} />
869
+ {/* Address bar */}
870
+ <div
871
+ style={{
872
+ flex: 1,
873
+ marginLeft: 8,
874
+ backgroundColor: "rgba(255,255,255,0.06)",
875
+ borderRadius: 6,
876
+ padding: "6px 12px",
877
+ fontFamily: SANS,
878
+ fontSize: 12,
879
+ color: "rgba(255,255,255,0.4)",
880
+ }}
881
+ >
882
+ {highlight.videoSrc ? "localhost:3000" : ""}
883
+ </div>
884
+ </div>
885
+
886
+ {/* Video content */}
887
+ <div
888
+ style={{
889
+ width: "100%",
890
+ aspectRatio: "16/10",
891
+ backgroundColor: "#fff",
892
+ overflow: "hidden",
893
+ }}
894
+ >
895
+ <OffthreadVideo
896
+ src={staticFile(videoSrc)}
897
+ startFrom={startFrom}
898
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
899
+ />
900
+ </div>
901
+ </div>
902
+ </AbsoluteFill>
903
+
904
+ {/* Mouse pointer */}
905
+ <MousePointer />
906
+
907
+ {/* Text overlay */}
908
+ {highlight.overlay && <TextOverlay text={highlight.overlay} />}
909
+ </AbsoluteFill>
910
+ );
911
+ };
912
+
913
+ // ─── End Card (CTA) ───────────────────────────────────────
914
+
915
+ const EndCard: React.FC<{ text: string; url?: string }> = ({ text, url }) => {
916
+ const frame = useCurrentFrame();
917
+ const { fps } = useVideoConfig();
918
+
919
+ const cmdSpring = spring({ fps, frame, config: { damping: 14 } });
920
+ const urlSpring = spring({
921
+ fps,
922
+ frame: Math.max(0, frame - 10),
923
+ config: { damping: 14 },
924
+ });
925
+ const brandSpring = spring({
926
+ fps,
927
+ frame: Math.max(0, frame - 18),
928
+ config: { damping: 14 },
929
+ });
930
+ const endZoom = interpolate(frame, [0, fps * END_DUR], [1.05, 1], {
931
+ extrapolateLeft: "clamp",
932
+ extrapolateRight: "clamp",
933
+ easing: Easing.out(Easing.cubic),
934
+ });
935
+
936
+ return (
937
+ <AbsoluteFill
938
+ style={{
939
+ justifyContent: "center",
940
+ alignItems: "center",
941
+ transform: `scale(${endZoom})`,
942
+ }}
943
+ >
944
+ <div
945
+ style={{
946
+ transform: `scale(${cmdSpring}) translateY(${interpolate(cmdSpring, [0, 1], [25, 0])}px)`,
947
+ textAlign: "center",
948
+ }}
949
+ >
950
+ <div
951
+ style={{
952
+ fontFamily: MONO,
953
+ fontSize: 30,
954
+ color: WHITE,
955
+ padding: "18px 36px",
956
+ borderRadius: 14,
957
+ backgroundColor: "rgba(255,255,255,0.05)",
958
+ border: "1px solid rgba(255,255,255,0.1)",
959
+ boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
960
+ }}
961
+ >
962
+ <span style={{ color: ACCENT }}>$ </span>
963
+ {text}
964
+ <Cursor visible blink />
965
+ </div>
966
+ </div>
967
+
968
+ {url && (
969
+ <div
970
+ style={{
971
+ marginTop: 24,
972
+ opacity: urlSpring,
973
+ transform: `translateY(${interpolate(urlSpring, [0, 1], [15, 0])}px)`,
974
+ fontFamily: SANS,
975
+ fontSize: 20,
976
+ color: DIM,
977
+ letterSpacing: 0.5,
978
+ }}
979
+ >
980
+ {url}
981
+ </div>
982
+ )}
983
+
984
+ <div
985
+ style={{
986
+ position: "absolute",
987
+ bottom: 40,
988
+ opacity: brandSpring * 0.4,
989
+ transform: `translateY(${interpolate(brandSpring, [0, 1], [10, 0])}px)`,
990
+ fontFamily: MONO,
991
+ fontSize: 13,
992
+ color: DIM,
993
+ letterSpacing: 3,
994
+ }}
995
+ >
996
+ MADE WITH AGENTREEL
997
+ </div>
998
+ </AbsoluteFill>
999
+ );
1000
+ };