reelme 0.2.2 → 0.4.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 (59) hide show
  1. package/README.md +10 -3
  2. package/assets/audio/PROVENANCE.md +16 -0
  3. package/assets/audio/bright-sparks.mp3 +0 -0
  4. package/assets/audio/calm-keys.mp3 +0 -0
  5. package/assets/audio/circuit-pulse.mp3 +0 -0
  6. package/assets/audio/clean-horizon.mp3 +0 -0
  7. package/assets/audio/generate-tracks.mjs +218 -0
  8. package/assets/audio/manifest.json +83 -0
  9. package/assets/audio/midnight-protocol.mp3 +0 -0
  10. package/assets/audio/pixel-bounce.mp3 +0 -0
  11. package/assets/audio/steady-launch.mp3 +0 -0
  12. package/assets/audio/sunny-loop.mp3 +0 -0
  13. package/assets/audio/vector-grid.mp3 +0 -0
  14. package/package.json +9 -2
  15. package/src/cache.mjs +155 -14
  16. package/src/cli.mjs +20 -11
  17. package/src/render.mjs +172 -9
  18. package/template/package.json +6 -5
  19. package/template/pnpm-lock.yaml +186 -116
  20. package/template/pnpm-workspace.yaml +2 -0
  21. package/template/src/Root.tsx +73 -51
  22. package/template/src/audio.ts +24 -0
  23. package/template/src/benchmark.ts +18 -0
  24. package/template/src/brief.json +7 -6
  25. package/template/src/brief.ts +73 -5
  26. package/template/src/cinematic/Atmosphere.tsx +139 -0
  27. package/template/src/cinematic/Camera.tsx +54 -0
  28. package/template/src/cinematic/look.ts +106 -0
  29. package/template/src/cinematic/transitions.tsx +110 -0
  30. package/template/src/components/primitives/Bar.tsx +86 -0
  31. package/template/src/components/primitives/Caption.tsx +5 -4
  32. package/template/src/components/primitives/Icon.tsx +7 -0
  33. package/template/src/components/primitives/KeyPill.tsx +1 -1
  34. package/template/src/components/primitives/Kicker.tsx +46 -0
  35. package/template/src/components/primitives/Label.tsx +22 -25
  36. package/template/src/components/primitives/Loop.tsx +65 -0
  37. package/template/src/components/primitives/RevealText.tsx +123 -0
  38. package/template/src/components/primitives/Stage.tsx +62 -0
  39. package/template/src/components/primitives/Terminal.tsx +11 -0
  40. package/template/src/components/scenes/Benchmark.tsx +83 -0
  41. package/template/src/components/scenes/BrowserFrame.tsx +5 -4
  42. package/template/src/components/scenes/CTA.tsx +67 -58
  43. package/template/src/components/scenes/Clip.tsx +131 -0
  44. package/template/src/components/scenes/CodeReveal.tsx +10 -8
  45. package/template/src/components/scenes/DataFlow.tsx +5 -4
  46. package/template/src/components/scenes/FeatureList.tsx +80 -78
  47. package/template/src/components/scenes/FileTree.tsx +5 -4
  48. package/template/src/components/scenes/Hook.tsx +55 -0
  49. package/template/src/components/scenes/HotkeyScene.tsx +8 -6
  50. package/template/src/components/scenes/MobileScreen.tsx +308 -155
  51. package/template/src/components/scenes/OSWindowScene.tsx +8 -7
  52. package/template/src/components/scenes/Problem.tsx +29 -30
  53. package/template/src/components/scenes/SplitComparison.tsx +65 -92
  54. package/template/src/components/scenes/StatCallout.tsx +162 -18
  55. package/template/src/components/scenes/TerminalScene.tsx +16 -16
  56. package/template/src/duration.ts +23 -2
  57. package/template/src/index.ts +7 -5
  58. package/template/src/platforms.ts +4 -0
  59. package/template/src/timing.ts +49 -0
@@ -2,23 +2,30 @@ import React from "react";
2
2
  import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
3
  import { ProblemScene as ProblemBrief, ProjectMeta } 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";
8
+ import { RevealText } from "../primitives/RevealText";
9
+ import { Kicker } from "../primitives/Kicker";
7
10
 
8
11
  interface Props {
9
12
  scene: ProblemBrief;
10
13
  theme: Theme;
11
14
  project: ProjectMeta;
15
+ platform?: PlatformPreset;
16
+ bottomInset?: number;
12
17
  }
