reelme 0.2.2 → 0.3.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/assets/audio/PROVENANCE.md +16 -0
- package/assets/audio/bright-sparks.mp3 +0 -0
- package/assets/audio/calm-keys.mp3 +0 -0
- package/assets/audio/circuit-pulse.mp3 +0 -0
- package/assets/audio/clean-horizon.mp3 +0 -0
- package/assets/audio/generate-tracks.mjs +213 -0
- package/assets/audio/manifest.json +83 -0
- package/assets/audio/midnight-protocol.mp3 +0 -0
- package/assets/audio/pixel-bounce.mp3 +0 -0
- package/assets/audio/steady-launch.mp3 +0 -0
- package/assets/audio/sunny-loop.mp3 +0 -0
- package/assets/audio/vector-grid.mp3 +0 -0
- package/package.json +2 -1
- package/src/cache.mjs +1 -0
- package/src/render.mjs +95 -4
- package/template/package.json +1 -0
- package/template/pnpm-lock.yaml +15 -0
- package/template/src/Root.tsx +55 -45
- package/template/src/audio.ts +24 -0
- package/template/src/benchmark.ts +18 -0
- package/template/src/brief.json +1 -0
- package/template/src/brief.ts +61 -3
- package/template/src/cinematic/Atmosphere.tsx +139 -0
- package/template/src/cinematic/Camera.tsx +54 -0
- package/template/src/cinematic/look.ts +89 -0
- package/template/src/cinematic/transitions.tsx +92 -0
- package/template/src/components/primitives/Bar.tsx +82 -0
- package/template/src/components/primitives/Caption.tsx +3 -2
- package/template/src/components/primitives/Kicker.tsx +46 -0
- package/template/src/components/primitives/Label.tsx +18 -24
- package/template/src/components/primitives/RevealText.tsx +101 -0
- package/template/src/components/primitives/Terminal.tsx +11 -0
- package/template/src/components/scenes/Benchmark.tsx +83 -0
- package/template/src/components/scenes/BrowserFrame.tsx +4 -3
- package/template/src/components/scenes/CTA.tsx +56 -10
- package/template/src/components/scenes/Clip.tsx +125 -0
- package/template/src/components/scenes/CodeReveal.tsx +4 -3
- package/template/src/components/scenes/DataFlow.tsx +4 -3
- package/template/src/components/scenes/FeatureList.tsx +13 -8
- package/template/src/components/scenes/FileTree.tsx +4 -3
- package/template/src/components/scenes/Hook.tsx +55 -0
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +120 -82
- package/template/src/components/scenes/OSWindowScene.tsx +4 -3
- package/template/src/components/scenes/Problem.tsx +28 -29
- package/template/src/components/scenes/SplitComparison.tsx +65 -92
- package/template/src/components/scenes/StatCallout.tsx +93 -4
- package/template/src/components/scenes/TerminalScene.tsx +5 -6
- package/template/src/duration.ts +12 -1
- package/template/src/platforms.ts +4 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, OffthreadVideo, staticFile } from "remotion";
|
|
3
|
+
import { Gif } from "@remotion/gif";
|
|
4
|
+
import { ClipScene } from "../../brief";
|
|
5
|
+
import { Theme } from "../../theme";
|
|
6
|
+
import { Caption } from "../primitives/Caption";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
scene: ClipScene;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isGif = (src: string) => src.endsWith(".gif");
|
|
14
|
+
|
|
15
|
+
const MediaElement: React.FC<{ scene: ClipScene }> = ({ scene }) => {
|
|
16
|
+
if (isGif(scene.src)) {
|
|
17
|
+
return (
|
|
18
|
+
<Gif
|
|
19
|
+
src={staticFile(scene.src)}
|
|
20
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
21
|
+
fit="cover"
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return (
|
|
26
|
+
<OffthreadVideo
|
|
27
|
+
src={staticFile(scene.src)}
|
|
28
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
29
|
+
muted
|
|
30
|
+
startFrom={scene.startFrom}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const BrowserChrome: React.FC<{ children: React.ReactNode; theme: Theme }> = ({ children, theme }) => (
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
width: "100%",
|
|
39
|
+
height: "100%",
|
|
40
|
+
display: "flex",
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
|
|
43
|
+
borderRadius: 12,
|
|
44
|
+
overflow: "hidden",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
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
|
+
flexShrink: 0,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{(["#ff5f56", "#ffbd2e", "#27c93f"] as const).map((c) => (
|
|
59
|
+
<div key={c} style={{ width: 12, height: 12, borderRadius: "50%", background: c }} />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>{children}</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const MobileChrome: React.FC<{ children: React.ReactNode; theme: Theme }> = ({ children, theme }) => (
|
|
67
|
+
<div
|
|
68
|
+
style={{
|
|
69
|
+
width: "100%",
|
|
70
|
+
height: "100%",
|
|
71
|
+
borderRadius: 40,
|
|
72
|
+
border: `3px solid ${theme.border}`,
|
|
73
|
+
overflow: "hidden",
|
|
74
|
+
boxShadow: "0 40px 100px rgba(0,0,0,0.55)",
|
|
75
|
+
position: "relative",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const Clip: React.FC<Props> = ({ scene, theme }) => {
|
|
83
|
+
const renderMedia = () => <MediaElement scene={scene} />;
|
|
84
|
+
|
|
85
|
+
if (scene.frame === "browser") {
|
|
86
|
+
return (
|
|
87
|
+
<AbsoluteFill
|
|
88
|
+
style={{
|
|
89
|
+
background: "transparent",
|
|
90
|
+
display: "flex",
|
|
91
|
+
alignItems: "center",
|
|
92
|
+
justifyContent: "center",
|
|
93
|
+
padding: "60px 120px",
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<BrowserChrome theme={theme}>{renderMedia()}</BrowserChrome>
|
|
97
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} />}
|
|
98
|
+
</AbsoluteFill>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (scene.frame === "mobile") {
|
|
103
|
+
return (
|
|
104
|
+
<AbsoluteFill
|
|
105
|
+
style={{
|
|
106
|
+
background: "transparent",
|
|
107
|
+
display: "flex",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
justifyContent: "center",
|
|
110
|
+
padding: "40px 200px",
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<MobileChrome theme={theme}>{renderMedia()}</MobileChrome>
|
|
114
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} />}
|
|
115
|
+
</AbsoluteFill>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<AbsoluteFill style={{ background: "transparent", borderRadius: 12, overflow: "hidden" }}>
|
|
121
|
+
{renderMedia()}
|
|
122
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} />}
|
|
123
|
+
</AbsoluteFill>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
@@ -8,9 +8,10 @@ import { Caption } from "../primitives/Caption";
|
|
|
8
8
|
interface Props {
|
|
9
9
|
scene: CodeRevealBrief;
|
|
10
10
|
theme: Theme;
|
|
11
|
+
bottomInset?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
|
|
14
|
+
export const CodeReveal: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
14
15
|
const lines = scene.code.split("\n");
|
|
15
16
|
// Caption appears after all lines are revealed: startFrame=14, framesPerLine=9, plus 20-frame buffer
|
|
16
17
|
const captionStart = 14 + lines.length * 9 + 20;
|
|
@@ -18,7 +19,7 @@ export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
|
|
|
18
19
|
return (
|
|
19
20
|
<AbsoluteFill
|
|
20
21
|
style={{
|
|
21
|
-
background:
|
|
22
|
+
background: "transparent",
|
|
22
23
|
display: "flex",
|
|
23
24
|
alignItems: "center",
|
|
24
25
|
justifyContent: "center",
|
|
@@ -35,7 +36,7 @@ export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
|
|
|
35
36
|
framesPerLine={9}
|
|
36
37
|
/>
|
|
37
38
|
</div>
|
|
38
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
39
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
|
|
39
40
|
</AbsoluteFill>
|
|
40
41
|
);
|
|
41
42
|
};
|
|
@@ -8,12 +8,13 @@ import { Caption } from "../primitives/Caption";
|
|
|
8
8
|
interface Props {
|
|
9
9
|
scene: DataFlowBrief;
|
|
10
10
|
theme: Theme;
|
|
11
|
+
bottomInset?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const NODE_W = 200;
|
|
14
15
|
const NODE_H = 64;
|
|
15
16
|
|
|
16
|
-
export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
|
|
17
|
+
export const DataFlow: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
17
18
|
const frame = useCurrentFrame();
|
|
18
19
|
const { fps, width, height } = useVideoConfig();
|
|
19
20
|
|
|
@@ -38,7 +39,7 @@ export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
|
|
|
38
39
|
const captionStart = (nodeCount - 1) * STEP + NODE_SETTLE + 20;
|
|
39
40
|
|
|
40
41
|
return (
|
|
41
|
-
<AbsoluteFill style={{ background:
|
|
42
|
+
<AbsoluteFill style={{ background: "transparent" }}>
|
|
42
43
|
{/* SVG layer for arrows */}
|
|
43
44
|
<svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
|
|
44
45
|
{scene.edges.map((edge, i) => {
|
|
@@ -96,7 +97,7 @@ export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
|
|
|
96
97
|
);
|
|
97
98
|
})}
|
|
98
99
|
|
|
99
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
100
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
|
|
100
101
|
</AbsoluteFill>
|
|
101
102
|
);
|
|
102
103
|
};
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
3
|
import { FeatureListScene as FeatureListBrief } from "../../brief";
|
|
4
4
|
import { Theme } from "../../theme";
|
|
5
|
+
import { PlatformPreset, typeScale } from "../../platforms";
|
|
5
6
|
import { Label } from "../primitives/Label";
|
|
6
7
|
import { Caption } from "../primitives/Caption";
|
|
7
8
|
import { Icon } from "../primitives/Icon";
|
|
@@ -9,35 +10,39 @@ import { Icon } from "../primitives/Icon";
|
|
|
9
10
|
interface Props {
|
|
10
11
|
scene: FeatureListBrief;
|
|
11
12
|
theme: Theme;
|
|
13
|
+
platform?: PlatformPreset;
|
|
14
|
+
bottomInset?: number;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
const HEADLINE_FRAMES = 20;
|
|
15
18
|
const FRAMES_PER_ITEM = 25;
|
|
16
19
|
|
|
17
|
-
export const FeatureList: React.FC<Props> = ({ scene, theme }) => {
|
|
20
|
+
export const FeatureList: React.FC<Props> = ({ scene, theme, platform, bottomInset = 0 }) => {
|
|
18
21
|
const frame = useCurrentFrame();
|
|
19
22
|
const { fps } = useVideoConfig();
|
|
23
|
+
const scale = platform ? typeScale(platform) : 1.0;
|
|
20
24
|
|
|
21
25
|
const captionStart = HEADLINE_FRAMES + scene.items.length * FRAMES_PER_ITEM + 20;
|
|
26
|
+
const left = scene.align === "left";
|
|
22
27
|
|
|
23
28
|
return (
|
|
24
29
|
<AbsoluteFill
|
|
25
30
|
style={{
|
|
26
|
-
background:
|
|
31
|
+
background: "transparent",
|
|
27
32
|
display: "flex",
|
|
28
33
|
flexDirection: "column",
|
|
29
|
-
alignItems: "center",
|
|
34
|
+
alignItems: left ? "flex-start" : "center",
|
|
30
35
|
justifyContent: "center",
|
|
31
|
-
padding: "0 200px",
|
|
36
|
+
padding: left ? "0 0 0 140px" : "0 200px",
|
|
32
37
|
}}
|
|
33
38
|
>
|
|
34
39
|
{scene.headline && (
|
|
35
40
|
<div style={{ marginBottom: 48 }}>
|
|
36
|
-
<Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
|
|
41
|
+
<Label text={scene.headline} theme={theme} size="lg" align={left ? "left" : "center"} startFrame={0} />
|
|
37
42
|
</div>
|
|
38
43
|
)}
|
|
39
44
|
|
|
40
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 28, width: "100%" }}>
|
|
45
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 28, width: left ? "auto" : "100%", maxWidth: left ? 1200 : undefined }}>
|
|
41
46
|
{scene.items.map((item, i) => {
|
|
42
47
|
const text = typeof item === "string" ? item : item.text;
|
|
43
48
|
const icon = typeof item === "string" ? undefined : item.icon;
|
|
@@ -90,7 +95,7 @@ export const FeatureList: React.FC<Props> = ({ scene, theme }) => {
|
|
|
90
95
|
<span
|
|
91
96
|
style={{
|
|
92
97
|
fontFamily: theme.fontSans,
|
|
93
|
-
fontSize: 28,
|
|
98
|
+
fontSize: 28 * scale,
|
|
94
99
|
fontWeight: 500,
|
|
95
100
|
color: theme.text,
|
|
96
101
|
lineHeight: 1.4,
|
|
@@ -104,7 +109,7 @@ export const FeatureList: React.FC<Props> = ({ scene, theme }) => {
|
|
|
104
109
|
})}
|
|
105
110
|
</div>
|
|
106
111
|
|
|
107
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
112
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
|
|
108
113
|
</AbsoluteFill>
|
|
109
114
|
);
|
|
110
115
|
};
|
|
@@ -8,6 +8,7 @@ import { Caption } from "../primitives/Caption";
|
|
|
8
8
|
interface Props {
|
|
9
9
|
scene: FileTreeBrief;
|
|
10
10
|
theme: Theme;
|
|
11
|
+
bottomInset?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const HEADLINE_FRAMES = 20;
|
|
@@ -43,7 +44,7 @@ const FileIcon: React.FC<{ color: string }> = ({ color }) => (
|
|
|
43
44
|
</svg>
|
|
44
45
|
);
|
|
45
46
|
|
|
46
|
-
export const FileTree: React.FC<Props> = ({ scene, theme }) => {
|
|
47
|
+
export const FileTree: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
47
48
|
const frame = useCurrentFrame();
|
|
48
49
|
const { fps } = useVideoConfig();
|
|
49
50
|
|
|
@@ -52,7 +53,7 @@ export const FileTree: React.FC<Props> = ({ scene, theme }) => {
|
|
|
52
53
|
return (
|
|
53
54
|
<AbsoluteFill
|
|
54
55
|
style={{
|
|
55
|
-
background:
|
|
56
|
+
background: "transparent",
|
|
56
57
|
display: "flex",
|
|
57
58
|
flexDirection: "column",
|
|
58
59
|
alignItems: "center",
|
|
@@ -119,7 +120,7 @@ export const FileTree: React.FC<Props> = ({ scene, theme }) => {
|
|
|
119
120
|
})}
|
|
120
121
|
</div>
|
|
121
122
|
|
|
122
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
123
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
|
|
123
124
|
</AbsoluteFill>
|
|
124
125
|
);
|
|
125
126
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { HookScene as HookBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { PlatformPreset, typeScale } from "../../platforms";
|
|
6
|
+
import { RevealText } from "../primitives/RevealText";
|
|
7
|
+
import { Kicker } from "../primitives/Kicker";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
scene: HookBrief;
|
|
11
|
+
theme: Theme;
|
|
12
|
+
platform: PlatformPreset;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Hook: React.FC<Props> = ({ scene, theme, platform }) => {
|
|
16
|
+
const frame = useCurrentFrame();
|
|
17
|
+
const { fps, width } = useVideoConfig();
|
|
18
|
+
const left = scene.align === "left";
|
|
19
|
+
|
|
20
|
+
const fontSize = width * 0.1 * typeScale(platform);
|
|
21
|
+
// The whole block eases out of a slight over-scale so the words don't just
|
|
22
|
+
// appear — they land. A touch of continuous drift keeps the hold alive.
|
|
23
|
+
const settle = spring({ frame, fps, config: { damping: 26, stiffness: 90, mass: 1 } });
|
|
24
|
+
const scale = interpolate(settle, [0, 1], [1.06, 1]);
|
|
25
|
+
const drift = Math.sin(frame / 40) * 0.4;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<AbsoluteFill
|
|
29
|
+
style={{
|
|
30
|
+
display: "flex",
|
|
31
|
+
flexDirection: "column",
|
|
32
|
+
alignItems: left ? "flex-start" : "center",
|
|
33
|
+
justifyContent: "center",
|
|
34
|
+
padding: left ? "0 0 0 140px" : "0 80px",
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<div style={{ transform: `scale(${scale}) translateY(${drift}px)`, transformOrigin: left ? "left center" : "center", willChange: "transform" }}>
|
|
38
|
+
{scene.kicker && <Kicker text={scene.kicker} theme={theme} startFrame={0} align={left ? "left" : "center"} />}
|
|
39
|
+
<RevealText
|
|
40
|
+
text={scene.text}
|
|
41
|
+
theme={theme}
|
|
42
|
+
fontSize={fontSize}
|
|
43
|
+
fontWeight={800}
|
|
44
|
+
emphasis={scene.accent}
|
|
45
|
+
align={left ? "left" : "center"}
|
|
46
|
+
startFrame={scene.kicker ? 6 : 0}
|
|
47
|
+
stagger={3.5}
|
|
48
|
+
letterSpacing="-0.04em"
|
|
49
|
+
lineHeight={1.04}
|
|
50
|
+
maxWidth={width * (left ? 0.72 : 0.84)}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
</AbsoluteFill>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
@@ -8,12 +8,13 @@ import { Caption } from "../primitives/Caption";
|
|
|
8
8
|
interface Props {
|
|
9
9
|
scene: HotkeyBrief;
|
|
10
10
|
theme: Theme;
|
|
11
|
+
bottomInset?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const FRAMES_PER_KEY = 20;
|
|
14
15
|
const ACTION_DELAY = 15;
|
|
15
16
|
|
|
16
|
-
export const Hotkey: React.FC<Props> = ({ scene, theme }) => {
|
|
17
|
+
export const Hotkey: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
17
18
|
const frame = useCurrentFrame();
|
|
18
19
|
const { fps } = useVideoConfig();
|
|
19
20
|
|
|
@@ -27,7 +28,7 @@ export const Hotkey: React.FC<Props> = ({ scene, theme }) => {
|
|
|
27
28
|
return (
|
|
28
29
|
<AbsoluteFill
|
|
29
30
|
style={{
|
|
30
|
-
background:
|
|
31
|
+
background: "transparent",
|
|
31
32
|
display: "flex",
|
|
32
33
|
flexDirection: "column",
|
|
33
34
|
alignItems: "center",
|
|
@@ -71,7 +72,7 @@ export const Hotkey: React.FC<Props> = ({ scene, theme }) => {
|
|
|
71
72
|
</div>
|
|
72
73
|
)}
|
|
73
74
|
|
|
74
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
|
|
75
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
|
|
75
76
|
</AbsoluteFill>
|
|
76
77
|
);
|
|
77
78
|
};
|
|
@@ -7,26 +7,27 @@ import { Caption } from "../primitives/Caption";
|
|
|
7
7
|
interface Props {
|
|
8
8
|
scene: MobileBrief;
|
|
9
9
|
theme: Theme;
|
|
10
|
+
bottomInset?: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
const PHONE_W =
|
|
13
|
-
const PHONE_H =
|
|
14
|
-
const NOTCH_H =
|
|
15
|
-
const RADIUS =
|
|
13
|
+
const PHONE_W = 384;
|
|
14
|
+
const PHONE_H = 808;
|
|
15
|
+
const NOTCH_H = 32;
|
|
16
|
+
const RADIUS = 46;
|
|
16
17
|
|
|
17
|
-
export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
|
|
18
|
+
export const MobileScreen: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
18
19
|
const frame = useCurrentFrame();
|
|
19
20
|
const { fps } = useVideoConfig();
|
|
20
21
|
|
|
21
22
|
const progress = spring({ frame, fps, config: theme.motion });
|
|
22
23
|
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
|
23
24
|
const translateY = interpolate(progress, [0, 1], [60, 0]);
|
|
24
|
-
const scale = interpolate(progress, [0, 1], [0.
|
|
25
|
+
const scale = interpolate(progress, [0, 1], [0.9, 1]);
|
|
25
26
|
|
|
26
27
|
return (
|
|
27
28
|
<AbsoluteFill
|
|
28
29
|
style={{
|
|
29
|
-
background:
|
|
30
|
+
background: "transparent",
|
|
30
31
|
display: "flex",
|
|
31
32
|
alignItems: "center",
|
|
32
33
|
justifyContent: "center",
|
|
@@ -39,7 +40,7 @@ export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
|
|
|
39
40
|
borderRadius: RADIUS,
|
|
40
41
|
border: `3px solid ${theme.border}`,
|
|
41
42
|
background: theme.surface,
|
|
42
|
-
boxShadow: `0
|
|
43
|
+
boxShadow: `0 50px 120px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(255,255,255,0.05)`,
|
|
43
44
|
position: "relative",
|
|
44
45
|
overflow: "hidden",
|
|
45
46
|
opacity,
|
|
@@ -53,11 +54,11 @@ export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
|
|
|
53
54
|
top: 0,
|
|
54
55
|
left: "50%",
|
|
55
56
|
transform: "translateX(-50%)",
|
|
56
|
-
width:
|
|
57
|
+
width: 120,
|
|
57
58
|
height: NOTCH_H,
|
|
58
59
|
background: theme.surface,
|
|
59
|
-
borderBottomLeftRadius:
|
|
60
|
-
borderBottomRightRadius:
|
|
60
|
+
borderBottomLeftRadius: 18,
|
|
61
|
+
borderBottomRightRadius: 18,
|
|
61
62
|
zIndex: 10,
|
|
62
63
|
}}
|
|
63
64
|
/>
|
|
@@ -79,123 +80,78 @@ export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
|
|
|
79
80
|
display: "flex",
|
|
80
81
|
alignItems: "flex-end",
|
|
81
82
|
justifyContent: "space-between",
|
|
82
|
-
paddingLeft:
|
|
83
|
-
paddingRight:
|
|
83
|
+
paddingLeft: 22,
|
|
84
|
+
paddingRight: 22,
|
|
84
85
|
paddingBottom: 4,
|
|
85
86
|
fontFamily: theme.fontSans,
|
|
86
|
-
fontSize:
|
|
87
|
+
fontSize: 12,
|
|
87
88
|
fontWeight: 600,
|
|
88
|
-
color: theme.
|
|
89
|
+
color: theme.text,
|
|
90
|
+
flexShrink: 0,
|
|
89
91
|
}}
|
|
90
92
|
>
|
|
91
93
|
<span>9:41</span>
|
|
92
|
-
<span>●●●</span>
|
|
94
|
+
<span style={{ letterSpacing: 1 }}>●●●</span>
|
|
93
95
|
</div>
|
|
94
96
|
|
|
95
97
|
{/* App header */}
|
|
96
98
|
{scene.title && (
|
|
97
99
|
<div
|
|
98
100
|
style={{
|
|
99
|
-
height:
|
|
101
|
+
height: 52,
|
|
100
102
|
display: "flex",
|
|
101
103
|
alignItems: "center",
|
|
102
|
-
|
|
104
|
+
gap: 10,
|
|
105
|
+
padding: "0 18px",
|
|
103
106
|
borderBottom: `1px solid ${theme.border}`,
|
|
104
107
|
fontFamily: theme.fontSans,
|
|
105
|
-
fontSize:
|
|
106
|
-
fontWeight:
|
|
108
|
+
fontSize: 17,
|
|
109
|
+
fontWeight: 700,
|
|
107
110
|
color: theme.text,
|
|
108
111
|
flexShrink: 0,
|
|
109
112
|
}}
|
|
110
113
|
>
|
|
114
|
+
<div style={{ width: 12, height: 12, borderRadius: "50%", background: theme.accent }} />
|
|
111
115
|
{scene.title}
|
|
112
116
|
</div>
|
|
113
117
|
)}
|
|
114
118
|
|
|
115
119
|
{/* Content */}
|
|
116
120
|
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
|
117
|
-
{scene.
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
{scene.screenshot ? (
|
|
122
|
+
<KenBurns frame={frame}>
|
|
123
|
+
<Img
|
|
124
|
+
src={staticFile(scene.screenshot)}
|
|
125
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
126
|
+
/>
|
|
127
|
+
</KenBurns>
|
|
122
128
|
) : (
|
|
123
|
-
<
|
|
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>
|
|
129
|
+
<MockFeed theme={theme} frame={frame} fps={fps} />
|
|
175
130
|
)}
|
|
176
131
|
</div>
|
|
177
132
|
|
|
178
133
|
{/* Bottom nav */}
|
|
179
134
|
<div
|
|
180
135
|
style={{
|
|
181
|
-
height:
|
|
136
|
+
height: 56,
|
|
182
137
|
borderTop: `1px solid ${theme.border}`,
|
|
183
138
|
display: "flex",
|
|
184
139
|
alignItems: "center",
|
|
185
140
|
justifyContent: "space-around",
|
|
186
141
|
flexShrink: 0,
|
|
142
|
+
background: theme.surface,
|
|
187
143
|
}}
|
|
188
144
|
>
|
|
189
145
|
{["⌂", "⊞", "♡", "◉"].map((icon, i) => (
|
|
190
146
|
<div
|
|
191
147
|
key={i}
|
|
192
148
|
style={{
|
|
193
|
-
width:
|
|
194
|
-
height:
|
|
149
|
+
width: 38,
|
|
150
|
+
height: 38,
|
|
195
151
|
display: "flex",
|
|
196
152
|
alignItems: "center",
|
|
197
153
|
justifyContent: "center",
|
|
198
|
-
fontSize:
|
|
154
|
+
fontSize: 19,
|
|
199
155
|
color: i === 0 ? theme.accent : theme.textMuted,
|
|
200
156
|
}}
|
|
201
157
|
>
|
|
@@ -206,7 +162,89 @@ export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
|
|
|
206
162
|
</div>
|
|
207
163
|
</div>
|
|
208
164
|
|
|
209
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} />}
|
|
165
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} bottomInset={bottomInset} />}
|
|
166
|
+
</AbsoluteFill>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** Slow ken-burns push so a real screenshot reads as alive footage, not a still. */
|
|
171
|
+
const KenBurns: React.FC<{ frame: number; children: React.ReactNode }> = ({ frame, children }) => {
|
|
172
|
+
const s = interpolate(frame, [0, 240], [1.0, 1.1], { extrapolateRight: "clamp" });
|
|
173
|
+
const y = interpolate(frame, [0, 240], [0, -3], { extrapolateRight: "clamp" });
|
|
174
|
+
return (
|
|
175
|
+
<AbsoluteFill style={{ transform: `scale(${s}) translateY(${y}%)`, transformOrigin: "center top" }}>
|
|
176
|
+
{children}
|
|
210
177
|
</AbsoluteFill>
|
|
211
178
|
);
|
|
212
179
|
};
|
|
180
|
+
|
|
181
|
+
/** An app-like feed that builds in and gently scrolls, when no screenshot is given. */
|
|
182
|
+
const MockFeed: React.FC<{ theme: Theme; frame: number; fps: number }> = ({ theme, frame, fps }) => {
|
|
183
|
+
const scrollY = interpolate(frame, [24, 200], [0, -46], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
184
|
+
|
|
185
|
+
const Item: React.FC<{ index: number; children: React.ReactNode }> = ({ index, children }) => {
|
|
186
|
+
const p = spring({ frame: frame - 8 - index * 7, fps, config: theme.motion });
|
|
187
|
+
return (
|
|
188
|
+
<div style={{ opacity: interpolate(p, [0, 1], [0, 1]), transform: `translateY(${interpolate(p, [0, 1], [16, 0])}px)` }}>
|
|
189
|
+
{children}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const cardBase: React.CSSProperties = {
|
|
195
|
+
background: theme.surface,
|
|
196
|
+
borderRadius: 14,
|
|
197
|
+
border: `1px solid ${theme.border}`,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div style={{ padding: 16, display: "flex", flexDirection: "column", gap: 12, transform: `translateY(${scrollY}px)` }}>
|
|
202
|
+
<Item index={0}>
|
|
203
|
+
<div style={{ ...cardBase, height: 36, display: "flex", alignItems: "center", padding: "0 14px", fontFamily: theme.fontSans, fontSize: 13, color: theme.textMuted }}>
|
|
204
|
+
Search
|
|
205
|
+
</div>
|
|
206
|
+
</Item>
|
|
207
|
+
|
|
208
|
+
{/* Featured hero card */}
|
|
209
|
+
<Item index={1}>
|
|
210
|
+
<div
|
|
211
|
+
style={{
|
|
212
|
+
height: 132,
|
|
213
|
+
borderRadius: 16,
|
|
214
|
+
background: `linear-gradient(135deg, ${theme.accent} 0%, ${theme.accentMuted} 100%)`,
|
|
215
|
+
padding: 16,
|
|
216
|
+
display: "flex",
|
|
217
|
+
flexDirection: "column",
|
|
218
|
+
justifyContent: "flex-end",
|
|
219
|
+
gap: 8,
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
<div style={{ height: 13, width: "62%", background: "rgba(255,255,255,0.92)", borderRadius: 5 }} />
|
|
223
|
+
<div style={{ height: 10, width: "40%", background: "rgba(255,255,255,0.6)", borderRadius: 5 }} />
|
|
224
|
+
</div>
|
|
225
|
+
</Item>
|
|
226
|
+
|
|
227
|
+
{[0, 1, 2, 3].map((i) => (
|
|
228
|
+
<Item key={i} index={2 + i}>
|
|
229
|
+
<div style={{ ...cardBase, height: 70, display: "flex", alignItems: "center", gap: 12, padding: "0 12px" }}>
|
|
230
|
+
<div
|
|
231
|
+
style={{
|
|
232
|
+
width: 44,
|
|
233
|
+
height: 44,
|
|
234
|
+
borderRadius: 12,
|
|
235
|
+
background: theme.accentMuted,
|
|
236
|
+
border: `1.5px solid ${theme.accent}`,
|
|
237
|
+
flexShrink: 0,
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 7 }}>
|
|
241
|
+
<div style={{ height: 11, background: theme.text, opacity: 0.85, borderRadius: 4, width: `${70 - i * 8}%` }} />
|
|
242
|
+
<div style={{ height: 9, background: theme.textMuted, opacity: 0.5, borderRadius: 4, width: `${48 - i * 5}%` }} />
|
|
243
|
+
</div>
|
|
244
|
+
<div style={{ width: 30, height: 18, borderRadius: 6, background: theme.accentMuted, flexShrink: 0 }} />
|
|
245
|
+
</div>
|
|
246
|
+
</Item>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
};
|