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,166 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { OSWindowScene as OSWindowBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Icon } from "../primitives/Icon";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: OSWindowBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const WINDOW_WIDTH = 600;
|
|
14
|
+
const SEARCH_TYPE_START = 18;
|
|
15
|
+
const FRAMES_PER_CHAR = 3;
|
|
16
|
+
const ITEMS_START_OFFSET = 10;
|
|
17
|
+
const FRAMES_PER_ITEM = 20;
|
|
18
|
+
|
|
19
|
+
const TRAFFIC_LIGHTS = ["#ff5f57", "#ffbd2e", "#28c941"] as const;
|
|
20
|
+
|
|
21
|
+
export const OSWindow: React.FC<Props> = ({ scene, theme }) => {
|
|
22
|
+
const frame = useCurrentFrame();
|
|
23
|
+
const { fps } = useVideoConfig();
|
|
24
|
+
|
|
25
|
+
const windowProgress = spring({ frame, fps, config: theme.motion });
|
|
26
|
+
const windowScale = interpolate(windowProgress, [0, 1], [0.88, 1]);
|
|
27
|
+
const windowOpacity = interpolate(windowProgress, [0, 1], [0, 1]);
|
|
28
|
+
|
|
29
|
+
const query = scene.searchQuery ?? "";
|
|
30
|
+
const charsVisible = Math.max(0, Math.floor((frame - SEARCH_TYPE_START) / FRAMES_PER_CHAR));
|
|
31
|
+
const typedText = query.slice(0, charsVisible);
|
|
32
|
+
|
|
33
|
+
const itemsStart = query
|
|
34
|
+
? SEARCH_TYPE_START + query.length * FRAMES_PER_CHAR + ITEMS_START_OFFSET
|
|
35
|
+
: 20;
|
|
36
|
+
const captionStart = itemsStart + (scene.items?.length ?? 0) * FRAMES_PER_ITEM + 20;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<AbsoluteFill
|
|
40
|
+
style={{ background: theme.bg, display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
41
|
+
>
|
|
42
|
+
<div
|
|
43
|
+
style={{
|
|
44
|
+
width: WINDOW_WIDTH,
|
|
45
|
+
borderRadius: 14,
|
|
46
|
+
overflow: "hidden",
|
|
47
|
+
background: theme.surface,
|
|
48
|
+
border: `1px solid ${theme.border}`,
|
|
49
|
+
boxShadow: "0 40px 100px rgba(0,0,0,0.45), 0 0 0 0.5px rgba(255,255,255,0.05)",
|
|
50
|
+
transform: `scale(${windowScale})`,
|
|
51
|
+
opacity: windowOpacity,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{/* Title bar */}
|
|
55
|
+
<div
|
|
56
|
+
style={{
|
|
57
|
+
display: "flex",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
padding: "14px 16px",
|
|
60
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
61
|
+
position: "relative",
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
65
|
+
{TRAFFIC_LIGHTS.map((color, i) => (
|
|
66
|
+
<div key={i} style={{ width: 13, height: 13, borderRadius: "50%", background: color }} />
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
{scene.title && (
|
|
70
|
+
<div
|
|
71
|
+
style={{
|
|
72
|
+
position: "absolute",
|
|
73
|
+
left: "50%",
|
|
74
|
+
transform: "translateX(-50%)",
|
|
75
|
+
fontFamily: theme.fontSans,
|
|
76
|
+
fontSize: 13,
|
|
77
|
+
fontWeight: 500,
|
|
78
|
+
color: theme.textMuted,
|
|
79
|
+
letterSpacing: "0.01em",
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{scene.title}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Search bar */}
|
|
88
|
+
{scene.searchQuery !== undefined && (
|
|
89
|
+
<div style={{ padding: "10px 12px", borderBottom: `1px solid ${theme.border}` }}>
|
|
90
|
+
<div
|
|
91
|
+
style={{
|
|
92
|
+
display: "flex",
|
|
93
|
+
alignItems: "center",
|
|
94
|
+
gap: 8,
|
|
95
|
+
background: theme.bg,
|
|
96
|
+
borderRadius: 8,
|
|
97
|
+
padding: "8px 12px",
|
|
98
|
+
border: `1px solid ${theme.border}`,
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<Icon name="search" size={15} color={theme.textMuted} />
|
|
102
|
+
<span style={{ fontFamily: theme.fontSans, fontSize: 14, color: typedText ? theme.text : theme.textMuted }}>
|
|
103
|
+
{typedText || "Search…"}
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{/* Item list */}
|
|
110
|
+
<div style={{ padding: "6px 0" }}>
|
|
111
|
+
{(scene.items ?? []).map((item, i) => {
|
|
112
|
+
const itemStart = itemsStart + i * FRAMES_PER_ITEM;
|
|
113
|
+
const p = spring({ frame: frame - itemStart, fps, config: theme.motion });
|
|
114
|
+
const opacity = interpolate(Math.max(0, p), [0, 1], [0, 1]);
|
|
115
|
+
const tx = interpolate(Math.max(0, p), [0, 1], [-14, 0]);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
key={i}
|
|
120
|
+
style={{
|
|
121
|
+
display: "flex",
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
gap: 12,
|
|
124
|
+
padding: "9px 14px",
|
|
125
|
+
background: item.highlighted ? theme.accentMuted : "transparent",
|
|
126
|
+
opacity,
|
|
127
|
+
transform: `translateX(${tx}px)`,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{item.icon && (
|
|
131
|
+
<div
|
|
132
|
+
style={{
|
|
133
|
+
width: 30,
|
|
134
|
+
height: 30,
|
|
135
|
+
borderRadius: 7,
|
|
136
|
+
background: theme.accentMuted,
|
|
137
|
+
border: `1px solid ${theme.border}`,
|
|
138
|
+
display: "flex",
|
|
139
|
+
alignItems: "center",
|
|
140
|
+
justifyContent: "center",
|
|
141
|
+
flexShrink: 0,
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<Icon name={item.icon} size={15} color={theme.accent} />
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
148
|
+
<div style={{ fontFamily: theme.fontMono, fontSize: 13, fontWeight: 500, color: theme.text }}>
|
|
149
|
+
{item.label}
|
|
150
|
+
</div>
|
|
151
|
+
{item.value && (
|
|
152
|
+
<div style={{ fontFamily: theme.fontMono, fontSize: 12, color: theme.textMuted, marginTop: 2 }}>
|
|
153
|
+
{item.value}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
164
|
+
</AbsoluteFill>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { ProblemScene as ProblemBrief, ProjectMeta } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Label } from "../primitives/Label";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: ProblemBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
project: ProjectMeta;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SNAP_OFFSET = 8;
|
|
15
|
+
|
|
16
|
+
export const Problem: React.FC<Props> = ({ scene, theme, project }) => {
|
|
17
|
+
const frame = useCurrentFrame();
|
|
18
|
+
const { fps } = useVideoConfig();
|
|
19
|
+
|
|
20
|
+
const isAnnouncement = project.mode === "announcement" && project.version;
|
|
21
|
+
const isHero = !!scene.hero;
|
|
22
|
+
|
|
23
|
+
const accentBarProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
|
|
24
|
+
const accentBarWidth = interpolate(accentBarProgress, [0, 1], [0, isHero ? 120 : 80]);
|
|
25
|
+
|
|
26
|
+
const badgeProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
|
|
27
|
+
const badgeOpacity = interpolate(badgeProgress, [0, 1], [0, 1]);
|
|
28
|
+
const badgeY = interpolate(badgeProgress, [0, 1], [12, 0]);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<AbsoluteFill
|
|
32
|
+
style={{
|
|
33
|
+
background: theme.bg,
|
|
34
|
+
display: "flex",
|
|
35
|
+
flexDirection: "column",
|
|
36
|
+
alignItems: "center",
|
|
37
|
+
justifyContent: "center",
|
|
38
|
+
padding: isHero ? "0 80px" : "0 120px",
|
|
39
|
+
gap: isHero ? 40 : 32,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{isAnnouncement ? (
|
|
43
|
+
<div
|
|
44
|
+
style={{
|
|
45
|
+
opacity: badgeOpacity,
|
|
46
|
+
transform: `translateY(${badgeY}px)`,
|
|
47
|
+
background: theme.accentMuted,
|
|
48
|
+
border: `1.5px solid ${theme.accent}`,
|
|
49
|
+
borderRadius: 999,
|
|
50
|
+
padding: "6px 20px",
|
|
51
|
+
fontFamily: theme.fontMono,
|
|
52
|
+
fontSize: 18,
|
|
53
|
+
fontWeight: 600,
|
|
54
|
+
color: theme.accent,
|
|
55
|
+
letterSpacing: "0.02em",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{project.name} {project.version}
|
|
59
|
+
</div>
|
|
60
|
+
) : (
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
width: accentBarWidth,
|
|
64
|
+
height: isHero ? 6 : 4,
|
|
65
|
+
background: theme.accent,
|
|
66
|
+
borderRadius: 3,
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{isHero ? (
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
fontFamily: theme.fontSans,
|
|
75
|
+
fontSize: 104,
|
|
76
|
+
fontWeight: 800,
|
|
77
|
+
color: theme.text,
|
|
78
|
+
textAlign: "center",
|
|
79
|
+
lineHeight: 1.05,
|
|
80
|
+
letterSpacing: "-0.03em",
|
|
81
|
+
opacity: interpolate(
|
|
82
|
+
spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion }),
|
|
83
|
+
[0, 1], [0, 1]
|
|
84
|
+
),
|
|
85
|
+
transform: `translateY(${interpolate(
|
|
86
|
+
spring({ frame: frame + SNAP_OFFSET - 4, fps, config: theme.motion }),
|
|
87
|
+
[0, 1], [24, 0]
|
|
88
|
+
)}px)`,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{scene.headline}
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
<>
|
|
95
|
+
<Label text={scene.headline} theme={theme} size="xl" startFrame={4} />
|
|
96
|
+
{scene.subtext && (
|
|
97
|
+
<Label text={scene.subtext} theme={theme} size="md" muted startFrame={16} />
|
|
98
|
+
)}
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={40} />}
|
|
103
|
+
</AbsoluteFill>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { SplitScene as SplitBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Caption } from "../primitives/Caption";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
scene: SplitBrief;
|
|
9
|
+
theme: Theme;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const SplitComparison: React.FC<Props> = ({ scene, theme }) => {
|
|
13
|
+
const frame = useCurrentFrame();
|
|
14
|
+
const { fps } = useVideoConfig();
|
|
15
|
+
|
|
16
|
+
const leftProgress = spring({ frame, fps, config: theme.motion });
|
|
17
|
+
const rightProgress = spring({ frame: frame - 12, fps, config: theme.motion });
|
|
18
|
+
const dividerProgress = spring({ frame: frame - 6, fps, config: theme.motion });
|
|
19
|
+
|
|
20
|
+
const leftOpacity = interpolate(Math.max(0, leftProgress), [0, 1], [0, 1]);
|
|
21
|
+
const leftX = interpolate(Math.max(0, leftProgress), [0, 1], [-60, 0]);
|
|
22
|
+
const rightOpacity = interpolate(Math.max(0, rightProgress), [0, 1], [0, 1]);
|
|
23
|
+
const rightX = interpolate(Math.max(0, rightProgress), [0, 1], [60, 0]);
|
|
24
|
+
const dividerOpacity = interpolate(Math.max(0, dividerProgress), [0, 1], [0, 1]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<AbsoluteFill
|
|
28
|
+
style={{
|
|
29
|
+
background: theme.bg,
|
|
30
|
+
display: "flex",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
justifyContent: "center",
|
|
33
|
+
padding: "60px 80px",
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<div style={{ display: "flex", width: "100%", height: "100%", maxHeight: 700, alignItems: "stretch", gap: 0 }}>
|
|
37
|
+
{/* Before panel */}
|
|
38
|
+
<div
|
|
39
|
+
style={{
|
|
40
|
+
flex: 1,
|
|
41
|
+
opacity: leftOpacity,
|
|
42
|
+
transform: `translateX(${leftX}px)`,
|
|
43
|
+
display: "flex",
|
|
44
|
+
flexDirection: "column",
|
|
45
|
+
gap: 16,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
style={{
|
|
50
|
+
fontFamily: theme.fontSans,
|
|
51
|
+
fontSize: 13,
|
|
52
|
+
fontWeight: 700,
|
|
53
|
+
letterSpacing: "0.1em",
|
|
54
|
+
textTransform: "uppercase",
|
|
55
|
+
color: theme.textMuted,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{scene.before.label}
|
|
59
|
+
</div>
|
|
60
|
+
<div
|
|
61
|
+
style={{
|
|
62
|
+
flex: 1,
|
|
63
|
+
background: theme.surface,
|
|
64
|
+
border: `1.5px solid ${theme.border}`,
|
|
65
|
+
borderRadius: 10,
|
|
66
|
+
padding: "24px 28px",
|
|
67
|
+
fontFamily: theme.fontMono,
|
|
68
|
+
fontSize: 18,
|
|
69
|
+
color: theme.textMuted,
|
|
70
|
+
whiteSpace: "pre-wrap",
|
|
71
|
+
lineHeight: 1.7,
|
|
72
|
+
overflow: "hidden",
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{scene.before.content}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Divider */}
|
|
80
|
+
<div
|
|
81
|
+
style={{
|
|
82
|
+
width: 2,
|
|
83
|
+
alignSelf: "stretch",
|
|
84
|
+
background: theme.border,
|
|
85
|
+
margin: "36px 32px 0",
|
|
86
|
+
opacity: dividerOpacity,
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{/* After panel */}
|
|
91
|
+
<div
|
|
92
|
+
style={{
|
|
93
|
+
flex: 1,
|
|
94
|
+
opacity: rightOpacity,
|
|
95
|
+
transform: `translateX(${rightX}px)`,
|
|
96
|
+
display: "flex",
|
|
97
|
+
flexDirection: "column",
|
|
98
|
+
gap: 16,
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div
|
|
102
|
+
style={{
|
|
103
|
+
fontFamily: theme.fontSans,
|
|
104
|
+
fontSize: 13,
|
|
105
|
+
fontWeight: 700,
|
|
106
|
+
letterSpacing: "0.1em",
|
|
107
|
+
textTransform: "uppercase",
|
|
108
|
+
color: theme.accent,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{scene.after.label}
|
|
112
|
+
</div>
|
|
113
|
+
<div
|
|
114
|
+
style={{
|
|
115
|
+
flex: 1,
|
|
116
|
+
background: theme.surface,
|
|
117
|
+
border: `1.5px solid ${theme.accent}55`,
|
|
118
|
+
borderRadius: 10,
|
|
119
|
+
padding: "24px 28px",
|
|
120
|
+
fontFamily: theme.fontMono,
|
|
121
|
+
fontSize: 18,
|
|
122
|
+
color: theme.text,
|
|
123
|
+
whiteSpace: "pre-wrap",
|
|
124
|
+
lineHeight: 1.7,
|
|
125
|
+
overflow: "hidden",
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{scene.after.content}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} />}
|
|
134
|
+
</AbsoluteFill>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { StatCalloutScene as StatCalloutBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Label } from "../primitives/Label";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: StatCalloutBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HEADLINE_FRAMES = 20;
|
|
14
|
+
const FRAMES_PER_STAT = 35;
|
|
15
|
+
const SNAP_OFFSET = 8;
|
|
16
|
+
|
|
17
|
+
export const StatCallout: React.FC<Props> = ({ scene, theme }) => {
|
|
18
|
+
const frame = useCurrentFrame();
|
|
19
|
+
const { fps } = useVideoConfig();
|
|
20
|
+
|
|
21
|
+
const captionStart = HEADLINE_FRAMES + scene.stats.length * FRAMES_PER_STAT + 20;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<AbsoluteFill
|
|
25
|
+
style={{
|
|
26
|
+
background: theme.bg,
|
|
27
|
+
display: "flex",
|
|
28
|
+
flexDirection: "column",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
justifyContent: "center",
|
|
31
|
+
padding: "0 120px",
|
|
32
|
+
gap: 64,
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
{scene.headline && (
|
|
36
|
+
<Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
<div
|
|
40
|
+
style={{
|
|
41
|
+
display: "flex",
|
|
42
|
+
gap: 80,
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
justifyContent: "center",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{scene.stats.map((stat, i) => {
|
|
48
|
+
const statStart = HEADLINE_FRAMES + i * FRAMES_PER_STAT;
|
|
49
|
+
const progress = spring({
|
|
50
|
+
frame: frame - statStart + SNAP_OFFSET,
|
|
51
|
+
fps,
|
|
52
|
+
config: theme.motion,
|
|
53
|
+
});
|
|
54
|
+
const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
|
|
55
|
+
const scale = interpolate(Math.max(0, progress), [0, 1], [0.4, 1]);
|
|
56
|
+
|
|
57
|
+
const labelStart = statStart + 12;
|
|
58
|
+
const labelProgress = spring({
|
|
59
|
+
frame: frame - labelStart + SNAP_OFFSET,
|
|
60
|
+
fps,
|
|
61
|
+
config: theme.motion,
|
|
62
|
+
});
|
|
63
|
+
const labelOpacity = interpolate(Math.max(0, labelProgress), [0, 1], [0, 1]);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
key={i}
|
|
68
|
+
style={{
|
|
69
|
+
display: "flex",
|
|
70
|
+
flexDirection: "column",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
gap: 16,
|
|
73
|
+
opacity,
|
|
74
|
+
transform: `scale(${scale})`,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div
|
|
78
|
+
style={{
|
|
79
|
+
fontFamily: theme.fontSans,
|
|
80
|
+
fontSize: 96,
|
|
81
|
+
fontWeight: 800,
|
|
82
|
+
color: theme.accent,
|
|
83
|
+
lineHeight: 1,
|
|
84
|
+
letterSpacing: "-0.04em",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{stat.value}
|
|
88
|
+
</div>
|
|
89
|
+
<div
|
|
90
|
+
style={{
|
|
91
|
+
fontFamily: theme.fontSans,
|
|
92
|
+
fontSize: 22,
|
|
93
|
+
fontWeight: 500,
|
|
94
|
+
color: theme.textMuted,
|
|
95
|
+
opacity: labelOpacity,
|
|
96
|
+
letterSpacing: "0.04em",
|
|
97
|
+
textTransform: "uppercase",
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{stat.label}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
108
|
+
</AbsoluteFill>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill } from "remotion";
|
|
3
|
+
import { TerminalScene as TerminalBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Terminal } from "../primitives/Terminal";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: TerminalBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const TerminalScene: React.FC<Props> = ({ scene, theme }) => {
|
|
14
|
+
const lines = scene.commands.flatMap((cmd) => [
|
|
15
|
+
{ text: cmd.input, isOutput: false },
|
|
16
|
+
{ text: cmd.output, isOutput: true },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Mirror Terminal's timing: startFrame=8, framesPerLine=23, framesPerChar=2.0
|
|
20
|
+
const terminalDuration = lines.reduce((acc, line) => {
|
|
21
|
+
return acc + (line.isOutput ? 23 : line.text.length * 2.0 + 23);
|
|
22
|
+
}, 0);
|
|
23
|
+
const captionStart = 8 + terminalDuration + 20;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<AbsoluteFill
|
|
27
|
+
style={{
|
|
28
|
+
background: theme.bg,
|
|
29
|
+
display: "flex",
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
justifyContent: "center",
|
|
32
|
+
padding: "60px 100px",
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
<div style={{ width: "100%" }}>
|
|
36
|
+
<Terminal lines={lines} theme={theme} startFrame={8} />
|
|
37
|
+
</div>
|
|
38
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
39
|
+
</AbsoluteFill>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { FeatureListScene, FileTreeScene, HotkeyScene, OSWindowScene, Scene, StatCalloutScene } from "./brief";
|
|
2
|
+
|
|
3
|
+
// Hard ceiling for the teaser cut: 10s at 30fps. Renders over the limit
|
|
4
|
+
// succeed, but the CLI prints a prominent warning.
|
|
5
|
+
export const TEASER_MAX_FRAMES = 300;
|
|
6
|
+
|
|
7
|
+
// Frames appended after each scene's content animation settles.
|
|
8
|
+
// Gives content time to breathe and provides room for the fade-out.
|
|
9
|
+
export const SCENE_TAIL = 30;
|
|
10
|
+
|
|
11
|
+
export const SCENE_DURATION_MAP: Record<Scene["type"], number> = {
|
|
12
|
+
problem: 120,
|
|
13
|
+
"code-reveal": 165,
|
|
14
|
+
terminal: 150,
|
|
15
|
+
"data-flow": 200,
|
|
16
|
+
cta: 120,
|
|
17
|
+
browser: 150,
|
|
18
|
+
split: 165,
|
|
19
|
+
"feature-list": 180,
|
|
20
|
+
"stat-callout": 180,
|
|
21
|
+
"file-tree": 200,
|
|
22
|
+
mobile: 150,
|
|
23
|
+
"os-window": 180,
|
|
24
|
+
hotkey: 120,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function sceneDuration(scene: Scene): number {
|
|
28
|
+
let content: number;
|
|
29
|
+
if (scene.type === "feature-list") {
|
|
30
|
+
const s = scene as FeatureListScene;
|
|
31
|
+
content = 20 + s.items.length * 25 + 60;
|
|
32
|
+
} else if (scene.type === "stat-callout") {
|
|
33
|
+
const s = scene as StatCalloutScene;
|
|
34
|
+
content = 20 + s.stats.length * 35 + 60;
|
|
35
|
+
} else if (scene.type === "file-tree") {
|
|
36
|
+
const s = scene as FileTreeScene;
|
|
37
|
+
content = 20 + s.entries.length * 20 + 60;
|
|
38
|
+
} else if (scene.type === "os-window") {
|
|
39
|
+
const s = scene as OSWindowScene;
|
|
40
|
+
const searchDuration = s.searchQuery ? s.searchQuery.length * 3 + 10 : 0;
|
|
41
|
+
content = 20 + searchDuration + (s.items?.length ?? 0) * 20 + 60;
|
|
42
|
+
} else if (scene.type === "hotkey") {
|
|
43
|
+
const s = scene as HotkeyScene;
|
|
44
|
+
content = 20 + s.keys.length * 20 + 70;
|
|
45
|
+
} else {
|
|
46
|
+
content = SCENE_DURATION_MAP[scene.type];
|
|
47
|
+
}
|
|
48
|
+
return content + SCENE_TAIL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Duration is computed per cut: callers pass the scene list of the chosen cut
|
|
52
|
+
// (cuts.main, cuts.vertical, or cuts.teaser).
|
|
53
|
+
export function calcTotalDuration(scenes: Scene[]): number {
|
|
54
|
+
return scenes.reduce((sum, scene) => sum + sceneDuration(scene), 0);
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadFont as loadInter, fontFamily as inter } from "@remotion/google-fonts/Inter";
|
|
2
|
+
import { loadFont as loadSpaceGrotesk, fontFamily as spaceGrotesk } from "@remotion/google-fonts/SpaceGrotesk";
|
|
3
|
+
import { loadFont as loadDMSans, fontFamily as dmSans } from "@remotion/google-fonts/DMSans";
|
|
4
|
+
import { loadFont as loadSyne, fontFamily as syne } from "@remotion/google-fonts/Syne";
|
|
5
|
+
import { loadFont as loadPlusJakartaSans, fontFamily as plusJakartaSans } from "@remotion/google-fonts/PlusJakartaSans";
|
|
6
|
+
import { loadFont as loadNunito, fontFamily as nunito } from "@remotion/google-fonts/Nunito";
|
|
7
|
+
import { loadFont as loadIBMPlexSans, fontFamily as ibmPlexSans } from "@remotion/google-fonts/IBMPlexSans";
|
|
8
|
+
import { loadFont as loadJetBrainsMono, fontFamily as jetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
|
|
9
|
+
import { loadFont as loadSpaceMono, fontFamily as spaceMono } from "@remotion/google-fonts/SpaceMono";
|
|
10
|
+
|
|
11
|
+
loadInter();
|
|
12
|
+
loadSpaceGrotesk();
|
|
13
|
+
loadDMSans();
|
|
14
|
+
loadSyne();
|
|
15
|
+
loadPlusJakartaSans();
|
|
16
|
+
loadNunito();
|
|
17
|
+
loadIBMPlexSans();
|
|
18
|
+
loadJetBrainsMono();
|
|
19
|
+
loadSpaceMono();
|
|
20
|
+
|
|
21
|
+
export const SANS_FONTS: Record<string, string> = {
|
|
22
|
+
inter,
|
|
23
|
+
"space-grotesk": spaceGrotesk,
|
|
24
|
+
"dm-sans": dmSans,
|
|
25
|
+
syne,
|
|
26
|
+
"plus-jakarta-sans": plusJakartaSans,
|
|
27
|
+
nunito,
|
|
28
|
+
"ibm-plex-sans": ibmPlexSans,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const MONO_FONTS: Record<string, string> = {
|
|
32
|
+
"jetbrains-mono": jetBrainsMono,
|
|
33
|
+
"space-mono": spaceMono,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SANS = inter;
|
|
37
|
+
export const DEFAULT_MONO = jetBrainsMono;
|
|
38
|
+
|
|
39
|
+
export function resolveSansFont(name?: string): string {
|
|
40
|
+
if (!name) return DEFAULT_SANS;
|
|
41
|
+
const key = name.toLowerCase().replace(/\s+/g, "-");
|
|
42
|
+
return SANS_FONTS[key] ?? DEFAULT_SANS;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveMonoFont(name?: string): string {
|
|
46
|
+
if (!name) return DEFAULT_MONO;
|
|
47
|
+
const key = name.toLowerCase().replace(/\s+/g, "-");
|
|
48
|
+
return MONO_FONTS[key] ?? DEFAULT_MONO;
|
|
49
|
+
}
|