13
18
 
14
19
  const SNAP_OFFSET = 8;
15
20
 
16
- export const Problem: React.FC<Props> = ({ scene, theme, project }) => {
21
+ export const Problem: React.FC<Props> = ({ scene, theme, project, platform, bottomInset = 0 }) => {
17
22
  const frame = useCurrentFrame();
18
23
  const { fps } = useVideoConfig();
24
+ const scale = platform ? typeScale(platform) : 1.0;
19
25
 
20
26
  const isAnnouncement = project.mode === "announcement" && project.version;
21
27
  const isHero = !!scene.hero;
28
+ const left = scene.align === "left";
22
29
 
23
30
  const accentBarProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
24
31
  const accentBarWidth = interpolate(accentBarProgress, [0, 1], [0, isHero ? 120 : 80]);
@@ -30,20 +37,22 @@ export const Problem: React.FC<Props> = ({ scene, theme, project }) => {
30
37
  return (
31
38
  <AbsoluteFill
32
39
  style={{
33
- background: theme.bg,
40
+ background: "transparent",
34
41
  display: "flex",
35
42
  flexDirection: "column",
36
- alignItems: "center",
43
+ alignItems: left ? "flex-start" : "center",
37
44
  justifyContent: "center",
38
- padding: isHero ? "0 80px" : "0 120px",
45
+ padding: left ? "0 0 0 140px" : isHero ? "0 80px" : "0 120px",
39
46
  gap: isHero ? 40 : 32,
40
47
  }}
41
48
  >
42
- {isAnnouncement ? (
49
+ {scene.kicker ? (
50
+ <Kicker text={scene.kicker} theme={theme} startFrame={0} align={left ? "left" : "center"} />
51
+ ) : isAnnouncement ? (
43
52
  <div
44
53
  style={{
45
54
  opacity: badgeOpacity,
46
- transform: `translateY(${badgeY}px)`,
55
+ translate: `0 ${badgeY}px`,
47
56
  background: theme.accentMuted,
48
57
  border: `1.5px solid ${theme.accent}`,
49
58
  borderRadius: 999,
@@ -69,37 +78,27 @@ export const Problem: React.FC<Props> = ({ scene, theme, project }) => {
69
78
  )}
70
79
 
71
80
  {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>
81
+ <RevealText
82
+ text={scene.headline}
83
+ theme={theme}
84
+ fontSize={104 * scale}
85
+ fontWeight={800}
86
+ align={left ? "left" : "center"}
87
+ stagger={3.5}
88
+ letterSpacing="-0.03em"
89
+ lineHeight={1.04}
90
+ maxWidth={left ? 1300 : 1500}
91
+ />
93
92
  ) : (
94
93
  <>
95
- <Label text={scene.headline} theme={theme} size="xl" startFrame={4} />
94
+ <Label text={scene.headline} theme={theme} size="xl" align={left ? "left" : "center"} startFrame={4} />
96
95
  {scene.subtext && (
97
- <Label text={scene.subtext} theme={theme} size="md" muted startFrame={16} />
96
+ <Label text={scene.subtext} theme={theme} size="md" muted align={left ? "left" : "center"} startFrame={16} />
98
97
  )}
99
98
  </>
100
99
  )}
101
100
 
102
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={40} />}
101
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={40} bottomInset={bottomInset} />}
103
102
  </AbsoluteFill>
104
103
  );
105
104
  };
@@ -7,9 +7,10 @@ import { Caption } from "../primitives/Caption";
7
7
  interface Props {
8
8
  scene: SplitBrief;
9
9
  theme: Theme;
10
+ bottomInset?: number;
10
11
  }
11
12
 
12
- export const SplitComparison: React.FC<Props> = ({ scene, theme }) => {
13
+ export const SplitComparison: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
13
14
  const frame = useCurrentFrame();
14
15
  const { fps } = useVideoConfig();
15
16
 
@@ -23,114 +24,86 @@ export const SplitComparison: React.FC<Props> = ({ scene, theme }) => {
23
24
  const rightX = interpolate(Math.max(0, rightProgress), [0, 1], [60, 0]);
24
25
  const dividerOpacity = interpolate(Math.max(0, dividerProgress), [0, 1], [0, 1]);
25
26
 
27
+ const Panel: React.FC<{
28
+ label: string;
29
+ content: string;
30
+ accent: boolean;
31
+ opacity: number;
32
+ x: number;
33
+ }> = ({ label, content, accent, opacity, x }) => (
34
+ <div
35
+ style={{
36
+ flex: 1,
37
+ opacity,
38
+ translate: `${x}px 0`,
39
+ display: "flex",
40
+ flexDirection: "column",
41
+ gap: 18,
42
+ }}
43
+ >
44
+ <div
45
+ style={{
46
+ fontFamily: theme.fontSans,
47
+ fontSize: 15,
48
+ fontWeight: 700,
49
+ letterSpacing: "0.12em",
50
+ textTransform: "uppercase",
51
+ color: accent ? theme.accent : theme.textMuted,
52
+ }}
53
+ >
54
+ {label}
55
+ </div>
56
+ <div
57
+ style={{
58
+ minHeight: 240,
59
+ background: theme.surface,
60
+ border: `1.5px solid ${accent ? `${theme.accent}66` : theme.border}`,
61
+ borderRadius: 14,
62
+ padding: "44px 40px",
63
+ display: "flex",
64
+ alignItems: "center",
65
+ justifyContent: "center",
66
+ textAlign: "center",
67
+ fontFamily: theme.fontSans,
68
+ fontSize: 32,
69
+ fontWeight: 600,
70
+ lineHeight: 1.3,
71
+ letterSpacing: "-0.01em",
72
+ color: accent ? theme.text : theme.textMuted,
73
+ boxShadow: accent ? `0 18px 50px ${theme.accent}22` : "0 14px 40px rgba(0,0,0,0.3)",
74
+ }}
75
+ >
76
+ {content}
77
+ </div>
78
+ </div>
79
+ );
80
+
26
81
  return (
27
82
  <AbsoluteFill
28
83
  style={{
29
- background: theme.bg,
84
+ background: "transparent",
30
85
  display: "flex",
31
86
  alignItems: "center",
32
87
  justifyContent: "center",
33
- padding: "60px 80px",
88
+ padding: "0 100px",
34
89
  }}
35
90
  >
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 */}
91
+ <div style={{ display: "flex", width: "100%", maxWidth: 1500, alignItems: "stretch", gap: 0 }}>
92
+ <Panel label={scene.before.label} content={scene.before.content} accent={false} opacity={leftOpacity} x={leftX} />
80
93
  <div
81
94
  style={{
82
95
  width: 2,
83
- alignSelf: "stretch",
96
+ alignSelf: "center",
97
+ height: 200,
84
98
  background: theme.border,
85
- margin: "36px 32px 0",
99
+ margin: "44px 36px 0",
86
100
  opacity: dividerOpacity,
87
101
  }}
88
102
  />
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>
103
+ <Panel label={scene.after.label} content={scene.after.content} accent opacity={rightOpacity} x={rightX} />
131
104
  </div>
132
105
 
133
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} />}
106
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} bottomInset={bottomInset} />}
134
107
  </AbsoluteFill>
135
108
  );
136
109
  };
