agentreel 0.6.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/README.md +12 -45
- package/bin/agentreel.mjs +173 -186
- package/package.json +1 -1
- package/public/browser-demo.mp4 +0 -0
- package/public/music.mp3 +0 -0
- package/src/CastVideo.tsx +981 -638
- package/src/Root.tsx +16 -12
- package/src/types.ts +100 -48
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
|
-
|
|
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
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
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
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
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
|
-
|
|
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
|
-
// ───
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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(
|
|
99
|
-
const endFrames = Math.round(
|
|
85
|
+
const titleFrames = Math.round(TIMING.title * fps);
|
|
86
|
+
const endFrames = Math.round(TIMING.end * fps);
|
|
100
87
|
|
|
101
|
-
|
|
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
|
-
|
|
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}
|
|
101
|
+
<TitleCard title={title} subtitle={subtitle} />
|
|
134
102
|
</Sequence>
|
|
135
103
|
|
|
136
104
|
{highlights.map((h, i) => {
|
|
137
|
-
const dur = getHighlightDuration(h
|
|
105
|
+
const dur = getHighlightDuration(h);
|
|
138
106
|
return (
|
|
139
|
-
<Sequence
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 (
|
|
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 =
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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:
|
|
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:
|
|
391
|
-
fontWeight:
|
|
392
|
-
color:
|
|
393
|
-
|
|
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:
|
|
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
|
|
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 * (
|
|
256
|
+
[fps * (TIMING.title - TIMING.transition), fps * TIMING.title],
|
|
437
257
|
[1, 0],
|
|
438
258
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
439
259
|
);
|
|
440
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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:
|
|
463
|
-
fontWeight:
|
|
464
|
-
color:
|
|
465
|
-
letterSpacing:
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
// ───
|
|
605
|
+
// ─── Diagram Clip ────────────────────────────────────────
|
|
491
606
|
|
|
492
|
-
const
|
|
607
|
+
const DiagramClip: React.FC<{
|
|
493
608
|
highlight: Highlight;
|
|
494
|
-
index: number;
|
|
495
|
-
total: number;
|
|
496
|
-
transition: TransitionStyle;
|
|
497
609
|
durationSec: number;
|
|
498
|
-
|
|
499
|
-
}> = ({ highlight, index, total, transition, durationSec, isDemo }) => {
|
|
610
|
+
}> = ({ highlight, durationSec }) => {
|
|
500
611
|
const frame = useCurrentFrame();
|
|
501
|
-
const { fps
|
|
502
|
-
const
|
|
612
|
+
const { fps } = useVideoConfig();
|
|
613
|
+
const diagram = highlight.diagram!;
|
|
503
614
|
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
515
|
-
const
|
|
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 -
|
|
632
|
+
[fps * (durationSec - 0.5), fps * durationSec],
|
|
522
633
|
[1, 0],
|
|
523
634
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
524
635
|
);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
823
|
+
// Compute total height
|
|
824
|
+
let maxY = rootY;
|
|
825
|
+
for (const n of nodes) { if (n.y > maxY) maxY = n.y; }
|
|
548
826
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
const
|
|
560
|
-
const
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
884
|
+
top: 0,
|
|
885
|
+
width: width,
|
|
886
|
+
height: fullHeight,
|
|
887
|
+
transform: `translateY(${-scrollY}px)`,
|
|
575
888
|
}}
|
|
576
889
|
>
|
|
577
|
-
<
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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:
|
|
597
|
-
height:
|
|
598
|
-
borderRadius:
|
|
599
|
-
backgroundColor:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
613
|
-
paddingTop:
|
|
614
|
-
paddingBottom:
|
|
1115
|
+
padding: 50,
|
|
1116
|
+
paddingTop: 70,
|
|
1117
|
+
paddingBottom: 90,
|
|
615
1118
|
}}
|
|
616
1119
|
>
|
|
617
1120
|
<div
|
|
618
1121
|
style={{
|
|
619
|
-
transform:
|
|
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:
|
|
624
|
-
borderRadius:
|
|
1124
|
+
width: 840,
|
|
1125
|
+
borderRadius: 16,
|
|
625
1126
|
overflow: "hidden",
|
|
626
|
-
boxShadow:
|
|
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:
|
|
635
|
-
padding: "
|
|
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={{
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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:
|
|
652
|
-
minHeight:
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
671
|
-
const typingEnd = lineFrame + fps * typingDur;
|
|
1166
|
+
const typingEnd = lineFrame + fps * 0.6;
|
|
672
1167
|
if (frame < typingEnd) {
|
|
673
|
-
const progress = interpolate(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
);
|
|
677
|
-
|
|
678
|
-
|
|
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:
|
|
697
|
-
transform: `translateX(${lineX}px) scale(${lineZoom})`,
|
|
698
|
-
transformOrigin: "left center",
|
|
1181
|
+
opacity: lineProgress,
|
|
699
1182
|
fontFamily: MONO,
|
|
700
|
-
fontSize:
|
|
701
|
-
lineHeight:
|
|
702
|
-
color: line.dim ?
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
741
|
-
}> = ({ highlight, index, total, transition, durationSec, isDemo }) => {
|
|
1212
|
+
}> = ({ highlight, durationSec }) => {
|
|
742
1213
|
const frame = useCurrentFrame();
|
|
743
1214
|
const { fps } = useVideoConfig();
|
|
744
1215
|
|
|
745
|
-
const
|
|
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 *
|
|
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 -
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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:
|
|
837
|
-
paddingTop:
|
|
838
|
-
paddingBottom:
|
|
1245
|
+
padding: 50,
|
|
1246
|
+
paddingTop: 70,
|
|
1247
|
+
paddingBottom: 90,
|
|
839
1248
|
}}
|
|
840
1249
|
>
|
|
841
1250
|
<div
|
|
842
1251
|
style={{
|
|
843
|
-
transform:
|
|
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:
|
|
848
|
-
borderRadius:
|
|
1254
|
+
width: 880,
|
|
1255
|
+
borderRadius: 16,
|
|
849
1256
|
overflow: "hidden",
|
|
850
|
-
boxShadow:
|
|
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:
|
|
859
|
-
padding: "10px
|
|
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={{
|
|
866
|
-
|
|
867
|
-
|
|
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:
|
|
872
|
-
backgroundColor: "rgba(
|
|
1278
|
+
marginLeft: 4,
|
|
1279
|
+
backgroundColor: "rgba(0,0,0,0.03)",
|
|
873
1280
|
borderRadius: 6,
|
|
874
|
-
padding: "
|
|
1281
|
+
padding: "5px 12px",
|
|
875
1282
|
fontFamily: SANS,
|
|
876
|
-
fontSize:
|
|
877
|
-
color: "rgba(
|
|
1283
|
+
fontSize: 11,
|
|
1284
|
+
color: "rgba(0,0,0,0.25)",
|
|
878
1285
|
}}
|
|
879
1286
|
>
|
|
880
|
-
|
|
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
|
-
{
|
|
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
|
|
1327
|
+
// ─── Browser Cursor ──────────────────────────────────────
|
|
929
1328
|
|
|
930
|
-
const BrowserCursor: React.FC<{
|
|
931
|
-
clicks
|
|
932
|
-
durationSec
|
|
933
|
-
}
|
|
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
|
|
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 (
|
|
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(${
|
|
1017
|
-
opacity:
|
|
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=
|
|
1042
|
-
stroke="
|
|
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
|
|
1428
|
+
// ─── End Card ─────────────────────────────────────────────
|
|
1053
1429
|
|
|
1054
|
-
const EndCard: React.FC<{ text: string; url?: string
|
|
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
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1093
|
-
fontSize:
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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:
|
|
1112
|
-
opacity:
|
|
1113
|
-
transform: `translateY(${interpolate(
|
|
1470
|
+
marginTop: 20,
|
|
1471
|
+
opacity: urlProgress,
|
|
1472
|
+
transform: `translateY(${interpolate(urlProgress, [0, 1], [6, 0])}px)`,
|
|
1114
1473
|
fontFamily: SANS,
|
|
1115
|
-
fontSize:
|
|
1116
|
-
color:
|
|
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
|
};
|