reelme 0.2.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 (39) hide show
  1. package/package.json +34 -0
  2. package/src/cache.mjs +148 -0
  3. package/src/cli.mjs +57 -0
  4. package/src/render.mjs +47 -0
  5. package/template/package.json +29 -0
  6. package/template/pnpm-lock.yaml +3522 -0
  7. package/template/pnpm-workspace.yaml +4 -0
  8. package/template/public/.gitkeep +0 -0
  9. package/template/remotion.config.ts +4 -0
  10. package/template/src/Root.tsx +127 -0
  11. package/template/src/brief.json +131 -0
  12. package/template/src/brief.ts +195 -0
  13. package/template/src/components/primitives/Arrow.tsx +83 -0
  14. package/template/src/components/primitives/Caption.tsx +52 -0
  15. package/template/src/components/primitives/CodeBlock.tsx +159 -0
  16. package/template/src/components/primitives/Icon.tsx +68 -0
  17. package/template/src/components/primitives/KeyPill.tsx +44 -0
  18. package/template/src/components/primitives/Label.tsx +49 -0
  19. package/template/src/components/primitives/Terminal.tsx +110 -0
  20. package/template/src/components/scenes/BrowserFrame.tsx +139 -0
  21. package/template/src/components/scenes/CTA.tsx +118 -0
  22. package/template/src/components/scenes/CodeReveal.tsx +41 -0
  23. package/template/src/components/scenes/DataFlow.tsx +102 -0
  24. package/template/src/components/scenes/FeatureList.tsx +110 -0
  25. package/template/src/components/scenes/FileTree.tsx +125 -0
  26. package/template/src/components/scenes/HotkeyScene.tsx +77 -0
  27. package/template/src/components/scenes/MobileScreen.tsx +212 -0
  28. package/template/src/components/scenes/OSWindowScene.tsx +166 -0
  29. package/template/src/components/scenes/Problem.tsx +105 -0
  30. package/template/src/components/scenes/SplitComparison.tsx +136 -0
  31. package/template/src/components/scenes/StatCallout.tsx +110 -0
  32. package/template/src/components/scenes/TerminalScene.tsx +41 -0
  33. package/template/src/duration.ts +55 -0
  34. package/template/src/fonts.ts +49 -0
  35. package/template/src/index.ts +106 -0
  36. package/template/src/platforms.json +78 -0
  37. package/template/src/platforms.ts +36 -0
  38. package/template/src/theme.ts +88 -0
  39. package/template/tsconfig.json +15 -0