@@ -1,37 +1,32 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
3
  import { StatCalloutScene as StatCalloutBrief } from "../../brief";
4
4
  import { Theme } from "../../theme";
5
5
  import { Label } from "../primitives/Label";
6
- import { Caption } from "../primitives/Caption";
6
+ import { Stage } from "../primitives/Stage";
7
7
 
8
8
  interface Props {
9
9
  scene: StatCalloutBrief;
10
10
  theme: Theme;
11
+ bottomInset?: number;
11
12
  }
12
13
 
13
14
  const HEADLINE_FRAMES = 20;
14
15
  const FRAMES_PER_STAT = 35;
15
16
  const SNAP_OFFSET = 8;
16
17
 
17
- export const StatCallout: React.FC<Props> = ({ scene, theme }) => {
18
+ export const StatCallout: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
18
19
  const frame = useCurrentFrame();
19
- const { fps } = useVideoConfig();
20
+ const { fps, width } = useVideoConfig();
21
+
22
+ if (scene.layout === "hero") {
23
+ return <HeroStat scene={scene} theme={theme} bottomInset={bottomInset} width={width} frame={frame} fps={fps} />;
24
+ }
20
25
 
21
26
  const captionStart = HEADLINE_FRAMES + scene.stats.length * FRAMES_PER_STAT + 20;
22
27
 
23
28
  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
- >
29
+ <Stage theme={theme} gap={64} caption={scene.caption} captionStart={captionStart} bottomInset={bottomInset}>
35
30
  {scene.headline && (
36
31
  <Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
37
32
  )}
@@ -71,7 +66,7 @@ export const StatCallout: React.FC<Props> = ({ scene, theme }) => {
71
66
  alignItems: "center",
72
67
  gap: 16,
73
68
  opacity,
74
- transform: `scale(${scale})`,
69
+ scale: String(scale),
75
70
  }}
