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.
- package/package.json +34 -0
- package/src/cache.mjs +148 -0
- package/src/cli.mjs +57 -0
- package/src/render.mjs +47 -0
- package/template/package.json +29 -0
- package/template/pnpm-lock.yaml +3522 -0
- package/template/pnpm-workspace.yaml +4 -0
- package/template/public/.gitkeep +0 -0
- package/template/remotion.config.ts +4 -0
- package/template/src/Root.tsx +127 -0
- package/template/src/brief.json +131 -0
- package/template/src/brief.ts +195 -0
- package/template/src/components/primitives/Arrow.tsx +83 -0
- package/template/src/components/primitives/Caption.tsx +52 -0
- package/template/src/components/primitives/CodeBlock.tsx +159 -0
- package/template/src/components/primitives/Icon.tsx +68 -0
- package/template/src/components/primitives/KeyPill.tsx +44 -0
- package/template/src/components/primitives/Label.tsx +49 -0
- package/template/src/components/primitives/Terminal.tsx +110 -0
- package/template/src/components/scenes/BrowserFrame.tsx +139 -0
- package/template/src/components/scenes/CTA.tsx +118 -0
- package/template/src/components/scenes/CodeReveal.tsx +41 -0
- package/template/src/components/scenes/DataFlow.tsx +102 -0
- package/template/src/components/scenes/FeatureList.tsx +110 -0
- package/template/src/components/scenes/FileTree.tsx +125 -0
- package/template/src/components/scenes/HotkeyScene.tsx +77 -0
- package/template/src/components/scenes/MobileScreen.tsx +212 -0
- package/template/src/components/scenes/OSWindowScene.tsx +166 -0
- package/template/src/components/scenes/Problem.tsx +105 -0
- package/template/src/components/scenes/SplitComparison.tsx +136 -0
- package/template/src/components/scenes/StatCallout.tsx +110 -0
- package/template/src/components/scenes/TerminalScene.tsx +41 -0
- package/template/src/duration.ts +55 -0
- package/template/src/fonts.ts +49 -0
- package/template/src/index.ts +106 -0
- package/template/src/platforms.json +78 -0
- package/template/src/platforms.ts +36 -0
- package/template/src/theme.ts +88 -0
- 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
|
+
};
|