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,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill } from "remotion";
|
|
3
|
+
import { CodeRevealScene as CodeRevealBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { CodeBlock } from "../primitives/CodeBlock";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: CodeRevealBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
|
|
14
|
+
const lines = scene.code.split("\n");
|
|
15
|
+
// Caption appears after all lines are revealed: startFrame=14, framesPerLine=9, plus 20-frame buffer
|
|
16
|
+
const captionStart = 14 + lines.length * 9 + 20;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<AbsoluteFill
|
|
20
|
+
style={{
|
|
21
|
+
background: theme.bg,
|
|
22
|
+
display: "flex",
|
|
23
|
+
alignItems: "center",
|
|
24
|
+
justifyContent: "center",
|
|
25
|
+
padding: "60px 100px",
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<div style={{ width: "100%" }}>
|
|
29
|
+
<CodeBlock
|
|
30
|
+
code={scene.code}
|
|
31
|
+
language={scene.language}
|
|
32
|
+
highlightLine={scene.highlightLine}
|
|
33
|
+
theme={theme}
|
|
34
|
+
startFrame={14}
|
|
35
|
+
framesPerLine={9}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
39
|
+
</AbsoluteFill>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { DataFlowScene as DataFlowBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Arrow } from "../primitives/Arrow";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: DataFlowBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const NODE_W = 200;
|
|
14
|
+
const NODE_H = 64;
|
|
15
|
+
|
|
16
|
+
export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
|
|
17
|
+
const frame = useCurrentFrame();
|
|
18
|
+
const { fps, width, height } = useVideoConfig();
|
|
19
|
+
|
|
20
|
+
const nodeCount = scene.nodes.length;
|
|
21
|
+
const spacing = Math.min(320, (width - 240) / nodeCount);
|
|
22
|
+
const startX = (width - spacing * (nodeCount - 1) - NODE_W) / 2;
|
|
23
|
+
const centerY = height / 2 - NODE_H / 2;
|
|
24
|
+
|
|
25
|
+
const positions: Record<string, { x: number; y: number }> = {};
|
|
26
|
+
scene.nodes.forEach((node, i) => {
|
|
27
|
+
positions[node.id] = { x: startX + i * spacing, y: centerY };
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const NODE_SETTLE = 18;
|
|
31
|
+
const ARROW_DRAW = 22;
|
|
32
|
+
const STEP = NODE_SETTLE + ARROW_DRAW; // 40 frames per pair
|
|
33
|
+
|
|
34
|
+
const nodeStartFrame = (i: number) => i * STEP;
|
|
35
|
+
const arrowStartFrame = (i: number) => i * STEP + NODE_SETTLE;
|
|
36
|
+
|
|
37
|
+
// Caption appears after the last node settles
|
|
38
|
+
const captionStart = (nodeCount - 1) * STEP + NODE_SETTLE + 20;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<AbsoluteFill style={{ background: theme.bg }}>
|
|
42
|
+
{/* SVG layer for arrows */}
|
|
43
|
+
<svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
|
|
44
|
+
{scene.edges.map((edge, i) => {
|
|
45
|
+
const from = positions[edge.from];
|
|
46
|
+
const to = positions[edge.to];
|
|
47
|
+
if (!from || !to) return null;
|
|
48
|
+
return (
|
|
49
|
+
<Arrow
|
|
50
|
+
key={i}
|
|
51
|
+
x1={from.x + NODE_W}
|
|
52
|
+
y1={from.y + NODE_H / 2}
|
|
53
|
+
x2={to.x}
|
|
54
|
+
y2={to.y + NODE_H / 2}
|
|
55
|
+
label={edge.label}
|
|
56
|
+
theme={theme}
|
|
57
|
+
startFrame={arrowStartFrame(i)}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</svg>
|
|
62
|
+
|
|
63
|
+
{/* Node boxes */}
|
|
64
|
+
{scene.nodes.map((node, i) => {
|
|
65
|
+
const pos = positions[node.id];
|
|
66
|
+
const progress = spring({ frame: frame - nodeStartFrame(i), fps, config: theme.motion });
|
|
67
|
+
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
|
68
|
+
const scale = interpolate(progress, [0, 1], [0.85, 1]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
key={node.id}
|
|
73
|
+
style={{
|
|
74
|
+
position: "absolute",
|
|
75
|
+
left: pos.x,
|
|
76
|
+
top: pos.y,
|
|
77
|
+
width: NODE_W,
|
|
78
|
+
height: NODE_H,
|
|
79
|
+
background: theme.surface,
|
|
80
|
+
border: `1.5px solid ${theme.border}`,
|
|
81
|
+
borderRadius: 10,
|
|
82
|
+
display: "flex",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
justifyContent: "center",
|
|
85
|
+
fontFamily: theme.fontSans,
|
|
86
|
+
fontSize: 18,
|
|
87
|
+
fontWeight: 600,
|
|
88
|
+
color: theme.text,
|
|
89
|
+
opacity,
|
|
90
|
+
transform: `scale(${scale})`,
|
|
91
|
+
boxShadow: `0 4px 24px rgba(0,0,0,0.3)`,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{node.label}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
|
|
99
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
100
|
+
</AbsoluteFill>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { FeatureListScene as FeatureListBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Label } from "../primitives/Label";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
import { Icon } from "../primitives/Icon";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
scene: FeatureListBrief;
|
|
11
|
+
theme: Theme;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const HEADLINE_FRAMES = 20;
|
|
15
|
+
const FRAMES_PER_ITEM = 25;
|
|
16
|
+
|
|
17
|
+
export const FeatureList: React.FC<Props> = ({ scene, theme }) => {
|
|
18
|
+
const frame = useCurrentFrame();
|
|
19
|
+
const { fps } = useVideoConfig();
|
|
20
|
+
|
|
21
|
+
const captionStart = HEADLINE_FRAMES + scene.items.length * FRAMES_PER_ITEM + 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 200px",
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{scene.headline && (
|
|
35
|
+
<div style={{ marginBottom: 48 }}>
|
|
36
|
+
<Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 28, width: "100%" }}>
|
|
41
|
+
{scene.items.map((item, i) => {
|
|
42
|
+
const text = typeof item === "string" ? item : item.text;
|
|
43
|
+
const icon = typeof item === "string" ? undefined : item.icon;
|
|
44
|
+
|
|
45
|
+
const itemStart = HEADLINE_FRAMES + i * FRAMES_PER_ITEM;
|
|
46
|
+
const progress = spring({ frame: frame - itemStart, fps, config: theme.motion });
|
|
47
|
+
const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
|
|
48
|
+
const translateX = interpolate(Math.max(0, progress), [0, 1], [-24, 0]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
key={i}
|
|
53
|
+
style={{
|
|
54
|
+
display: "flex",
|
|
55
|
+
alignItems: "center",
|
|
56
|
+
gap: 24,
|
|
57
|
+
opacity,
|
|
58
|
+
transform: `translateX(${translateX}px)`,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
width: 36,
|
|
64
|
+
height: 36,
|
|
65
|
+
borderRadius: "50%",
|
|
66
|
+
background: theme.accentMuted,
|
|
67
|
+
border: `1.5px solid ${theme.accent}`,
|
|
68
|
+
display: "flex",
|
|
69
|
+
alignItems: "center",
|
|
70
|
+
justifyContent: "center",
|
|
71
|
+
flexShrink: 0,
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{icon ? (
|
|
75
|
+
<Icon name={icon} size={18} color={theme.accent} />
|
|
76
|
+
) : (
|
|
77
|
+
<span
|
|
78
|
+
style={{
|
|
79
|
+
fontFamily: theme.fontMono,
|
|
80
|
+
fontSize: 14,
|
|
81
|
+
fontWeight: 700,
|
|
82
|
+
color: theme.accent,
|
|
83
|
+
lineHeight: 1,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{i + 1}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
<span
|
|
91
|
+
style={{
|
|
92
|
+
fontFamily: theme.fontSans,
|
|
93
|
+
fontSize: 28,
|
|
94
|
+
fontWeight: 500,
|
|
95
|
+
color: theme.text,
|
|
96
|
+
lineHeight: 1.4,
|
|
97
|
+
letterSpacing: "-0.01em",
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{text}
|
|
101
|
+
</span>
|
|
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,125 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { FileTreeScene as FileTreeBrief } 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: FileTreeBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HEADLINE_FRAMES = 20;
|
|
14
|
+
const FRAMES_PER_ENTRY = 20;
|
|
15
|
+
const INDENT = 28;
|
|
16
|
+
|
|
17
|
+
function getDepth(path: string): number {
|
|
18
|
+
const clean = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
19
|
+
const segments = clean.split("/").filter(Boolean);
|
|
20
|
+
return Math.max(0, segments.length - 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getName(path: string): string {
|
|
24
|
+
const clean = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
25
|
+
const parts = clean.split("/").filter(Boolean);
|
|
26
|
+
return parts[parts.length - 1] ?? path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FolderIcon: React.FC<{ color: string }> = ({ color }) => (
|
|
30
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
31
|
+
<path
|
|
32
|
+
d="M1 4.5C1 3.67 1.67 3 2.5 3H7L9 5h6.5c.83 0 1.5.67 1.5 1.5v7c0 .83-.67 1.5-1.5 1.5h-13C1.67 15 1 14.33 1 13.5v-9z"
|
|
33
|
+
fill={color}
|
|
34
|
+
opacity={0.9}
|
|
35
|
+
/>
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const FileIcon: React.FC<{ color: string }> = ({ color }) => (
|
|
40
|
+
<svg width="15" height="18" viewBox="0 0 15 18" fill="none">
|
|
41
|
+
<path d="M2 1h8l4 4v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1z" stroke={color} strokeWidth="1.5" fill="none" />
|
|
42
|
+
<path d="M9 1v5h5" stroke={color} strokeWidth="1.5" />
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export const FileTree: React.FC<Props> = ({ scene, theme }) => {
|
|
47
|
+
const frame = useCurrentFrame();
|
|
48
|
+
const { fps } = useVideoConfig();
|
|
49
|
+
|
|
50
|
+
const captionStart = HEADLINE_FRAMES + scene.entries.length * FRAMES_PER_ENTRY + 20;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<AbsoluteFill
|
|
54
|
+
style={{
|
|
55
|
+
background: theme.bg,
|
|
56
|
+
display: "flex",
|
|
57
|
+
flexDirection: "column",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
justifyContent: "center",
|
|
60
|
+
padding: "60px 240px",
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{scene.headline && (
|
|
64
|
+
<div style={{ marginBottom: 48, alignSelf: "flex-start" }}>
|
|
65
|
+
<Label text={scene.headline} theme={theme} size="lg" startFrame={0} align="left" />
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 2, width: "100%" }}>
|
|
70
|
+
{scene.entries.map((entry, i) => {
|
|
71
|
+
const entryStart = HEADLINE_FRAMES + i * FRAMES_PER_ENTRY;
|
|
72
|
+
const progress = spring({
|
|
73
|
+
frame: frame - entryStart,
|
|
74
|
+
fps,
|
|
75
|
+
config: theme.motion,
|
|
76
|
+
});
|
|
77
|
+
const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
|
|
78
|
+
const translateX = interpolate(Math.max(0, progress), [0, 1], [-20, 0]);
|
|
79
|
+
|
|
80
|
+
const depth = getDepth(entry.path);
|
|
81
|
+
const name = getName(entry.path);
|
|
82
|
+
const isDir = entry.type === "dir" || entry.path.endsWith("/");
|
|
83
|
+
const isHighlighted = !!entry.highlight;
|
|
84
|
+
const textColor = isHighlighted ? theme.accent : isDir ? theme.text : theme.textMuted;
|
|
85
|
+
const iconColor = isHighlighted ? theme.accent : isDir ? theme.accent : theme.textMuted;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
key={i}
|
|
90
|
+
style={{
|
|
91
|
+
display: "flex",
|
|
92
|
+
alignItems: "center",
|
|
93
|
+
gap: 10,
|
|
94
|
+
opacity,
|
|
95
|
+
transform: `translateX(${translateX}px)`,
|
|
96
|
+
paddingTop: isHighlighted ? 6 : 4,
|
|
97
|
+
paddingBottom: isHighlighted ? 6 : 4,
|
|
98
|
+
paddingLeft: depth * INDENT + 8,
|
|
99
|
+
paddingRight: 12,
|
|
100
|
+
background: isHighlighted ? theme.accentMuted : "transparent",
|
|
101
|
+
borderRadius: isHighlighted ? 6 : 0,
|
|
102
|
+
border: isHighlighted ? `1px solid ${theme.accent}` : "1px solid transparent",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{isDir ? <FolderIcon color={iconColor} /> : <FileIcon color={iconColor} />}
|
|
106
|
+
<span
|
|
107
|
+
style={{
|
|
108
|
+
fontFamily: theme.fontMono,
|
|
109
|
+
fontSize: 22,
|
|
110
|
+
fontWeight: isDir ? 600 : 400,
|
|
111
|
+
color: textColor,
|
|
112
|
+
letterSpacing: "-0.01em",
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
{name}{isDir ? "/" : ""}
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
123
|
+
</AbsoluteFill>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { HotkeyScene as HotkeyBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { KeyPill } from "../primitives/KeyPill";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: HotkeyBrief;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FRAMES_PER_KEY = 20;
|
|
14
|
+
const ACTION_DELAY = 15;
|
|
15
|
+
|
|
16
|
+
export const Hotkey: React.FC<Props> = ({ scene, theme }) => {
|
|
17
|
+
const frame = useCurrentFrame();
|
|
18
|
+
const { fps } = useVideoConfig();
|
|
19
|
+
|
|
20
|
+
const actionStart = scene.keys.length * FRAMES_PER_KEY + ACTION_DELAY;
|
|
21
|
+
const captionStart = actionStart + 50;
|
|
22
|
+
|
|
23
|
+
const actionProgress = spring({ frame: frame - actionStart, fps, config: theme.motion });
|
|
24
|
+
const actionOpacity = interpolate(Math.max(0, actionProgress), [0, 1], [0, 1]);
|
|
25
|
+
const actionY = interpolate(Math.max(0, actionProgress), [0, 1], [16, 0]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<AbsoluteFill
|
|
29
|
+
style={{
|
|
30
|
+
background: theme.bg,
|
|
31
|
+
display: "flex",
|
|
32
|
+
flexDirection: "column",
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
justifyContent: "center",
|
|
35
|
+
gap: 40,
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
39
|
+
{scene.keys.map((key, i) => (
|
|
40
|
+
<React.Fragment key={i}>
|
|
41
|
+
{i > 0 && (
|
|
42
|
+
<span
|
|
43
|
+
style={{
|
|
44
|
+
fontFamily: theme.fontSans,
|
|
45
|
+
fontSize: 28,
|
|
46
|
+
color: theme.textMuted,
|
|
47
|
+
opacity: frame >= i * FRAMES_PER_KEY ? 1 : 0,
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
+
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
<KeyPill label={key} theme={theme} startFrame={i * FRAMES_PER_KEY} />
|
|
54
|
+
</React.Fragment>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{scene.action && (
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
opacity: actionOpacity,
|
|
62
|
+
transform: `translateY(${actionY}px)`,
|
|
63
|
+
fontFamily: theme.fontSans,
|
|
64
|
+
fontSize: 28,
|
|
65
|
+
fontWeight: 400,
|
|
66
|
+
color: theme.textMuted,
|
|
67
|
+
letterSpacing: "-0.01em",
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{scene.action}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
75
|
+
</AbsoluteFill>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
|
|
3
|
+
import { MobileScene as MobileBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { Caption } from "../primitives/Caption";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
scene: MobileBrief;
|
|
9
|
+
theme: Theme;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PHONE_W = 340;
|
|
13
|
+
const PHONE_H = 720;
|
|
14
|
+
const NOTCH_H = 30;
|
|
15
|
+
const RADIUS = 40;
|
|
16
|
+
|
|
17
|
+
export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
|
|
18
|
+
const frame = useCurrentFrame();
|
|
19
|
+
const { fps } = useVideoConfig();
|
|
20
|
+
|
|
21
|
+
const progress = spring({ frame, fps, config: theme.motion });
|
|
22
|
+
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
|
23
|
+
const translateY = interpolate(progress, [0, 1], [60, 0]);
|
|
24
|
+
const scale = interpolate(progress, [0, 1], [0.88, 1]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<AbsoluteFill
|
|
28
|
+
style={{
|
|
29
|
+
background: theme.bg,
|
|
30
|
+
display: "flex",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
justifyContent: "center",
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
<div
|
|
36
|
+
style={{
|
|
37
|
+
width: PHONE_W,
|
|
38
|
+
height: PHONE_H,
|
|
39
|
+
borderRadius: RADIUS,
|
|
40
|
+
border: `3px solid ${theme.border}`,
|
|
41
|
+
background: theme.surface,
|
|
42
|
+
boxShadow: `0 40px 100px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(255,255,255,0.05)`,
|
|
43
|
+
position: "relative",
|
|
44
|
+
overflow: "hidden",
|
|
45
|
+
opacity,
|
|
46
|
+
transform: `translateY(${translateY}px) scale(${scale})`,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{/* Notch */}
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
position: "absolute",
|
|
53
|
+
top: 0,
|
|
54
|
+
left: "50%",
|
|
55
|
+
transform: "translateX(-50%)",
|
|
56
|
+
width: 110,
|
|
57
|
+
height: NOTCH_H,
|
|
58
|
+
background: theme.surface,
|
|
59
|
+
borderBottomLeftRadius: 16,
|
|
60
|
+
borderBottomRightRadius: 16,
|
|
61
|
+
zIndex: 10,
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
{/* Screen */}
|
|
66
|
+
<div
|
|
67
|
+
style={{
|
|
68
|
+
position: "absolute",
|
|
69
|
+
inset: 0,
|
|
70
|
+
background: theme.bg,
|
|
71
|
+
display: "flex",
|
|
72
|
+
flexDirection: "column",
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{/* Status bar */}
|
|
76
|
+
<div
|
|
77
|
+
style={{
|
|
78
|
+
height: NOTCH_H + 2,
|
|
79
|
+
display: "flex",
|
|
80
|
+
alignItems: "flex-end",
|
|
81
|
+
justifyContent: "space-between",
|
|
82
|
+
paddingLeft: 20,
|
|
83
|
+
paddingRight: 20,
|
|
84
|
+
paddingBottom: 4,
|
|
85
|
+
fontFamily: theme.fontSans,
|
|
86
|
+
fontSize: 11,
|
|
87
|
+
fontWeight: 600,
|
|
88
|
+
color: theme.textMuted,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<span>9:41</span>
|
|
92
|
+
<span>●●●</span>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* App header */}
|
|
96
|
+
{scene.title && (
|
|
97
|
+
<div
|
|
98
|
+
style={{
|
|
99
|
+
height: 48,
|
|
100
|
+
display: "flex",
|
|
101
|
+
alignItems: "center",
|
|
102
|
+
justifyContent: "center",
|
|
103
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
104
|
+
fontFamily: theme.fontSans,
|
|
105
|
+
fontSize: 16,
|
|
106
|
+
fontWeight: 600,
|
|
107
|
+
color: theme.text,
|
|
108
|
+
flexShrink: 0,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{scene.title}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Content */}
|
|
116
|
+
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
|
117
|
+
{scene.image ? (
|
|
118
|
+
<Img
|
|
119
|
+
src={staticFile(scene.image)}
|
|
120
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
121
|
+
/>
|
|
122
|
+
) : (
|
|
123
|
+
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
|
|
124
|
+
{/* Search bar */}
|
|
125
|
+
<div
|
|
126
|
+
style={{
|
|
127
|
+
height: 34,
|
|
128
|
+
background: theme.surface,
|
|
129
|
+
borderRadius: 8,
|
|
130
|
+
border: `1px solid ${theme.border}`,
|
|
131
|
+
display: "flex",
|
|
132
|
+
alignItems: "center",
|
|
133
|
+
paddingLeft: 12,
|
|
134
|
+
paddingRight: 12,
|
|
135
|
+
fontFamily: theme.fontSans,
|
|
136
|
+
fontSize: 12,
|
|
137
|
+
color: theme.textMuted,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
Search...
|
|
141
|
+
</div>
|
|
142
|
+
{/* List items */}
|
|
143
|
+
{[72, 56, 60, 54, 58].map((_, i) => (
|
|
144
|
+
<div
|
|
145
|
+
key={i}
|
|
146
|
+
style={{
|
|
147
|
+
height: 58,
|
|
148
|
+
background: theme.surface,
|
|
149
|
+
borderRadius: 10,
|
|
150
|
+
border: `1px solid ${theme.border}`,
|
|
151
|
+
display: "flex",
|
|
152
|
+
alignItems: "center",
|
|
153
|
+
gap: 10,
|
|
154
|
+
paddingLeft: 10,
|
|
155
|
+
paddingRight: 10,
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<div
|
|
159
|
+
style={{
|
|
160
|
+
width: 32,
|
|
161
|
+
height: 32,
|
|
162
|
+
borderRadius: "50%",
|
|
163
|
+
background: theme.accentMuted,
|
|
164
|
+
border: `1.5px solid ${theme.accent}`,
|
|
165
|
+
flexShrink: 0,
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
169
|
+
<div style={{ height: 11, background: theme.border, borderRadius: 4, width: `${58 + i * 8}%` }} />
|
|
170
|
+
<div style={{ height: 9, background: theme.border, borderRadius: 4, width: `${38 + i * 6}%`, opacity: 0.6 }} />
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Bottom nav */}
|
|
179
|
+
<div
|
|
180
|
+
style={{
|
|
181
|
+
height: 52,
|
|
182
|
+
borderTop: `1px solid ${theme.border}`,
|
|
183
|
+
display: "flex",
|
|
184
|
+
alignItems: "center",
|
|
185
|
+
justifyContent: "space-around",
|
|
186
|
+
flexShrink: 0,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{["⌂", "⊞", "♡", "◉"].map((icon, i) => (
|
|
190
|
+
<div
|
|
191
|
+
key={i}
|
|
192
|
+
style={{
|
|
193
|
+
width: 36,
|
|
194
|
+
height: 36,
|
|
195
|
+
display: "flex",
|
|
196
|
+
alignItems: "center",
|
|
197
|
+
justifyContent: "center",
|
|
198
|
+
fontSize: 18,
|
|
199
|
+
color: i === 0 ? theme.accent : theme.textMuted,
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{icon}
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} />}
|
|
210
|
+
</AbsoluteFill>
|
|
211
|
+
);
|
|
212
|
+
};
|