76
71
  >
77
72
  <div
@@ -104,7 +99,156 @@ export const StatCallout: React.FC<Props> = ({ scene, theme }) => {
104
99
  })}
105
100
  </div>
106
101
 
107
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
108
- </AbsoluteFill>
102
+ </Stage>
103
+ );
104
+ };
105
+
106
+ /**
107
+ * One dominant number, kept in balance. With supporting stats they fill the
108
+ * opposite side as a substantial column (divided by a thin rule) so the frame
109
+ * carries weight on both sides instead of stranding the number against an empty
110
+ * void. Alone, the number is centered and composed so it never sits in a corner.
111
+ */
112
+ const HeroStat: React.FC<{
113
+ scene: StatCalloutBrief;
114
+ theme: Theme;
115
+ bottomInset: number;
116
+ width: number;
117
+ frame: number;
118
+ fps: number;
119
+ }> = ({ scene, theme, bottomInset, width, frame, fps }) => {
120
+ const [hero, ...rest] = scene.stats;
121
+ const hasSecondary = rest.length > 0;
122
+ const p = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
123
+ const valueScale = interpolate(p, [0, 1], [0.6, 1]);
124
+ const valueOpacity = interpolate(p, [0, 1], [0, 1]);
125
+ const labelP = spring({ frame: frame - 10 + SNAP_OFFSET, fps, config: theme.motion });
126
+ const dividerP = spring({ frame: frame - 18 + SNAP_OFFSET, fps, config: theme.motion });
127
+
128
+ return (
129
+ <Stage
130
+ theme={theme}
131
+ direction="row"
132
+ justify={hasSecondary ? "space-between" : "center"}
133
+ gap={90}
134
+ padding="0 130px"
135
+ caption={scene.caption}
136
+ captionStart={50}
137
+ bottomInset={bottomInset}
138
+ >
139
+ {/* Primary: the dominant number and its label. */}
140
+ <div
141
+ style={{
142
+ display: "flex",
143
+ flexDirection: "column",
144
+ alignItems: hasSecondary ? "flex-start" : "center",
145
+ maxWidth: hasSecondary ? width * 0.5 : width * 0.78,
146
+ }}
147
+ >
148
+ {scene.headline && (
149
+ <div style={{ marginBottom: 10 }}>
150
+ <Label
151
+ text={scene.headline}
152
+ theme={theme}
153
+ size="md"
154
+ align={hasSecondary ? "left" : "center"}
155
+ startFrame={0}
156
+ muted
157
+ />
158
+ </div>
159
+ )}
160
+ <div
161
+ style={{
162
+ fontFamily: theme.fontSans,
163
+ fontSize: hasSecondary ? width * 0.16 : width * 0.2,
164
+ fontWeight: 800,
165
+ color: theme.accent,
166
+ lineHeight: 0.9,
167
+ letterSpacing: "-0.05em",
168
+ opacity: valueOpacity,
169
+ scale: String(valueScale),
170
+ transformOrigin: hasSecondary ? "left center" : "center",
171
+ }}
172
+ >
173
+ {hero?.value}
174
+ </div>
175
+ {hero?.label && (
176
+ <div
177
+ style={{
178
+ marginTop: 20,
179
+ fontFamily: theme.fontSans,
180
+ fontSize: 40,
181
+ fontWeight: 600,
182
+ lineHeight: 1.2,
183
+ color: theme.text,
184
+ textAlign: hasSecondary ? "left" : "center",
185
+ maxWidth: hasSecondary ? width * 0.46 : width * 0.66,
186
+ opacity: interpolate(labelP, [0, 1], [0, 1]),
187
+ translate: `0 ${interpolate(labelP, [0, 1], [16, 0])}px`,
188
+ }}
189
+ >
190
+ {hero?.label}
191
+ </div>
192
+ )}
193
+ </div>
194
+
195
+ {/* Secondary mass: supporting stats fill the opposite side as substantial
196
+ blocks, balancing the hero number instead of leaving a void. */}
197
+ {hasSecondary && (
198
+ <div style={{ display: "flex", alignItems: "stretch", gap: 44 }}>
199
+ <div
200
+ style={{
201
+ width: 2,
202
+ alignSelf: "stretch",
203
+ background: theme.border,
204
+ opacity: interpolate(dividerP, [0, 1], [0, 0.7]),
205
+ scale: `1 ${interpolate(dividerP, [0, 1], [0, 1])}`,
206
+ transformOrigin: "top",
207
+ }}
208
+ />
209
+ <div style={{ display: "flex", flexDirection: "column", gap: 40 }}>
210
+ {rest.map((s, i) => {
211
+ const rp = spring({ frame: frame - 22 - i * 8 + SNAP_OFFSET, fps, config: theme.motion });
212
+ return (
213
+ <div
214
+ key={i}
215
+ style={{
216
+ opacity: interpolate(rp, [0, 1], [0, 1]),
217
+ translate: `${interpolate(rp, [0, 1], [24, 0])}px 0`,
218
+ }}
219
+ >
220
+ <div
221
+ style={{
222
+ fontFamily: theme.fontSans,
223
+ fontSize: 68,
224
+ fontWeight: 800,
225
+ color: theme.text,
226
+ lineHeight: 1,
227
+ letterSpacing: "-0.03em",
228
+ }}
229
+ >
230
+ {s.value}
231
+ </div>
232
+ <div
233
+ style={{
234
+ marginTop: 8,
235
+ fontFamily: theme.fontSans,
236
+ fontSize: 30,
237
+ fontWeight: 500,
238
+ color: theme.textMuted,
239
+ maxWidth: width * 0.28,
240
+ lineHeight: 1.25,
241
+ }}
242
+ >
243
+ {s.label}
244
+ </div>
245
+ </div>
246
+ );
247
+ })}
248
+ </div>
249
+ </div>
250
+ )}
251
+
252
+ </Stage>
109
253
  );
110
254
  };
