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.
Files changed (50) hide show
  1. package/assets/audio/PROVENANCE.md +16 -0
  2. package/assets/audio/bright-sparks.mp3 +0 -0
  3. package/assets/audio/calm-keys.mp3 +0 -0
  4. package/assets/audio/circuit-pulse.mp3 +0 -0
  5. package/assets/audio/clean-horizon.mp3 +0 -0
  6. package/assets/audio/generate-tracks.mjs +213 -0
  7. package/assets/audio/manifest.json +83 -0
  8. package/assets/audio/midnight-protocol.mp3 +0 -0
  9. package/assets/audio/pixel-bounce.mp3 +0 -0
  10. package/assets/audio/steady-launch.mp3 +0 -0
  11. package/assets/audio/sunny-loop.mp3 +0 -0
  12. package/assets/audio/vector-grid.mp3 +0 -0
  13. package/package.json +2 -1
  14. package/src/cache.mjs +1 -0
  15. package/src/render.mjs +95 -4
  16. package/template/package.json +1 -0
  17. package/template/pnpm-lock.yaml +15 -0
  18. package/template/src/Root.tsx +55 -45
  19. package/template/src/audio.ts +24 -0
  20. package/template/src/benchmark.ts +18 -0
  21. package/template/src/brief.json +1 -0
  22. package/template/src/brief.ts +61 -3
  23. package/template/src/cinematic/Atmosphere.tsx +139 -0
  24. package/template/src/cinematic/Camera.tsx +54 -0
  25. package/template/src/cinematic/look.ts +89 -0
  26. package/template/src/cinematic/transitions.tsx +92 -0
  27. package/template/src/components/primitives/Bar.tsx +82 -0
  28. package/template/src/components/primitives/Caption.tsx +3 -2
  29. package/template/src/components/primitives/Kicker.tsx +46 -0
  30. package/template/src/components/primitives/Label.tsx +18 -24
  31. package/template/src/components/primitives/RevealText.tsx +101 -0
  32. package/template/src/components/primitives/Terminal.tsx +11 -0
  33. package/template/src/components/scenes/Benchmark.tsx +83 -0
  34. package/template/src/components/scenes/BrowserFrame.tsx +4 -3
  35. package/template/src/components/scenes/CTA.tsx +56 -10
  36. package/template/src/components/scenes/Clip.tsx +125 -0
  37. package/template/src/components/scenes/CodeReveal.tsx +4 -3
  38. package/template/src/components/scenes/DataFlow.tsx +4 -3
  39. package/template/src/components/scenes/FeatureList.tsx +13 -8
  40. package/template/src/components/scenes/FileTree.tsx +4 -3
  41. package/template/src/components/scenes/Hook.tsx +55 -0
  42. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  43. package/template/src/components/scenes/MobileScreen.tsx +120 -82
  44. package/template/src/components/scenes/OSWindowScene.tsx +4 -3
  45. package/template/src/components/scenes/Problem.tsx +28 -29
  46. package/template/src/components/scenes/SplitComparison.tsx +65 -92
  47. package/template/src/components/scenes/StatCallout.tsx +93 -4
  48. package/template/src/components/scenes/TerminalScene.tsx +5 -6
  49. package/template/src/duration.ts +12 -1
  50. 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: theme.bg,
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: theme.bg }}>
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: theme.bg,
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: theme.bg,
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: theme.bg,
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 = 340;
13
- const PHONE_H = 720;
14
- const NOTCH_H = 30;
15
- const RADIUS = 40;
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.88, 1]);
25
+ const scale = interpolate(progress, [0, 1], [0.9, 1]);
25
26
 
26
27
  return (
27
28
  <AbsoluteFill
28
29
  style={{
29
- background: theme.bg,
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 40px 100px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(255,255,255,0.05)`,
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: 110,
57
+ width: 120,
57
58
  height: NOTCH_H,
58
59
  background: theme.surface,
59
- borderBottomLeftRadius: 16,
60
- borderBottomRightRadius: 16,
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: 20,
83
- paddingRight: 20,
83
+ paddingLeft: 22,
84
+ paddingRight: 22,
84
85
  paddingBottom: 4,
85
86
  fontFamily: theme.fontSans,
86
- fontSize: 11,
87
+ fontSize: 12,
87
88
  fontWeight: 600,
88
- color: theme.textMuted,
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: 48,
101
+ height: 52,
100
102
  display: "flex",
101
103
  alignItems: "center",
102
- justifyContent: "center",
104
+ gap: 10,
105
+ padding: "0 18px",
103
106
  borderBottom: `1px solid ${theme.border}`,
104
107
  fontFamily: theme.fontSans,
105
- fontSize: 16,
106
- fontWeight: 600,
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.image ? (
118
- <Img
119
- src={staticFile(scene.image)}
120
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
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
- <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>
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: 52,
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: 36,
194
- height: 36,
149
+ width: 38,
150
+ height: 38,
195
151
  display: "flex",
196
152
  alignItems: "center",
197
153
  justifyContent: "center",
198
- fontSize: 18,
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
+ };