@@ -0,0 +1,166 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { OSWindowScene as OSWindowBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Icon } from "../primitives/Icon";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: OSWindowBrief;
10
+ theme: Theme;
11
+ }
12
+
13
+ const WINDOW_WIDTH = 600;
14
+ const SEARCH_TYPE_START = 18;
15
+ const FRAMES_PER_CHAR = 3;
16
+ const ITEMS_START_OFFSET = 10;
17
+ const FRAMES_PER_ITEM = 20;
18
+
19
+ const TRAFFIC_LIGHTS = ["#ff5f57", "#ffbd2e", "#28c941"] as const;
20
+
21
+ export const OSWindow: React.FC<Props> = ({ scene, theme }) => {
22
+ const frame = useCurrentFrame();
23
+ const { fps } = useVideoConfig();
24
+
25
+ const windowProgress = spring({ frame, fps, config: theme.motion });
26
+ const windowScale = interpolate(windowProgress, [0, 1], [0.88, 1]);
27
+ const windowOpacity = interpolate(windowProgress, [0, 1], [0, 1]);
28
+
29
+ const query = scene.searchQuery ?? "";
30
+ const charsVisible = Math.max(0, Math.floor((frame - SEARCH_TYPE_START) / FRAMES_PER_CHAR));
31
+ const typedText = query.slice(0, charsVisible);
32
+
33
+ const itemsStart = query
34
+ ? SEARCH_TYPE_START + query.length * FRAMES_PER_CHAR + ITEMS_START_OFFSET
35
+ : 20;
36
+ const captionStart = itemsStart + (scene.items?.length ?? 0) * FRAMES_PER_ITEM + 20;
37
+
38
+ return (
39
+ <AbsoluteFill
40
+ style={{ background: theme.bg, display: "flex", alignItems: "center", justifyContent: "center" }}
41
+ >
42
+ <div
43
+ style={{
44
+ width: WINDOW_WIDTH,
45
+ borderRadius: 14,
46
+ overflow: "hidden",
47
+ background: theme.surface,
48
+ border: `1px solid ${theme.border}`,
49
+ boxShadow: "0 40px 100px rgba(0,0,0,0.45), 0 0 0 0.5px rgba(255,255,255,0.05)",
50
+ transform: `scale(${windowScale})`,
51
+ opacity: windowOpacity,
52
+ }}
53
+ >
54
+ {/* Title bar */}
55
+ <div
56
+ style={{
57
+ display: "flex",
58
+ alignItems: "center",
59
+ padding: "14px 16px",
60
+ borderBottom: `1px solid ${theme.border}`,
61
+ position: "relative",
62
+ }}
63
+ >
64
+ <div style={{ display: "flex", gap: 8 }}>
65
+ {TRAFFIC_LIGHTS.map((color, i) => (
66
+ <div key={i} style={{ width: 13, height: 13, borderRadius: "50%", background: color }} />
67
+ ))}
68
+ </div>
69
+ {scene.title && (
70
+ <div
71
+ style={{
72
+ position: "absolute",
73
+ left: "50%",
74
+ transform: "translateX(-50%)",
75
+ fontFamily: theme.fontSans,
76
+ fontSize: 13,
77
+ fontWeight: 500,
78
+ color: theme.textMuted,
79
+ letterSpacing: "0.01em",
80
+ }}
81
+ >
82
+ {scene.title}
83
+ </div>
84
+ )}
85
+ </div>
86
+
87
+ {/* Search bar */}
88
+ {scene.searchQuery !== undefined && (
89
+ <div style={{ padding: "10px 12px", borderBottom: `1px solid ${theme.border}` }}>
90
+ <div
91
+ style={{
92
+ display: "flex",
93
+ alignItems: "center",
94
+ gap: 8,
95
+ background: theme.bg,
96
+ borderRadius: 8,
97
+ padding: "8px 12px",
98
+ border: `1px solid ${theme.border}`,
99
+ }}
100
+ >
101
+ <Icon name="search" size={15} color={theme.textMuted} />
102
+ <span style={{ fontFamily: theme.fontSans, fontSize: 14, color: typedText ? theme.text : theme.textMuted }}>
103
+ {typedText || "Search…"}
104
+ </span>
105
+ </div>
106
+ </div>
107
+ )}
108
+
109
+ {/* Item list */}
110
+ <div style={{ padding: "6px 0" }}>
111
+ {(scene.items ?? []).map((item, i) => {
112
+ const itemStart = itemsStart + i * FRAMES_PER_ITEM;
113
+ const p = spring({ frame: frame - itemStart, fps, config: theme.motion });
114
+ const opacity = interpolate(Math.max(0, p), [0, 1], [0, 1]);
115
+ const tx = interpolate(Math.max(0, p), [0, 1], [-14, 0]);
116
+
117
+ return (
118
+ <div
119
+ key={i}
120
+ style={{
121
+ display: "flex",
122
+ alignItems: "center",
123
+ gap: 12,
124
+ padding: "9px 14px",
125
+ background: item.highlighted ? theme.accentMuted : "transparent",
126
+ opacity,
127
+ transform: `translateX(${tx}px)`,
128
+ }}
129
+ >
130
+ {item.icon && (
131
+ <div
132
+ style={{
133
+ width: 30,
134
+ height: 30,
135
+ borderRadius: 7,
136
+ background: theme.accentMuted,
137
+ border: `1px solid ${theme.border}`,
138
+ display: "flex",
139
+ alignItems: "center",
140
+ justifyContent: "center",
141
+ flexShrink: 0,
142
+ }}
143
+ >
144
+ <Icon name={item.icon} size={15} color={theme.accent} />
145
+ </div>
146
+ )}
147
+ <div style={{ flex: 1, minWidth: 0 }}>
148
+ <div style={{ fontFamily: theme.fontMono, fontSize: 13, fontWeight: 500, color: theme.text }}>
149
+ {item.label}
150
+ </div>
151
+ {item.value && (
152
+ <div style={{ fontFamily: theme.fontMono, fontSize: 12, color: theme.textMuted, marginTop: 2 }}>
153
+ {item.value}
154
+ </div>
155
+ )}
156
+ </div>
157
+ </div>
158
+ );
159
+ })}
160
+ </div>
161
+ </div>
162
+
163
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
164
+ </AbsoluteFill>
165
+ );
166
+ };
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { ProblemScene as ProblemBrief, ProjectMeta } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Label } from "../primitives/Label";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: ProblemBrief;
10
+ theme: Theme;
11
+ project: ProjectMeta;
12
+ }
13
+
14
+ const SNAP_OFFSET = 8;
15
+
16
+ export const Problem: React.FC<Props> = ({ scene, theme, project }) => {
17
+ const frame = useCurrentFrame();
18
+ const { fps } = useVideoConfig();
19
+
20
+ const isAnnouncement = project.mode === "announcement" && project.version;
21
+ const isHero = !!scene.hero;
22
+
23
+ const accentBarProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
24
+ const accentBarWidth = interpolate(accentBarProgress, [0, 1], [0, isHero ? 120 : 80]);
25
+
26
+ const badgeProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
27
+ const badgeOpacity = interpolate(badgeProgress, [0, 1], [0, 1]);
28
+ const badgeY = interpolate(badgeProgress, [0, 1], [12, 0]);
29
+
30
+ return (
31
+ <AbsoluteFill
32
+ style={{
33
+ background: theme.bg,
34
+ display: "flex",
35
+ flexDirection: "column",
36
+ alignItems: "center",
37
+ justifyContent: "center",
38
+ padding: isHero ? "0 80px" : "0 120px",
39
+ gap: isHero ? 40 : 32,
40
+ }}
41
+ >
42
+ {isAnnouncement ? (
43
+ <div
44
+ style={{
45
+ opacity: badgeOpacity,
46
+ transform: `translateY(${badgeY}px)`,
47
+ background: theme.accentMuted,
48
+ border: `1.5px solid ${theme.accent}`,
49
+ borderRadius: 999,
50
+ padding: "6px 20px",
51
+ fontFamily: theme.fontMono,
52
+ fontSize: 18,
53
+ fontWeight: 600,
54
+ color: theme.accent,
55
+ letterSpacing: "0.02em",
56
+ }}
57
+ >
58
+ {project.name} {project.version}
59
+ </div>
60
+ ) : (
61
+ <div
62
+ style={{
63
+ width: accentBarWidth,
64
+ height: isHero ? 6 : 4,
65
+ background: theme.accent,
66
+ borderRadius: 3,
67
+ }}
68
+ />
69
+ )}
70
+
71
+ {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>
93
+ ) : (
94
+ <>
95
+ <Label text={scene.headline} theme={theme} size="xl" startFrame={4} />
96
+ {scene.subtext && (
97
+ <Label text={scene.subtext} theme={theme} size="md" muted startFrame={16} />
98
+ )}
99
+ </>
100
+ )}
101
+
102
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={40} />}
103
+ </AbsoluteFill>
104
+ );
105
+ };
@@ -0,0 +1,136 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { SplitScene as SplitBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Caption } from "../primitives/Caption";
6
+
7
+ interface Props {
8
+ scene: SplitBrief;
9
+ theme: Theme;
10
+ }
11
+
12
+ export const SplitComparison: React.FC<Props> = ({ scene, theme }) => {
13
+ const frame = useCurrentFrame();
14
+ const { fps } = useVideoConfig();
15
+
16
+ const leftProgress = spring({ frame, fps, config: theme.motion });
17
+ const rightProgress = spring({ frame: frame - 12, fps, config: theme.motion });
18
+ const dividerProgress = spring({ frame: frame - 6, fps, config: theme.motion });
19
+
20
+ const leftOpacity = interpolate(Math.max(0, leftProgress), [0, 1], [0, 1]);
21
+ const leftX = interpolate(Math.max(0, leftProgress), [0, 1], [-60, 0]);
22
+ const rightOpacity = interpolate(Math.max(0, rightProgress), [0, 1], [0, 1]);
23
+ const rightX = interpolate(Math.max(0, rightProgress), [0, 1], [60, 0]);
24
+ const dividerOpacity = interpolate(Math.max(0, dividerProgress), [0, 1], [0, 1]);
25
+
26
+ return (
27
+ <AbsoluteFill
28
+ style={{
29
+ background: theme.bg,
30
+ display: "flex",
31
+ alignItems: "center",
32
+ justifyContent: "center",
33
+ padding: "60px 80px",
34
+ }}
35
+ >
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 */}
80
+ <div
81
+ style={{
82
+ width: 2,
83
+ alignSelf: "stretch",
84
+ background: theme.border,
85
+ margin: "36px 32px 0",
86
+ opacity: dividerOpacity,
87
+ }}
88
+ />
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>
131
+ </div>
132
+
133
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} />}
134
+ </AbsoluteFill>
135
+ );
136
+ };
@@ -0,0 +1,110 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { StatCalloutScene as StatCalloutBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Label } from "../primitives/Label";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: StatCalloutBrief;
10
+ theme: Theme;
11
+ }
12
+
13
+ const HEADLINE_FRAMES = 20;
14
+ const FRAMES_PER_STAT = 35;
15
+ const SNAP_OFFSET = 8;
16
+
17
+ export const StatCallout: React.FC<Props> = ({ scene, theme }) => {
18
+ const frame = useCurrentFrame();
19
+ const { fps } = useVideoConfig();
20
+
21
+ const captionStart = HEADLINE_FRAMES + scene.stats.length * FRAMES_PER_STAT + 20;
22
+
23
+ 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
+ >
35
+ {scene.headline && (
36
+ <Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
37
+ )}
38
+
39
+ <div
40
+ style={{
41
+ display: "flex",
42
+ gap: 80,
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ }}
46
+ >
47
+ {scene.stats.map((stat, i) => {
48
+ const statStart = HEADLINE_FRAMES + i * FRAMES_PER_STAT;
49
+ const progress = spring({
50
+ frame: frame - statStart + SNAP_OFFSET,
51
+ fps,
52
+ config: theme.motion,
53
+ });
54
+ const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
55
+ const scale = interpolate(Math.max(0, progress), [0, 1], [0.4, 1]);
56
+
57
+ const labelStart = statStart + 12;
58
+ const labelProgress = spring({
59
+ frame: frame - labelStart + SNAP_OFFSET,
60
+ fps,
61
+ config: theme.motion,
62
+ });
63
+ const labelOpacity = interpolate(Math.max(0, labelProgress), [0, 1], [0, 1]);
64
+
65
+ return (
66
+ <div
67
+ key={i}
68
+ style={{
69
+ display: "flex",
70
+ flexDirection: "column",
71
+ alignItems: "center",
72
+ gap: 16,
73
+ opacity,
74
+ transform: `scale(${scale})`,
75
+ }}
76
+ >
77
+ <div
78
+ style={{
79
+ fontFamily: theme.fontSans,
80
+ fontSize: 96,
81
+ fontWeight: 800,
82
+ color: theme.accent,
83
+ lineHeight: 1,
84
+ letterSpacing: "-0.04em",
85
+ }}
86
+ >
87
+ {stat.value}
88
+ </div>
89
+ <div
90
+ style={{
91
+ fontFamily: theme.fontSans,
92
+ fontSize: 22,
93
+ fontWeight: 500,
94
+ color: theme.textMuted,
95
+ opacity: labelOpacity,
96
+ letterSpacing: "0.04em",
97
+ textTransform: "uppercase",
98
+ }}
99
+ >
100
+ {stat.label}
101
+ </div>
102
+ </div>
103
+ );
104
+ })}
105
+ </div>
106
+
107
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
108
+ </AbsoluteFill>
109
+ );
110
+ };
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import { AbsoluteFill } from "remotion";
3
+ import { TerminalScene as TerminalBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Terminal } from "../primitives/Terminal";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: TerminalBrief;
10
+ theme: Theme;
11
+ }
12
+
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;
24
+
25
+ return (
26
+ <AbsoluteFill
27
+ style={{
28
+ background: theme.bg,
29
+ display: "flex",
30
+ alignItems: "center",
31
+ justifyContent: "center",
32
+ padding: "60px 100px",
33
+ }}
34
+ >
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} />}
39
+ </AbsoluteFill>
40
+ );
41
+ };
@@ -0,0 +1,55 @@
1
+ import { FeatureListScene, FileTreeScene, HotkeyScene, OSWindowScene, Scene, StatCalloutScene } from "./brief";
2
+
3
+ // Hard ceiling for the teaser cut: 10s at 30fps. Renders over the limit
4
+ // succeed, but the CLI prints a prominent warning.
5
+ export const TEASER_MAX_FRAMES = 300;
6
+
7
+ // Frames appended after each scene's content animation settles.
8
+ // Gives content time to breathe and provides room for the fade-out.
9
+ export const SCENE_TAIL = 30;
10
+
11
+ export const SCENE_DURATION_MAP: Record<Scene["type"], number> = {
12
+ problem: 120,
13
+ "code-reveal": 165,
14
+ terminal: 150,
15
+ "data-flow": 200,
16
+ cta: 120,
17
+ browser: 150,
18
+ split: 165,
19
+ "feature-list": 180,
20
+ "stat-callout": 180,
21
+ "file-tree": 200,
22
+ mobile: 150,
23
+ "os-window": 180,
24
+ hotkey: 120,
25
+ };
26
+
27
+ export function sceneDuration(scene: Scene): number {
28
+ let content: number;
29
+ if (scene.type === "feature-list") {
30
+ const s = scene as FeatureListScene;
31
+ content = 20 + s.items.length * 25 + 60;
32
+ } else if (scene.type === "stat-callout") {
33
+ const s = scene as StatCalloutScene;
34
+ content = 20 + s.stats.length * 35 + 60;
35
+ } else if (scene.type === "file-tree") {
36
+ const s = scene as FileTreeScene;
37
+ content = 20 + s.entries.length * 20 + 60;
38
+ } else if (scene.type === "os-window") {
39
+ const s = scene as OSWindowScene;
40
+ const searchDuration = s.searchQuery ? s.searchQuery.length * 3 + 10 : 0;
41
+ content = 20 + searchDuration + (s.items?.length ?? 0) * 20 + 60;
42
+ } else if (scene.type === "hotkey") {
43
+ const s = scene as HotkeyScene;
44
+ content = 20 + s.keys.length * 20 + 70;
45
+ } else {
46
+ content = SCENE_DURATION_MAP[scene.type];
47
+ }
48
+ return content + SCENE_TAIL;
49
+ }
50
+
51
+ // Duration is computed per cut: callers pass the scene list of the chosen cut
52
+ // (cuts.main, cuts.vertical, or cuts.teaser).
53
+ export function calcTotalDuration(scenes: Scene[]): number {
54
+ return scenes.reduce((sum, scene) => sum + sceneDuration(scene), 0);
55
+ }
@@ -0,0 +1,49 @@
1
+ import { loadFont as loadInter, fontFamily as inter } from "@remotion/google-fonts/Inter";
2
+ import { loadFont as loadSpaceGrotesk, fontFamily as spaceGrotesk } from "@remotion/google-fonts/SpaceGrotesk";
3
+ import { loadFont as loadDMSans, fontFamily as dmSans } from "@remotion/google-fonts/DMSans";
4
+ import { loadFont as loadSyne, fontFamily as syne } from "@remotion/google-fonts/Syne";
5
+ import { loadFont as loadPlusJakartaSans, fontFamily as plusJakartaSans } from "@remotion/google-fonts/PlusJakartaSans";
6
+ import { loadFont as loadNunito, fontFamily as nunito } from "@remotion/google-fonts/Nunito";
7
+ import { loadFont as loadIBMPlexSans, fontFamily as ibmPlexSans } from "@remotion/google-fonts/IBMPlexSans";
8
+ import { loadFont as loadJetBrainsMono, fontFamily as jetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
9
+ import { loadFont as loadSpaceMono, fontFamily as spaceMono } from "@remotion/google-fonts/SpaceMono";
10
+
11
+ loadInter();
12
+ loadSpaceGrotesk();
13
+ loadDMSans();
14
+ loadSyne();
15
+ loadPlusJakartaSans();
16
+ loadNunito();
17
+ loadIBMPlexSans();
18
+ loadJetBrainsMono();
19
+ loadSpaceMono();
20
+
21
+ export const SANS_FONTS: Record<string, string> = {
22
+ inter,
23
+ "space-grotesk": spaceGrotesk,
24
+ "dm-sans": dmSans,
25
+ syne,
26
+ "plus-jakarta-sans": plusJakartaSans,
27
+ nunito,
28
+ "ibm-plex-sans": ibmPlexSans,
29
+ };
30
+
31
+ export const MONO_FONTS: Record<string, string> = {
32
+ "jetbrains-mono": jetBrainsMono,
33
+ "space-mono": spaceMono,
34
+ };
35
+
36
+ export const DEFAULT_SANS = inter;
37
+ export const DEFAULT_MONO = jetBrainsMono;
38
+
39
+ export function resolveSansFont(name?: string): string {
40
+ if (!name) return DEFAULT_SANS;
41
+ const key = name.toLowerCase().replace(/\s+/g, "-");
42
+ return SANS_FONTS[key] ?? DEFAULT_SANS;
43
+ }
44
+
45
+ export function resolveMonoFont(name?: string): string {
46
+ if (!name) return DEFAULT_MONO;
47
+ const key = name.toLowerCase().replace(/\s+/g, "-");
48
+ return MONO_FONTS[key] ?? DEFAULT_MONO;
49
+ }