@@ -4,38 +4,38 @@ import { TerminalScene as TerminalBrief } from "../../brief";
4
4
  import { Theme } from "../../theme";
5
5
  import { Terminal } from "../primitives/Terminal";
6
6
  import { Caption } from "../primitives/Caption";
7
+ import { TERMINAL_FRAMES_PER_CHAR, TERMINAL_FRAMES_PER_LINE, TERMINAL_START, terminalCaptionStart, terminalLines } from "../../timing";
7
8
 
8
9
  interface Props {
9
10
  scene: TerminalBrief;
10
11
  theme: Theme;
12
+ bottomInset?: number;
11
13
  }
12
14
 
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;
15
+ export const TerminalScene: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
16
+ const lines = terminalLines(scene);
17
+ // Timing is shared with the duration oracle so the scene is always long
18
+ // enough for the caption to appear (see timing.ts).
19
+ const captionStart = terminalCaptionStart(scene);
24
20
 
25
21
  return (
26
22
  <AbsoluteFill
27
23
  style={{
28
- background: theme.bg,
24
+ background: "transparent",
29
25
  display: "flex",
30
26
  alignItems: "center",
31
27
  justifyContent: "center",
32
28
  padding: "60px 100px",
33
29
  }}
34
30
  >
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} />}
31
+ <Terminal
32
+ lines={lines}
33
+ theme={theme}
34
+ startFrame={TERMINAL_START}
35
+ framesPerLine={TERMINAL_FRAMES_PER_LINE}
36
+ framesPerChar={TERMINAL_FRAMES_PER_CHAR}
37
+ />
38
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
39
39
  </AbsoluteFill>
40
40
  );
41
41
  };
@@ -1,4 +1,5 @@
1
- import { FeatureListScene, FileTreeScene, HotkeyScene, OSWindowScene, Scene, StatCalloutScene } from "./brief";
1
+ import { BenchmarkScene, ClipScene, CodeRevealScene, FeatureListScene, FileTreeScene, HotkeyScene, OSWindowScene, Scene, StatCalloutScene, TerminalScene } from "./brief";
2
+ import { CAPTION_HOLD, codeRevealCaptionStart, terminalCaptionStart } from "./timing";
2
3
 
