reelme 0.3.0 → 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 (40) hide show
  1. package/README.md +10 -3
  2. package/assets/audio/generate-tracks.mjs +6 -1
  3. package/package.json +8 -2
  4. package/src/cache.mjs +154 -14
  5. package/src/cli.mjs +20 -11
  6. package/src/render.mjs +91 -19
  7. package/template/package.json +6 -6
  8. package/template/pnpm-lock.yaml +177 -122
  9. package/template/pnpm-workspace.yaml +2 -0
  10. package/template/src/Root.tsx +27 -15
  11. package/template/src/brief.json +6 -6
  12. package/template/src/brief.ts +13 -3
  13. package/template/src/cinematic/Camera.tsx +1 -1
  14. package/template/src/cinematic/look.ts +25 -8
  15. package/template/src/cinematic/transitions.tsx +27 -9
  16. package/template/src/components/primitives/Bar.tsx +6 -2
  17. package/template/src/components/primitives/Caption.tsx +2 -2
  18. package/template/src/components/primitives/Icon.tsx +7 -0
  19. package/template/src/components/primitives/KeyPill.tsx +1 -1
  20. package/template/src/components/primitives/Label.tsx +4 -1
  21. package/template/src/components/primitives/Loop.tsx +65 -0
  22. package/template/src/components/primitives/RevealText.tsx +27 -5
  23. package/template/src/components/primitives/Stage.tsx +62 -0
  24. package/template/src/components/scenes/BrowserFrame.tsx +1 -1
  25. package/template/src/components/scenes/CTA.tsx +45 -82
  26. package/template/src/components/scenes/Clip.tsx +9 -3
  27. package/template/src/components/scenes/CodeReveal.tsx +6 -5
  28. package/template/src/components/scenes/DataFlow.tsx +1 -1
  29. package/template/src/components/scenes/FeatureList.tsx +74 -77
  30. package/template/src/components/scenes/FileTree.tsx +1 -1
  31. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  32. package/template/src/components/scenes/MobileScreen.tsx +222 -107
  33. package/template/src/components/scenes/OSWindowScene.tsx +4 -4
  34. package/template/src/components/scenes/Problem.tsx +1 -1
  35. package/template/src/components/scenes/SplitComparison.tsx +1 -1
  36. package/template/src/components/scenes/StatCallout.tsx +123 -68
  37. package/template/src/components/scenes/TerminalScene.tsx +12 -11
  38. package/template/src/duration.ts +12 -2
  39. package/template/src/index.ts +7 -5
  40. package/template/src/timing.ts +49 -0
@@ -1,9 +1,9 @@
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;
@@ -26,17 +26,7 @@ export const StatCallout: React.FC<Props> = ({ scene, theme, bottomInset = 0 })
26
26
  const captionStart = HEADLINE_FRAMES + scene.stats.length * FRAMES_PER_STAT + 20;
27
27
 
28
28
  return (
29
- <AbsoluteFill
30
- style={{
31
- background: "transparent",
32
- display: "flex",
33
- flexDirection: "column",
34
- alignItems: "center",
35
- justifyContent: "center",
36
- padding: "0 120px",
37
- gap: 64,
38
- }}
39
- >
29
+ <Stage theme={theme} gap={64} caption={scene.caption} captionStart={captionStart} bottomInset={bottomInset}>
40
30
  {scene.headline && (
41
31
  <Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
42
32
  )}
@@ -76,7 +66,7 @@ export const StatCallout: React.FC<Props> = ({ scene, theme, bottomInset = 0 })
76
66
  alignItems: "center",
77
67
  gap: 16,
78
68
  opacity,
79
- transform: `scale(${scale})`,
69
+ scale: String(scale),
80
70
  }}
81
71
  >
82
72
  <div
@@ -109,12 +99,16 @@ export const StatCallout: React.FC<Props> = ({ scene, theme, bottomInset = 0 })
109
99
  })}
110
100
  </div>
111
101
 
