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.
- package/README.md +10 -3
- package/assets/audio/generate-tracks.mjs +6 -1
- package/package.json +8 -2
- package/src/cache.mjs +154 -14
- package/src/cli.mjs +20 -11
- package/src/render.mjs +91 -19
- package/template/package.json +6 -6
- package/template/pnpm-lock.yaml +177 -122
- package/template/pnpm-workspace.yaml +2 -0
- package/template/src/Root.tsx +27 -15
- package/template/src/brief.json +6 -6
- package/template/src/brief.ts +13 -3
- package/template/src/cinematic/Camera.tsx +1 -1
- package/template/src/cinematic/look.ts +25 -8
- package/template/src/cinematic/transitions.tsx +27 -9
- package/template/src/components/primitives/Bar.tsx +6 -2
- package/template/src/components/primitives/Caption.tsx +2 -2
- package/template/src/components/primitives/Icon.tsx +7 -0
- package/template/src/components/primitives/KeyPill.tsx +1 -1
- package/template/src/components/primitives/Label.tsx +4 -1
- package/template/src/components/primitives/Loop.tsx +65 -0
- package/template/src/components/primitives/RevealText.tsx +27 -5
- package/template/src/components/primitives/Stage.tsx +62 -0
- package/template/src/components/scenes/BrowserFrame.tsx +1 -1
- package/template/src/components/scenes/CTA.tsx +45 -82
- package/template/src/components/scenes/Clip.tsx +9 -3
- package/template/src/components/scenes/CodeReveal.tsx +6 -5
- package/template/src/components/scenes/DataFlow.tsx +1 -1
- package/template/src/components/scenes/FeatureList.tsx +74 -77
- package/template/src/components/scenes/FileTree.tsx +1 -1
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +222 -107
- package/template/src/components/scenes/OSWindowScene.tsx +4 -4
- package/template/src/components/scenes/Problem.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +1 -1
- package/template/src/components/scenes/StatCallout.tsx +123 -68
- package/template/src/components/scenes/TerminalScene.tsx +12 -11
- package/template/src/duration.ts +12 -2
- package/template/src/index.ts +7 -5
- package/template/src/timing.ts +49 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
</AbsoluteFill>
|
|
102
|
+
</Stage>
|
|
114
103
|
);
|
|
115
104
|
};
|
|
116
105
|
|
|
117
|
-
/**
|
|
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
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
{
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
169
|
-
fontWeight:
|
|
170
|
-
color: theme.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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?.
|
|
173
|
+
{hero?.value}
|
|
177
174
|
</div>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
)
|
|
193
|
-
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</div>
|
|
194
249
|
</div>
|
|
195
250
|
)}
|
|
196
|
-
|
|
197
|
-
</
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
);
|
package/template/src/duration.ts
CHANGED
|
@@ -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 === "
|
|
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") {
|
package/template/src/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
`
|
|
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
|
+
}
|