3
4
  // Hard ceiling for the teaser cut: 10s at 30fps. Renders over the limit
4
5
  // succeed, but the CLI prints a prominent warning.
@@ -22,11 +23,28 @@ export const SCENE_DURATION_MAP: Record<Scene["type"], number> = {
22
23
  mobile: 150,
23
24
  "os-window": 180,
24
25
  hotkey: 120,
26
+ hook: 50,
27
+ clip: 150,
28
+ benchmark: 180,
25
29
  };
26
30
 
27
31
  export function sceneDuration(scene: Scene): number {
32
+ if (scene.type === "hook") return 50; // no tail: hook IS the headline, no breathing room needed
33
+ if (scene.type === "clip") {
34
+ const s = scene as ClipScene;
35
+ return (s.durationInFrames ?? SCENE_DURATION_MAP.clip) + SCENE_TAIL;
36
+ }
28
37
  let content: number;
29
- if (scene.type === "feature-list") {
38
+ if (scene.type === "terminal") {
39
+ // Long enough to type every command out, then show the caption (F7).
40
+ const s = scene as TerminalScene;
41
+ const start = terminalCaptionStart(s);
42
+ content = s.caption ? start + CAPTION_HOLD : start;
43
+ } else if (scene.type === "code-reveal") {
44
+ const s = scene as CodeRevealScene;
45
+ const start = codeRevealCaptionStart(s);
46
+ content = s.caption ? start + CAPTION_HOLD : start;
47
+ } else if (scene.type === "feature-list") {
30
48
  const s = scene as FeatureListScene;
31
49
  content = 20 + s.items.length * 25 + 60;
32
50
  } else if (scene.type === "stat-callout") {
@@ -42,6 +60,9 @@ export function sceneDuration(scene: Scene): number {
42
60
  } else if (scene.type === "hotkey") {
43
61
  const s = scene as HotkeyScene;
44
62
  content = 20 + s.keys.length * 20 + 70;
63
+ } else if (scene.type === "benchmark") {
64
+ const s = scene as BenchmarkScene;
65
+ content = 20 + s.bars.length * 30 + 60;
45
66
  } else {
46
67
  content = SCENE_DURATION_MAP[scene.type];
47
68
  }
@@ -31,19 +31,21 @@ if (!brief.cuts || !Array.isArray(brief.cuts.main) || brief.cuts.main.length ===
31
31
  );
32
32
  }
33
33
 
34
- // A vertical platform without cuts.vertical falls back to the main cut.
34
+ // A vertical platform without a (non-empty) cuts.vertical falls back to the main
35
+ // cut. An empty array is treated as absent so it never builds a 0-scene, and
36
+ // therefore 0-frame, composition that Remotion rejects (F3).
35
37
  function scenesForCut(cut: "main" | "vertical"): Scene[] {
36
- if (cut === "vertical") return brief.cuts.vertical ?? brief.cuts.main;
38
+ if (cut === "vertical") return brief.cuts.vertical?.length ? brief.cuts.vertical : brief.cuts.main;
37
39
  return brief.cuts.main;
38
40
  }
39
41
 
40
42
  const verticalFallback = platformIds.filter(
41
- (id) => cutForPlatform(id) === "vertical" && !brief.cuts.vertical
43
+ (id) => cutForPlatform(id) === "vertical" && !brief.cuts.vertical?.length
42
44
  );
43
45
  if (verticalFallback.length > 0) {
44
46
  console.warn(
45
- `reelme: no cuts.vertical in the brief — ${verticalFallback.join(", ")} will render the main cut ` +
46
- `letterboxed into 9:16. Author a vertical cut for better results.`
47
+ `reelme: no cuts.vertical in the brief — ${verticalFallback.join(", ")} will re-render the main cut ` +
48
+ `at 9:16. Dense wide scenes may cramp — author a vertical cut for better results.`
47
49
  );
48
50
  }
49
51
 
@@ -34,3 +34,7 @@ export const PLATFORMS = presets as Record<PlatformId, PlatformPreset>;
34
34
  export function cutForPlatform(id: PlatformId): "main" | "vertical" {
35
35
  return PLATFORMS[id].cut;
36
36
  }
37
+
38
+ export function typeScale(platform: PlatformPreset): number {
39
+ return platform.height > platform.width ? 1.25 : 1.0;
40
+ }