112
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
113
- </AbsoluteFill>
102
+ </Stage>
114
103
  );
115
104
  };
116
105
 
117
- /** One dominant number filling the frame; supporting stats sit small below. */
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
+ */
118
112
  const HeroStat: React.FC<{
119
113
  scene: StatCalloutBrief;
120
114
  theme: Theme;
@@ -124,76 +118,137 @@ const HeroStat: React.FC<{
124
118
  fps: number;
125
119
  }> = ({ scene, theme, bottomInset, width, frame, fps }) => {
126
120
  const [hero, ...rest] = scene.stats;
121
+ const hasSecondary = rest.length > 0;
127
122
  const p = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
128
123
  const valueScale = interpolate(p, [0, 1], [0.6, 1]);
129
124
  const valueOpacity = interpolate(p, [0, 1], [0, 1]);
130
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 });
131
127
 
132
128
  return (
133
- <AbsoluteFill
134
- style={{
135
- background: "transparent",
136
- display: "flex",
137
- flexDirection: "column",
138
- alignItems: "flex-start",
139
- justifyContent: "center",
140
- padding: "0 0 0 140px",
141
- }}
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}
142
138
  >
143
- {scene.headline && (
144
- <div style={{ marginBottom: 8 }}>
145
- <Label text={scene.headline} theme={theme} size="md" align="left" startFrame={0} muted />
146
- </div>
147
- )}
139
+ {/* Primary: the dominant number and its label. */}
148
140
  <div
149
141
  style={{
150
- fontFamily: theme.fontSans,
151
- fontSize: width * 0.2,
152
- fontWeight: 800,
153
- color: theme.accent,
154
- lineHeight: 0.92,
155
- letterSpacing: "-0.05em",
156
- opacity: valueOpacity,
157
- transform: `scale(${valueScale})`,
158
- transformOrigin: "left center",
142
+ display: "flex",
143
+ flexDirection: "column",
144
+ alignItems: hasSecondary ? "flex-start" : "center",
145
+ maxWidth: hasSecondary ? width * 0.5 : width * 0.78,
159
146
  }}
160
147
  >
161
- {hero?.value}
162
- </div>
163
- {hero?.label && (
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
+ )}
164
160
  <div
165
161
  style={{
166
- marginTop: 18,
167
162
  fontFamily: theme.fontSans,
168
- fontSize: 34,
169
- fontWeight: 600,
170
- color: theme.text,
171
- maxWidth: width * 0.6,
172
- opacity: interpolate(labelP, [0, 1], [0, 1]),
173
- transform: `translateY(${interpolate(labelP, [0, 1], [16, 0])}px)`,
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",
174
171
  }}
175
172
  >
176
- {hero?.label}
173
+ {hero?.value}
177
174
  </div>
178
- )}
179
- {rest.length > 0 && (
180
- <div style={{ display: "flex", gap: 56, marginTop: 48 }}>
181
- {rest.map((s, i) => {
182
- const rp = spring({ frame: frame - 24 - i * 8 + SNAP_OFFSET, fps, config: theme.motion });
183
- return (
184
- <div key={i} style={{ opacity: interpolate(rp, [0, 1], [0, 1]) }}>
185
- <div style={{ fontFamily: theme.fontSans, fontSize: 44, fontWeight: 800, color: theme.text, letterSpacing: "-0.03em" }}>
186
- {s.value}
187
- </div>
188
- <div style={{ fontFamily: theme.fontSans, fontSize: 18, fontWeight: 500, color: theme.textMuted, textTransform: "uppercase", letterSpacing: "0.04em" }}>
189
- {s.label}
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>
190
245
  </div>
191
- </div>
192
- );
193
- })}
246
+ );
247
+ })}
248
+ </div>
194
249
  </div>
195
250
  )}
196
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} bottomInset={bottomInset} />}
197
- </AbsoluteFill>
251
+
252
+ </Stage>
198
253
  );
199
254
  };
@@ -4,6 +4,7 @@ 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;
@@ -12,16 +13,10 @@ interface Props {
12
13
  }
