reelme 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +34 -0
  2. package/src/cache.mjs +148 -0
  3. package/src/cli.mjs +57 -0
  4. package/src/render.mjs +47 -0
  5. package/template/package.json +29 -0
  6. package/template/pnpm-lock.yaml +3522 -0
  7. package/template/pnpm-workspace.yaml +4 -0
  8. package/template/public/.gitkeep +0 -0
  9. package/template/remotion.config.ts +4 -0
  10. package/template/src/Root.tsx +127 -0
  11. package/template/src/brief.json +131 -0
  12. package/template/src/brief.ts +195 -0
  13. package/template/src/components/primitives/Arrow.tsx +83 -0
  14. package/template/src/components/primitives/Caption.tsx +52 -0
  15. package/template/src/components/primitives/CodeBlock.tsx +159 -0
  16. package/template/src/components/primitives/Icon.tsx +68 -0
  17. package/template/src/components/primitives/KeyPill.tsx +44 -0
  18. package/template/src/components/primitives/Label.tsx +49 -0
  19. package/template/src/components/primitives/Terminal.tsx +110 -0
  20. package/template/src/components/scenes/BrowserFrame.tsx +139 -0
  21. package/template/src/components/scenes/CTA.tsx +118 -0
  22. package/template/src/components/scenes/CodeReveal.tsx +41 -0
  23. package/template/src/components/scenes/DataFlow.tsx +102 -0
  24. package/template/src/components/scenes/FeatureList.tsx +110 -0
  25. package/template/src/components/scenes/FileTree.tsx +125 -0
  26. package/template/src/components/scenes/HotkeyScene.tsx +77 -0
  27. package/template/src/components/scenes/MobileScreen.tsx +212 -0
  28. package/template/src/components/scenes/OSWindowScene.tsx +166 -0
  29. package/template/src/components/scenes/Problem.tsx +105 -0
  30. package/template/src/components/scenes/SplitComparison.tsx +136 -0
  31. package/template/src/components/scenes/StatCallout.tsx +110 -0
  32. package/template/src/components/scenes/TerminalScene.tsx +41 -0
  33. package/template/src/duration.ts +55 -0
  34. package/template/src/fonts.ts +49 -0
  35. package/template/src/index.ts +106 -0
  36. package/template/src/platforms.json +78 -0
  37. package/template/src/platforms.ts +36 -0
  38. package/template/src/theme.ts +88 -0
  39. package/template/tsconfig.json +15 -0
@@ -0,0 +1,159 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface CodeBlockProps {
6
+ code: string;
7
+ language?: string;
8
+ highlightLine?: number;
9
+ theme: Theme;
10
+ startFrame?: number;
11
+ framesPerLine?: number;
12
+ }
13
+
14
+ function tokenize(line: string, _lang: string): Array<{ text: string; color: string }> {
15
+ // Minimal tokenizer — keywords, strings, comments, numbers
16
+ const segments: Array<{ text: string; color: string }> = [];
17
+ const keywords = /\b(import|export|from|const|let|var|function|return|async|await|if|else|for|while|class|new|this|true|false|null|undefined|type|interface|extends|implements|of|in|default)\b/g;
18
+ const strings = /(["'`])(?:(?!\1)[^\\]|\\.)*?\1/g;
19
+ const comments = /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm;
20
+ const numbers = /\b\d+\.?\d*\b/g;
21
+
22
+ // We'll do a simple pass: mark regions with their type
23
+ const marks: Array<{ start: number; end: number; color: string }> = [];
24
+
25
+ for (const re of [
26
+ { re: comments, color: "#8b949e" },
27
+ { re: strings, color: "#a5d6ff" },
28
+ { re: keywords, color: "#ff7b72" },
29
+ { re: numbers, color: "#79c0ff" },
30
+ ]) {
31
+ let m: RegExpExecArray | null;
32
+ re.re.lastIndex = 0;
33
+ while ((m = re.re.exec(line)) !== null) {
34
+ // Only add if no overlap
35
+ const start = m.index;
36
+ const end = m.index + m[0].length;
37
+ const overlap = marks.some((mk) => mk.start < end && mk.end > start);
38
+ if (!overlap) marks.push({ start, end, color: re.color });
39
+ }
40
+ }
41
+
42
+ marks.sort((a, b) => a.start - b.start);
43
+
44
+ let pos = 0;
45
+ for (const mk of marks) {
46
+ if (pos < mk.start) segments.push({ text: line.slice(pos, mk.start), color: "#e6edf3" });
47
+ segments.push({ text: line.slice(mk.start, mk.end), color: mk.color });
48
+ pos = mk.end;
49
+ }
50
+ if (pos < line.length) segments.push({ text: line.slice(pos), color: "#e6edf3" });
51
+
52
+ return segments.length > 0 ? segments : [{ text: line, color: "#e6edf3" }];
53
+ }
54
+
55
+ export const CodeBlock: React.FC<CodeBlockProps> = ({
56
+ code,
57
+ language = "ts",
58
+ highlightLine,
59
+ theme,
60
+ startFrame = 0,
61
+ framesPerLine = 9,
62
+ }) => {
63
+ const frame = useCurrentFrame();
64
+ const { fps } = useVideoConfig();
65
+ const elapsed = frame - startFrame;
66
+ const lines = code.split("\n");
67
+ const visibleCount = Math.max(0, Math.min(lines.length, Math.floor(elapsed / framesPerLine) + 1));
68
+
69
+ return (
70
+ <div style={{ fontFamily: theme.fontMono, fontSize: 20, lineHeight: 1.7 }}>
71
+ <div
72
+ style={{
73
+ background: "#0d1117",
74
+ borderRadius: 10,
75
+ overflow: "hidden",
76
+ boxShadow: "0 8px 48px rgba(0,0,0,0.6)",
77
+ }}
78
+ >
79
+ {/* Title bar */}
80
+ <div
81
+ style={{
82
+ background: "#161b22",
83
+ padding: "10px 16px",
84
+ display: "flex",
85
+ alignItems: "center",
86
+ gap: 8,
87
+ borderBottom: "1px solid #21262d",
88
+ }}
89
+ >
90
+ {(["#ff5f56", "#ffbd2e", "#27c93f"] as const).map((c) => (
91
+ <div key={c} style={{ width: 12, height: 12, borderRadius: "50%", background: c }} />
92
+ ))}
93
+ <span style={{ marginLeft: 8, color: "#8b949e", fontSize: 13 }}>
94
+ {language === "ts" ? "index.ts" : language === "py" ? "main.py" : language === "rs" ? "main.rs" : `code.${language}`}
95
+ </span>
96
+ </div>
97
+
98
+ {/* Code lines */}
99
+ <div style={{ padding: "20px 0" }}>
100
+ {lines.slice(0, visibleCount).map((line, i) => {
101
+ const lineNum = i + 1;
102
+ const isHighlight = highlightLine !== undefined && lineNum === highlightLine;
103
+ const highlightOpacity =
104
+ highlightLine !== undefined && visibleCount >= highlightLine
105
+ ? interpolate(
106
+ spring({ frame: elapsed - highlightLine * framesPerLine, fps, config: theme.motion }),
107
+ [0, 1],
108
+ [0, 1]
109
+ )
110
+ : 0;
111
+
112
+ return (
113
+ <div
114
+ key={i}
115
+ style={{
116
+ display: "flex",
117
+ padding: "0 24px",
118
+ background: isHighlight
119
+ ? `rgba(${hexToRgb(theme.accent)}, ${0.15 * highlightOpacity})`
120
+ : "transparent",
121
+ borderLeft: isHighlight
122
+ ? `3px solid rgba(${hexToRgb(theme.accent)}, ${highlightOpacity})`
123
+ : "3px solid transparent",
124
+ }}
125
+ >
126
+ <span
127
+ style={{
128
+ color: "#484f58",
129
+ minWidth: 32,
130
+ userSelect: "none",
131
+ fontSize: 16,
132
+ paddingTop: 1,
133
+ }}
134
+ >
135
+ {lineNum}
136
+ </span>
137
+ <span style={{ whiteSpace: "pre" }}>
138
+ {tokenize(line, language).map((seg, j) => (
139
+ <span key={j} style={{ color: seg.color }}>
140
+ {seg.text}
141
+ </span>
142
+ ))}
143
+ </span>
144
+ </div>
145
+ );
146
+ })}
147
+ </div>
148
+ </div>
149
+
150
+ </div>
151
+ );
152
+ };
153
+
154
+ function hexToRgb(hex: string): string {
155
+ const r = parseInt(hex.slice(1, 3), 16);
156
+ const g = parseInt(hex.slice(3, 5), 16);
157
+ const b = parseInt(hex.slice(5, 7), 16);
158
+ return `${r}, ${g}, ${b}`;
159
+ }
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import {
3
+ AlertCircle, ArrowRight, Bell, Brackets, Check, CheckCircle,
4
+ ChevronDown, ChevronRight, Clock, Cloud, Code, Copy, Database,
5
+ Download, Edit, Eye, EyeOff, File, FileCode, Fingerprint, Folder,
6
+ FolderOpen, Globe, HardDrive, Info, Key, Lock, Package, Plus,
7
+ Search, Server, Settings, Shield, Star, Terminal, Trash, Upload,
8
+ User, Users, X, Zap, type LucideProps,
9
+ } from "lucide-react";
10
+
11
+ type LucideIcon = React.FC<LucideProps>;
12
+
13
+ const ICON_MAP: Record<string, LucideIcon> = {
14
+ "alert-circle": AlertCircle,
15
+ "arrow-right": ArrowRight,
16
+ bell: Bell,
17
+ brackets: Brackets,
18
+ check: Check,
19
+ "check-circle": CheckCircle,
20
+ "chevron-down": ChevronDown,
21
+ "chevron-right": ChevronRight,
22
+ clock: Clock,
23
+ cloud: Cloud,
24
+ code: Code,
25
+ copy: Copy,
26
+ database: Database,
27
+ download: Download,
28
+ edit: Edit,
29
+ eye: Eye,
30
+ "eye-off": EyeOff,
31
+ file: File,
32
+ "file-code": FileCode,
33
+ fingerprint: Fingerprint,
34
+ folder: Folder,
35
+ "folder-open": FolderOpen,
36
+ globe: Globe,
37
+ "hard-drive": HardDrive,
38
+ info: Info,
39
+ key: Key,
40
+ lock: Lock,
41
+ package: Package,
42
+ plus: Plus,
43
+ search: Search,
44
+ server: Server,
45
+ settings: Settings,
46
+ shield: Shield,
47
+ star: Star,
48
+ terminal: Terminal,
49
+ trash: Trash,
50
+ upload: Upload,
51
+ user: User,
52
+ users: Users,
53
+ x: X,
54
+ zap: Zap,
55
+ };
56
+
57
+ export interface IconProps {
58
+ name: string;
59
+ size?: number;
60
+ color?: string;
61
+ strokeWidth?: number;
62
+ }
63
+
64
+ export const Icon: React.FC<IconProps> = ({ name, size = 20, color = "currentColor", strokeWidth = 1.5 }) => {
65
+ const LucideIcon = ICON_MAP[name.toLowerCase()];
66
+ if (!LucideIcon) return null;
67
+ return <LucideIcon size={size} color={color} strokeWidth={strokeWidth} />;
68
+ };
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface KeyPillProps {
6
+ label: string;
7
+ theme: Theme;
8
+ startFrame?: number;
9
+ }
10
+
11
+ export const KeyPill: React.FC<KeyPillProps> = ({ label, theme, startFrame = 0 }) => {
12
+ const frame = useCurrentFrame();
13
+ const { fps } = useVideoConfig();
14
+ const elapsed = frame - startFrame;
15
+
16
+ const progress = spring({ frame: elapsed, fps, config: { damping: 12, stiffness: 180, mass: 0.6 } });
17
+ const scale = interpolate(progress, [0, 1], [0.65, 1]);
18
+ const opacity = interpolate(progress, [0, 1], [0, 1]);
19
+
20
+ return (
21
+ <div
22
+ style={{
23
+ display: "inline-flex",
24
+ alignItems: "center",
25
+ justifyContent: "center",
26
+ padding: "10px 22px",
27
+ borderRadius: 10,
28
+ background: theme.surface,
29
+ border: `1.5px solid ${theme.border}`,
30
+ boxShadow: `0 4px 0 ${theme.border}`,
31
+ fontFamily: theme.fontSans,
32
+ fontSize: 30,
33
+ fontWeight: 600,
34
+ color: theme.text,
35
+ minWidth: 60,
36
+ letterSpacing: "-0.01em",
37
+ transform: `scale(${scale})`,
38
+ opacity,
39
+ }}
40
+ >
41
+ {label}
42
+ </div>
43
+ );
44
+ };
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface LabelProps {
6
+ text: string;
7
+ theme: Theme;
8
+ startFrame?: number;
9
+ size?: "sm" | "md" | "lg" | "xl";
10
+ align?: "left" | "center" | "right";
11
+ muted?: boolean;
12
+ }
13
+
14
+ const sizeMap = { sm: 22, md: 32, lg: 48, xl: 72 };
15
+
16
+ export const Label: React.FC<LabelProps> = ({
17
+ text,
18
+ theme,
19
+ startFrame = 0,
20
+ size = "md",
21
+ align = "center",
22
+ muted = false,
23
+ }) => {
24
+ const frame = useCurrentFrame();
25
+ const { fps } = useVideoConfig();
26
+ const elapsed = frame - startFrame;
27
+
28
+ const progress = spring({ frame: elapsed, fps, config: theme.motion });
29
+ const opacity = interpolate(progress, [0, 1], [0, 1]);
30
+ const translateY = interpolate(progress, [0, 1], [20, 0]);
31
+
32
+ return (
33
+ <div
34
+ style={{
35
+ opacity,
36
+ transform: `translateY(${translateY}px)`,
37
+ fontFamily: theme.fontSans,
38
+ fontSize: sizeMap[size],
39
+ fontWeight: size === "xl" || size === "lg" ? 700 : 500,
40
+ color: muted ? theme.textMuted : theme.text,
41
+ textAlign: align,
42
+ lineHeight: 1.25,
43
+ letterSpacing: size === "xl" ? "-0.02em" : "-0.01em",
44
+ }}
45
+ >
46
+ {text}
47
+ </div>
48
+ );
49
+ };
@@ -0,0 +1,110 @@
1
+ import React from "react";
2
+ import { useCurrentFrame } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface TerminalLine {
6
+ text: string;
7
+ isOutput?: boolean;
8
+ }
9
+
10
+ interface TerminalProps {
11
+ lines: TerminalLine[];
12
+ theme: Theme;
13
+ startFrame?: number;
14
+ framesPerLine?: number;
15
+ framesPerChar?: number;
16
+ }
17
+
18
+ export const Terminal: React.FC<TerminalProps> = ({
19
+ lines,
20
+ theme,
21
+ startFrame = 0,
22
+ framesPerLine = 23,
23
+ framesPerChar = 2.0,
24
+ }) => {
25
+ const frame = useCurrentFrame();
26
+ const elapsed = frame - startFrame;
27
+
28
+ const visibleLines: Array<{ text: string; isOutput: boolean; partialText: string }> = [];
29
+
30
+ let cursor = 0;
31
+ for (const line of lines) {
32
+ const lineStart = cursor;
33
+ const isOutput = line.isOutput ?? false;
34
+
35
+ if (isOutput) {
36
+ if (elapsed >= lineStart) {
37
+ visibleLines.push({ text: line.text, isOutput: true, partialText: line.text });
38
+ }
39
+ cursor += framesPerLine;
40
+ } else {
41
+ if (elapsed >= lineStart) {
42
+ const charsRevealed = Math.floor((elapsed - lineStart) / framesPerChar);
43
+ visibleLines.push({
44
+ text: line.text,
45
+ isOutput: false,
46
+ partialText: line.text.slice(0, charsRevealed),
47
+ });
48
+ }
49
+ cursor += line.text.length * framesPerChar + framesPerLine;
50
+ }
51
+ }
52
+
53
+ return (
54
+ <div
55
+ style={{
56
+ background: "#0d1117",
57
+ borderRadius: 10,
58
+ overflow: "hidden",
59
+ fontFamily: theme.fontMono,
60
+ fontSize: 22,
61
+ boxShadow: "0 8px 48px rgba(0,0,0,0.6)",
62
+ }}
63
+ >
64
+ <div
65
+ style={{
66
+ background: "#21262d",
67
+ padding: "10px 16px",
68
+ display: "flex",
69
+ alignItems: "center",
70
+ gap: 8,
71
+ }}
72
+ >
73
+ {(["#ff5f56", "#ffbd2e", "#27c93f"] as const).map((c) => (
74
+ <div key={c} style={{ width: 12, height: 12, borderRadius: "50%", background: c }} />
75
+ ))}
76
+ <span style={{ marginLeft: 8, color: "#8b949e", fontSize: 13 }}>terminal</span>
77
+ </div>
78
+
79
+ <div style={{ padding: "20px 24px", minHeight: 120 }}>
80
+ {visibleLines.map((line, i) => {
81
+ const isTyping =
82
+ !line.isOutput &&
83
+ i === visibleLines.length - 1 &&
84
+ line.partialText.length < line.text.length;
85
+ return (
86
+ <div
87
+ key={i}
88
+ style={{ color: line.isOutput ? "#8b949e" : "#e6edf3", marginBottom: 6, lineHeight: 1.6, whiteSpace: "pre" }}
89
+ >
90
+ {!line.isOutput && <span style={{ color: theme.accent, marginRight: 8 }}>$</span>}
91
+ {line.partialText}
92
+ {isTyping && (
93
+ <span
94
+ style={{
95
+ display: "inline-block",
96
+ width: 10,
97
+ height: "1em",
98
+ background: theme.accent,
99
+ verticalAlign: "text-bottom",
100
+ opacity: Math.floor(elapsed / 15) % 2 === 0 ? 1 : 0,
101
+ }}
102
+ />
103
+ )}
104
+ </div>
105
+ );
106
+ })}
107
+ </div>
108
+ </div>
109
+ );
110
+ };
@@ -0,0 +1,139 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
+ import { BrowserScene as BrowserBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Caption } from "../primitives/Caption";
6
+
7
+ interface Props {
8
+ scene: BrowserBrief;
9
+ theme: Theme;
10
+ }
11
+
12
+ export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
13
+ const frame = useCurrentFrame();
14
+ const { fps } = useVideoConfig();
15
+
16
+ const windowProgress = spring({ frame, fps, config: theme.motion });
17
+ const windowOpacity = interpolate(windowProgress, [0, 1], [0, 1]);
18
+ const windowScale = interpolate(windowProgress, [0, 1], [0.92, 1]);
19
+
20
+ // URL types character-by-character starting at frame 20
21
+ const urlChars = Math.max(0, Math.floor((frame - 20) / 2));
22
+ const displayUrl = scene.url.replace(/^https?:\/\//, "");
23
+ const visibleUrl = displayUrl.slice(0, urlChars);
24
+ const isCursorVisible = urlChars < displayUrl.length && Math.floor(frame / 10) % 2 === 0;
25
+
26
+ return (
27
+ <AbsoluteFill
28
+ style={{
29
+ background: theme.bg,
30
+ display: "flex",
31
+ alignItems: "center",
32
+ justifyContent: "center",
33
+ padding: "60px 120px",
34
+ }}
35
+ >
36
+ <div
37
+ style={{
38
+ width: "100%",
39
+ opacity: windowOpacity,
40
+ transform: `scale(${windowScale})`,
41
+ boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
42
+ borderRadius: 12,
43
+ overflow: "hidden",
44
+ }}
45
+ >
46
+ {/* Chrome bar */}
47
+ <div
48
+ style={{
49
+ background: theme.surface,
50
+ padding: "12px 16px",
51
+ display: "flex",
52
+ alignItems: "center",
53
+ gap: 8,
54
+ borderBottom: `1px solid ${theme.border}`,
55
+ }}
56
+ >
57
+ {(["#ff5f56", "#ffbd2e", "#27c93f"] as const).map((c) => (
58
+ <div key={c} style={{ width: 12, height: 12, borderRadius: "50%", background: c }} />
59
+ ))}
60
+ <div
61
+ style={{
62
+ flex: 1,
63
+ marginLeft: 16,
64
+ background: theme.bg,
65
+ borderRadius: 6,
66
+ padding: "6px 16px",
67
+ fontFamily: theme.fontMono,
68
+ fontSize: 15,
69
+ color: theme.textMuted,
70
+ border: `1px solid ${theme.border}`,
71
+ display: "flex",
72
+ alignItems: "center",
73
+ }}
74
+ >
75
+ <span style={{ color: theme.textMuted, marginRight: 4, opacity: 0.5 }}>https://</span>
76
+ <span style={{ color: theme.text }}>{visibleUrl}</span>
77
+ {isCursorVisible && (
78
+ <span
79
+ style={{
80
+ display: "inline-block",
81
+ width: 8,
82
+ height: "1em",
83
+ background: theme.accent,
84
+ verticalAlign: "text-bottom",
85
+ marginLeft: 1,
86
+ }}
87
+ />
88
+ )}
89
+ </div>
90
+ </div>
91
+
92
+ {/* Content area */}
93
+ <div
94
+ style={{
95
+ background: "#0d1117",
96
+ height: 500,
97
+ overflow: "hidden",
98
+ position: "relative",
99
+ display: "flex",
100
+ alignItems: "center",
101
+ justifyContent: "center",
102
+ }}
103
+ >
104
+ {scene.image ? (
105
+ <Img
106
+ src={staticFile(scene.image)}
107
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
108
+ />
109
+ ) : (
110
+ // Wireframe placeholder
111
+ <div style={{ width: "100%", height: "100%", padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
112
+ {/* Nav */}
113
+ <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
114
+ <div style={{ width: 80, height: 28, background: theme.surface, borderRadius: 6, border: `1px solid ${theme.border}` }} />
115
+ <div style={{ flex: 1 }} />
116
+ {[60, 60, 80].map((w, i) => (
117
+ <div key={i} style={{ width: w, height: 28, background: theme.surface, borderRadius: 6, border: `1px solid ${theme.border}` }} />
118
+ ))}
119
+ <div style={{ width: 100, height: 28, background: theme.accentMuted, borderRadius: 6, border: `1px solid ${theme.accent}` }} />
120
+ </div>
121
+ {/* Hero */}
122
+ <div style={{ flex: 2, background: theme.surface, borderRadius: 8, border: `1px solid ${theme.border}`, display: "flex", alignItems: "center", justifyContent: "center", gap: 16 }}>
123
+ <div style={{ width: 200, height: 24, background: theme.border, borderRadius: 4 }} />
124
+ </div>
125
+ {/* Cards */}
126
+ <div style={{ flex: 1, display: "flex", gap: 12 }}>
127
+ {[1, 2, 3].map((i) => (
128
+ <div key={i} style={{ flex: 1, background: theme.surface, borderRadius: 8, border: `1px solid ${theme.border}` }} />
129
+ ))}
130
+ </div>
131
+ </div>
132
+ )}
133
+ </div>
134
+ </div>
135
+
136
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
137
+ </AbsoluteFill>
138
+ );
139
+ };
@@ -0,0 +1,118 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
+ import { CTAScene as CTABrief, ProjectMeta } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Caption } from "../primitives/Caption";
6
+
7
+ interface Props {
8
+ scene: CTABrief;
9
+ theme: Theme;
10
+ project: ProjectMeta;
11
+ }
12
+
13
+ const SNAP_OFFSET = 8;
14
+
15
+ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
16
+ const frame = useCurrentFrame();
17
+ const { fps } = useVideoConfig();
18
+
19
+ const logoProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
20
+ const titleProgress = spring({ frame: frame + SNAP_OFFSET - (project.logo ? 16 : 0), fps, config: theme.motion });
21
+ const cmdProgress = spring({ frame: frame + SNAP_OFFSET - (project.logo ? 32 : 16), fps, config: theme.motion });
22
+ const urlProgress = spring({ frame: frame + SNAP_OFFSET - (project.logo ? 48 : 31), fps, config: theme.motion });
23
+
24
+ const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
25
+ const titleY = interpolate(titleProgress, [0, 1], [24, 0]);
26
+ const cmdOpacity = interpolate(cmdProgress, [0, 1], [0, 1]);
27
+ const cmdScale = interpolate(cmdProgress, [0, 1], [0.92, 1]);
28
+ const urlFade = interpolate(urlProgress, [0, 1], [0, 0.7]);
29
+
30
+ const isAnnouncement = project.mode === "announcement";
31
+ const displayName = isAnnouncement && project.version
32
+ ? `${project.name} ${project.version}`
33
+ : project.name;
34
+
35
+ return (
36
+ <AbsoluteFill
37
+ style={{
38
+ background: theme.accent,
39
+ display: "flex",
40
+ flexDirection: "column",
41
+ alignItems: "center",
42
+ justifyContent: "center",
43
+ gap: 32,
44
+ padding: "0 120px",
45
+ }}
46
+ >
47
+ {project.logo && (
48
+ <div
49
+ style={{
50
+ opacity: interpolate(logoProgress, [0, 1], [0, 1]),
51
+ transform: `scale(${interpolate(logoProgress, [0, 1], [0.85, 1])})`,
52
+ background: "#ffffff",
53
+ borderRadius: 20,
54
+ padding: 16,
55
+ boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
56
+ }}
57
+ >
58
+ <Img
59
+ src={staticFile(project.logo)}
60
+ style={{ height: 80, width: "auto", objectFit: "contain", display: "block" }}
61
+ />
62
+ </div>
63
+ )}
64
+
65
+ <div
66
+ style={{
67
+ opacity: titleOpacity,
68
+ transform: `translateY(${titleY}px)`,
69
+ fontFamily: theme.fontSans,
70
+ fontSize: 56,
71
+ fontWeight: 700,
72
+ color: theme.textInverse,
73
+ letterSpacing: "-0.02em",
74
+ textAlign: "center",
75
+ }}
76
+ >
77
+ {isAnnouncement ? "" : "Get started with "}
78
+ <span style={{ color: theme.textInverse, fontWeight: 800 }}>{displayName}</span>
79
+ {isAnnouncement ? " is here." : ""}
80
+ </div>
81
+
82
+ <div
83
+ style={{
84
+ opacity: cmdOpacity,
85
+ transform: `scale(${cmdScale})`,
86
+ background: theme.bg,
87
+ border: `1.5px solid rgba(0,0,0,0.12)`,
88
+ borderRadius: 12,
89
+ padding: "18px 40px",
90
+ fontFamily: theme.fontMono,
91
+ fontSize: 28,
92
+ color: theme.text,
93
+ display: "flex",
94
+ alignItems: "center",
95
+ gap: 16,
96
+ }}
97
+ >
98
+ <span style={{ color: theme.accent }}>$</span>
99
+ {scene.installCommand}
100
+ </div>
101
+
102
+ {scene.repoUrl && (
103
+ <div
104
+ style={{
105
+ opacity: urlFade,
106
+ fontFamily: theme.fontMono,
107
+ fontSize: 20,
108
+ color: theme.textInverse,
109
+ }}
110
+ >
111
+ {scene.repoUrl}
112
+ </div>
113
+ )}
114
+
115
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
116
+ </AbsoluteFill>
117
+ );
118
+ };