13
14
 
14
15
  export const TerminalScene: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
15
- const lines = scene.commands.flatMap((cmd) => [
16
- { text: cmd.input, isOutput: false },
17
- { text: cmd.output, isOutput: true },
18
- ]);
19
-
20
- // Mirror Terminal's timing: startFrame=8, framesPerLine=23, framesPerChar=2.0
21
- const terminalDuration = lines.reduce((acc, line) => {
22
- return acc + (line.isOutput ? 23 : line.text.length * 2.0 + 23);
23
- }, 0);
24
- const captionStart = 8 + terminalDuration + 20;
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);
25
20
 
26
21
  return (
27
22
  <AbsoluteFill
@@ -33,7 +28,13 @@ export const TerminalScene: React.FC<Props> = ({ scene, theme, bottomInset = 0 }
33
28
  padding: "60px 100px",
34
29
  }}
35
30
  >
36
- <Terminal lines={lines} theme={theme} startFrame={8} />
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
+ />
37
38
  {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
38
39
  </AbsoluteFill>
39
40
  );
@@ -1,4 +1,5 @@
1
- import { BenchmarkScene, ClipScene, 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.
@@ -34,7 +35,16 @@ export function sceneDuration(scene: Scene): number {
34
35
  return (s.durationInFrames ?? SCENE_DURATION_MAP.clip) + SCENE_TAIL;
35
36
  }
36
37
  let content: number;
37
- 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") {
38
48
  const s = scene as FeatureListScene;
39
49
  content = 20 + s.items.length * 25 + 60;
40
50
  } else if (scene.type === "stat-callout") {
@@ -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
 
@@ -0,0 +1,49 @@
1
+ // Shared reveal timing for the two content-length-driven scenes. The duration
2
+ // oracle (duration.ts) and the components used to each re-implement this math,
3
+ // so they drifted: a long terminal or code block overran its fixed scene length
4
+ // and its caption never rendered (F7). Both now import from here, so the scene
5
+ // is always long enough for its own content plus its caption.
6
+
7
+ import { CodeRevealScene, TerminalScene } from "./brief";
8
+
9
+ export const TERMINAL_START = 8;
10
+ export const TERMINAL_FRAMES_PER_LINE = 23;
11
+ export const TERMINAL_FRAMES_PER_CHAR = 2.0;
12
+
13
+ export const CODE_START = 14;
14
+ export const CODE_FRAMES_PER_LINE = 9;
15
+
16
+ // Frames after the caption's start for it to spring in and be read before the
17
+ // scene's tail fade begins.
18
+ export const CAPTION_HOLD = 40;
19
+
20
+ export function terminalLines(scene: TerminalScene): Array<{ text: string; isOutput: boolean }> {
21
+ return scene.commands.flatMap((cmd) => [
22
+ { text: cmd.input, isOutput: false },
23
+ { text: cmd.output, isOutput: true },
24
+ ]);
25
+ }
26
+
27
+ // Frames until the last character of the last command has typed out.
28
+ export function terminalContentFrames(scene: TerminalScene): number {
29
+ return terminalLines(scene).reduce(
30
+ (acc, line) =>
31
+ acc +
32
+ (line.isOutput
33
+ ? TERMINAL_FRAMES_PER_LINE
34
+ : line.text.length * TERMINAL_FRAMES_PER_CHAR + TERMINAL_FRAMES_PER_LINE),
35
+ 0
36
+ );
37
+ }
38
+
39
+ export function terminalCaptionStart(scene: TerminalScene): number {
40
+ return TERMINAL_START + terminalContentFrames(scene) + 20;
41
+ }
42
+
43
+ export function codeLineCount(scene: CodeRevealScene): number {
44
+ return scene.code.split("\n").length;
45
+ }
46
+
47
+ export function codeRevealCaptionStart(scene: CodeRevealScene): number {
48
+ return CODE_START + codeLineCount(scene) * CODE_FRAMES_PER_LINE + 20;
49
+ }