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,10 +1,11 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
- import chroma from "chroma-js";
4
- import { CTAScene as CTABrief, ProjectMeta } from "../../brief";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
+ import { CTAScene as CTABrief, ProjectMeta, showWatermark } from "../../brief";
5
4
  import { Theme } from "../../theme";
6
5
  import { PlatformPreset, typeScale } from "../../platforms";
7
- import { Caption } from "../primitives/Caption";
6
+ import { Stage } from "../primitives/Stage";
7
+ import { Terminal } from "../primitives/Terminal";
8
+ import { Loop } from "../primitives/Loop";
8
9
 
9
10
  interface Props {
10
11
  scene: CTABrief;
@@ -12,16 +13,15 @@ interface Props {
12
13
  project: ProjectMeta;
13
14
  platform?: PlatformPreset;
14
15
  bottomInset?: number;
15
- /** gif output: use a flat fill instead of gradients that bloat gif size. */
16
- lite?: boolean;
17
16
  }
18
17
 
19
18
  const SNAP_OFFSET = 8;
20
19
 
21
- export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomInset = 0, lite = false }) => {
20
+ export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomInset = 0 }) => {
22
21
  const frame = useCurrentFrame();
23
22
  const { fps } = useVideoConfig();
24
23
  const scale = platform ? typeScale(platform) : 1.0;
24
+ const isGif = platform?.output.codec === "gif";
25
25
 
26
26
  const logoProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
27
27
  const titleProgress = spring({ frame: frame + SNAP_OFFSET - (project.logo ? 16 : 0), fps, config: theme.motion });
@@ -31,101 +31,66 @@ export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomIn
31
31
  const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
32
32
  const titleY = interpolate(titleProgress, [0, 1], [24, 0]);
33
33
  const cmdOpacity = interpolate(cmdProgress, [0, 1], [0, 1]);
34
- const cmdScale = interpolate(cmdProgress, [0, 1], [0.92, 1]);
35
- const urlFade = interpolate(urlProgress, [0, 1], [0, 0.7]);
34
+ const cmdY = interpolate(cmdProgress, [0, 1], [18, 0]);
35
+ const urlFade = interpolate(urlProgress, [0, 1], [0, 0.65]);
36
36
 
37
37
  const isAnnouncement = project.mode === "announcement";
38
38
  const displayName = isAnnouncement && project.version
39
39
  ? `${project.name} ${project.version}`
40
40
  : project.name;
41
41
 
42
- // Contrast-aware text on the brand field: white on saturated/dark accents,
43
- // near-black on light ones confident lockup instead of dull inverse grey.
44
- const onAccent = chroma(theme.accent).luminance() > 0.5 ? "#15151c" : "#ffffff";
42
+ // When the command starts typing inside the terminal: a beat after the title
43
+ // settles, so the eye lands on the headline first.
44
+ const termStart = project.logo ? 26 : 16;
45
45
 
46
46
  return (
47
- <AbsoluteFill
48
- style={{
49
- // Graded brand end-card: a lit radial instead of a flat fill, so the
50
- // CTA reads as produced as the rest of the film, not a plain slate. The
51
- // gif path uses a flat fill — a full-frame gradient explodes gif size.
52
- background: lite
53
- ? theme.accent
54
- : `radial-gradient(125% 95% at 50% 32%, color-mix(in srgb, ${theme.accent}, #fff 12%) 0%, ${theme.accent} 46%, color-mix(in srgb, ${theme.accent}, #000 24%) 100%)`,
55
- display: "flex",
56
- flexDirection: "column",
57
- alignItems: "center",
58
- justifyContent: "center",
59
- gap: 32,
60
- padding: "0 120px",
61
- }}
62
- >
63
- {/* Edge vignette spotlights the lockup so the field doesn't read as empty.
64
- Skipped on the gif path to keep the frame flat and compressible. */}
65
- {!lite && (
66
- <AbsoluteFill
67
- style={{
68
- background: "radial-gradient(70% 70% at 50% 50%, transparent 40%, rgba(0,0,0,0.28) 100%)",
69
- pointerEvents: "none",
70
- }}
71
- />
72
- )}
73
-
47
+ // Stay on the film's continuous atmosphere stage instead of flooding the
48
+ // frame with a flat brand field — the end-card belongs to the same shot as
49
+ // the rest of the reel. Stage's transparent root lets the Atmosphere show.
50
+ <Stage theme={theme} gap={40} caption={scene.caption} captionStart={60} bottomInset={bottomInset}>
74
51
  {project.logo && (
75
- <div
76
- style={{
77
- opacity: interpolate(logoProgress, [0, 1], [0, 1]),
78
- transform: `scale(${interpolate(logoProgress, [0, 1], [0.85, 1])})`,
79
- background: "#ffffff",
80
- borderRadius: 20,
81
- padding: 16,
82
- boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
83
- }}
84
- >
85
- <Img
86
- src={staticFile(project.logo)}
87
- style={{ height: 80, width: "auto", objectFit: "contain", display: "block" }}
88
- />
89
- </div>
52
+ // A barely-there breathe keeps the end-card's hero alive while it holds,
53
+ // after the entrance spring has settled. Off for gif (defeats compression).
54
+ <Loop property="scale" amplitude={0.012} period={4} disabled={isGif}>
55
+ <div
56
+ style={{
57
+ opacity: interpolate(logoProgress, [0, 1], [0, 1]),
58
+ scale: String(interpolate(logoProgress, [0, 1], [0.85, 1])),
59
+ background: "#ffffff",
60
+ borderRadius: 20,
61
+ padding: 16,
62
+ boxShadow: "0 12px 40px rgba(0,0,0,0.35)",
63
+ }}
64
+ >
65
+ <Img
66
+ src={staticFile(project.logo)}
67
+ style={{ height: 80, width: "auto", objectFit: "contain", display: "block" }}
68
+ />
69
+ </div>
70
+ </Loop>
90
71
  )}
91
72
 
92
73
  <div
93
74
  style={{
94
75
  opacity: titleOpacity,
95
- transform: `translateY(${titleY}px)`,
76
+ translate: `0 ${titleY}px`,
96
77
  fontFamily: theme.fontSans,
97
78
  fontSize: 60 * scale,
98
79
  fontWeight: 700,
99
- color: onAccent,
80
+ color: theme.text,
100
81
  letterSpacing: "-0.03em",
101
82
  textAlign: "center",
102
- textShadow: "0 4px 30px rgba(0,0,0,0.25)",
103
83
  }}
104
84
  >
105
85
  {isAnnouncement ? "" : "Get started with "}
106
- <span style={{ color: onAccent, fontWeight: 800 }}>{displayName}</span>
86
+ <span style={{ color: theme.accent, fontWeight: 800 }}>{displayName}</span>
107
87
  {isAnnouncement ? " is here." : ""}
108
88
  </div>
109
89
 
110
- <div
111
- style={{
112
- opacity: cmdOpacity,
113
- transform: `scale(${cmdScale})`,
114
- background: theme.bg,
115
- border: `1.5px solid rgba(0,0,0,0.12)`,
116
- borderRadius: 14,
117
- padding: "20px 44px",
118
- fontFamily: theme.fontMono,
119
- fontSize: 28,
120
- color: theme.text,
121
- display: "flex",
122
- alignItems: "center",
123
- gap: 16,
124
- boxShadow: "0 22px 60px rgba(0,0,0,0.30)",
125
- }}
126
- >
127
- <span style={{ color: theme.accent }}>$</span>
128
- {scene.installCommand}
90
+ {/* The install command in the film's own terminal, typed out — consistent
91
+ with the reel's other terminal scenes instead of a web-style pill. */}
92
+ <div style={{ opacity: cmdOpacity, translate: `0 ${cmdY}px` }}>
93
+ <Terminal lines={[{ text: scene.installCommand }]} theme={theme} startFrame={termStart} />
129
94
  </div>
130
95
 
131
96
  {scene.repoUrl && (
@@ -134,16 +99,14 @@ export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomIn
134
99
  opacity: urlFade,
135
100
  fontFamily: theme.fontMono,
136
101
  fontSize: 20,
137
- color: onAccent,
102
+ color: theme.textMuted,
138
103
  }}
139
104
  >
140
105
  {scene.repoUrl}
141
106
  </div>
142
107
  )}
143
108
 
144
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} bottomInset={bottomInset} />}
145
-
146
- {project.watermark !== false && (
109
+ {showWatermark(project.watermark) && (
147
110
  <div
148
111
  style={{
149
112
  position: "absolute",
@@ -159,6 +122,6 @@ export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomIn
159
122
  made with reelme
160
123
  </div>
161
124
  )}
162
- </AbsoluteFill>
125
+ </Stage>
163
126
  );
164
127
  };
@@ -8,6 +8,7 @@ import { Caption } from "../primitives/Caption";
8
8
  interface Props {
9
9
  scene: ClipScene;
10
10
  theme: Theme;
11
+ bottomInset?: number;
11
12
  }
12
13
 
13
14
  const isGif = (src: string) => src.endsWith(".gif");
@@ -22,12 +23,17 @@ const MediaElement: React.FC<{ scene: ClipScene }> = ({ scene }) => {
22
23
  />
23
24
  );
24
25
  }
26
+ // Stop the source at the requested trim point so the scene's tail doesn't keep
27
+ // playing past what the brief asked for (F23).
28
+ const endAt =
29
+ scene.durationInFrames !== undefined ? (scene.startFrom ?? 0) + scene.durationInFrames : undefined;
25
30
  return (
26
31
  <OffthreadVideo
27
32
  src={staticFile(scene.src)}
28
33
  style={{ width: "100%", height: "100%", objectFit: "cover" }}
29
34
  muted
30
35
  startFrom={scene.startFrom}
36
+ endAt={endAt}
31
37
  />
32
38
  );
33
39
  };
@@ -79,7 +85,7 @@ const MobileChrome: React.FC<{ children: React.ReactNode; theme: Theme }> = ({ c
79
85
  </div>
80
86
  );
81
87
 
82
- export const Clip: React.FC<Props> = ({ scene, theme }) => {
88
+ export const Clip: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
83
89
  const renderMedia = () => <MediaElement scene={scene} />;
84
90
 
85
91
  if (scene.frame === "browser") {
@@ -94,7 +100,7 @@ export const Clip: React.FC<Props> = ({ scene, theme }) => {
94
100
  }}
95
101
  >
96
102
  <BrowserChrome theme={theme}>{renderMedia()}</BrowserChrome>
97
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} />}
103
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} bottomInset={bottomInset} />}
98
104
  </AbsoluteFill>
99
105
  );
100
106
  }
@@ -111,7 +117,7 @@ export const Clip: React.FC<Props> = ({ scene, theme }) => {
111
117
  }}
112
118
  >
113
119
  <MobileChrome theme={theme}>{renderMedia()}</MobileChrome>
114
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} />}
120
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} bottomInset={bottomInset} />}
115
121
  </AbsoluteFill>
116
122
  );
117
123
  }
@@ -4,6 +4,7 @@ import { CodeRevealScene as CodeRevealBrief } from "../../brief";
4
4
  import { Theme } from "../../theme";
5
5
  import { CodeBlock } from "../primitives/CodeBlock";
6
6
  import { Caption } from "../primitives/Caption";
7
+ import { CODE_FRAMES_PER_LINE, CODE_START, codeRevealCaptionStart } from "../../timing";
7
8
 
8
9
  interface Props {
9
10
  scene: CodeRevealBrief;
@@ -12,9 +13,9 @@ interface Props {
12
13
  }
13
14
 
14
15
  export const CodeReveal: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
15
- const lines = scene.code.split("\n");
16
- // Caption appears after all lines are revealed: startFrame=14, framesPerLine=9, plus 20-frame buffer
17
- const captionStart = 14 + lines.length * 9 + 20;
16
+ // Timing is shared with the duration oracle so the scene lasts long enough for
17
+ // every line to reveal and the caption to appear (see timing.ts).
18
+ const captionStart = codeRevealCaptionStart(scene);
18
19
 
19
20
  return (
20
21
  <AbsoluteFill
@@ -32,8 +33,8 @@ export const CodeReveal: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) =
32
33
  language={scene.language}
33
34
  highlightLine={scene.highlightLine}
34
35
  theme={theme}
35
- startFrame={14}
36
- framesPerLine={9}
36
+ startFrame={CODE_START}
37
+ framesPerLine={CODE_FRAMES_PER_LINE}
37
38
  />
38
39
  </div>
39
40
  {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
@@ -88,7 +88,7 @@ export const DataFlow: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) =>
88
88
  fontWeight: 600,
89
89
  color: theme.text,
90
90
  opacity,
91
- transform: `scale(${scale})`,
91
+ scale: String(scale),
92
92
  boxShadow: `0 4px 24px rgba(0,0,0,0.3)`,
93
93
  }}
94
94
  >
@@ -1,11 +1,11 @@
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 { FeatureListScene as FeatureListBrief } from "../../brief";
4
4
  import { Theme } from "../../theme";
5
5
  import { PlatformPreset, typeScale } from "../../platforms";
6
6
  import { Label } from "../primitives/Label";
7
- import { Caption } from "../primitives/Caption";
8
- import { Icon } from "../primitives/Icon";
7
+ import { Stage } from "../primitives/Stage";
8
+ import { Icon, hasIcon } from "../primitives/Icon";
9
9
 
10
10
  interface Props {
11
11
  scene: FeatureListBrief;
@@ -22,94 +22,91 @@ export const FeatureList: React.FC<Props> = ({ scene, theme, platform, bottomIns
22
22
  const { fps } = useVideoConfig();
23
23
  const scale = platform ? typeScale(platform) : 1.0;
24
24
 
25
- const captionStart = HEADLINE_FRAMES + scene.items.length * FRAMES_PER_ITEM + 20;
25
+ const items = scene.items ?? [];
26
+ const captionStart = HEADLINE_FRAMES + items.length * FRAMES_PER_ITEM + 20;
26
27
  const left = scene.align === "left";
27
28
 
28
29
  return (
29
- <AbsoluteFill
30
- style={{
31
- background: "transparent",
32
- display: "flex",
33
- flexDirection: "column",
34
- alignItems: left ? "flex-start" : "center",
35
- justifyContent: "center",
36
- padding: left ? "0 0 0 140px" : "0 200px",
37
- }}
38
- >
39
- {scene.headline && (
40
- <div style={{ marginBottom: 48 }}>
41
- <Label text={scene.headline} theme={theme} size="lg" align={left ? "left" : "center"} startFrame={0} />
42
- </div>
43
- )}
30
+ <Stage theme={theme} caption={scene.caption} captionStart={captionStart} bottomInset={bottomInset}>
31
+ {/* The list is a single block, centered in the frame. Even a short
32
+ left-aligned list then sits in symmetric margins (composed negative
33
+ space) instead of being anchored against one edge with a void opposite.
34
+ Items left-align within the block for a clean reading column. */}
35
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
36
+ {scene.headline && (
37
+ <div style={{ marginBottom: 52, alignSelf: left ? "flex-start" : "center" }}>
38
+ <Label text={scene.headline} theme={theme} size="lg" align={left ? "left" : "center"} startFrame={0} />
39
+ </div>
40
+ )}
44
41
 
45
- <div style={{ display: "flex", flexDirection: "column", gap: 28, width: left ? "auto" : "100%", maxWidth: left ? 1200 : undefined }}>
46
- {scene.items.map((item, i) => {
47
- const text = typeof item === "string" ? item : item.text;
48
- const icon = typeof item === "string" ? undefined : item.icon;
42
+ <div style={{ display: "flex", flexDirection: "column", gap: 34 }}>
43
+ {items.map((item, i) => {
44
+ const text = typeof item === "string" ? item : item.text;
45
+ const icon = typeof item === "string" ? undefined : item.icon;
46
+ const showIcon = icon !== undefined && hasIcon(icon);
49
47
 
50
- const itemStart = HEADLINE_FRAMES + i * FRAMES_PER_ITEM;
51
- const progress = spring({ frame: frame - itemStart, fps, config: theme.motion });
52
- const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
53
- const translateX = interpolate(Math.max(0, progress), [0, 1], [-24, 0]);
48
+ const itemStart = HEADLINE_FRAMES + i * FRAMES_PER_ITEM;
49
+ const progress = spring({ frame: frame - itemStart, fps, config: theme.motion });
50
+ const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
51
+ const translateX = interpolate(Math.max(0, progress), [0, 1], [-24, 0]);
54
52
 
55
- return (
56
- <div
57
- key={i}
58
- style={{
59
- display: "flex",
60
- alignItems: "center",
61
- gap: 24,
62
- opacity,
63
- transform: `translateX(${translateX}px)`,
64
- }}
65
- >
53
+ return (
66
54
  <div
55
+ key={i}
67
56
  style={{
68
- width: 36,
69
- height: 36,
70
- borderRadius: "50%",
71
- background: theme.accentMuted,
72
- border: `1.5px solid ${theme.accent}`,
73
57
  display: "flex",
74
58
  alignItems: "center",
75
- justifyContent: "center",
76
- flexShrink: 0,
59
+ gap: 26,
60
+ opacity,
61
+ translate: `${translateX}px 0`,
77
62
  }}
78
63
  >
79
- {icon ? (
80
- <Icon name={icon} size={18} color={theme.accent} />
81
- ) : (
82
- <span
83
- style={{
84
- fontFamily: theme.fontMono,
85
- fontSize: 14,
86
- fontWeight: 700,
87
- color: theme.accent,
88
- lineHeight: 1,
89
- }}
90
- >
91
- {i + 1}
92
- </span>
93
- )}
64
+ {/* A bold accent marker carries the weight — no faint bordered
65
+ chip (a web-UI badge pattern that reads as filler on screen). */}
66
+ <div
67
+ style={{
68
+ width: 40,
69
+ display: "flex",
70
+ alignItems: "center",
71
+ justifyContent: "center",
72
+ flexShrink: 0,
73
+ }}
74
+ >
75
+ {showIcon ? (
76
+ <Icon name={icon} size={34} color={theme.accent} />
77
+ ) : (
78
+ <span
79
+ style={{
80
+ fontFamily: theme.fontSans,
81
+ fontSize: 30,
82
+ fontWeight: 800,
83
+ color: theme.accent,
84
+ lineHeight: 1,
85
+ letterSpacing: "-0.03em",
86
+ }}
87
+ >
88
+ {i + 1}
89
+ </span>
90
+ )}
91
+ </div>
92
+ <span
93
+ style={{
94
+ fontFamily: theme.fontSans,
95
+ fontSize: 40 * scale,
96
+ fontWeight: 600,
97
+ color: theme.text,
98
+ lineHeight: 1.3,
99
+ letterSpacing: "-0.015em",
100
+ }}
101
+ >
102
+ {text}
103
+ </span>
94
104
  </div>
95
- <span
96
- style={{
97
- fontFamily: theme.fontSans,
98
- fontSize: 28 * scale,
99
- fontWeight: 500,
100
- color: theme.text,
101
- lineHeight: 1.4,
102
- letterSpacing: "-0.01em",
103
- }}
104
- >
105
- {text}
106
- </span>
107
- </div>
108
- );
109
- })}
105
+ );
106
+ })}
107
+ </div>
110
108
  </div>
111
109
 
112
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
113
- </AbsoluteFill>
110
+ </Stage>
114
111
  );
115
112
  };
@@ -93,7 +93,7 @@ export const FileTree: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) =>
93
93
  alignItems: "center",
94
94
  gap: 10,
95
95
  opacity,
96
- transform: `translateX(${translateX}px)`,
96
+ translate: `${translateX}px 0`,
97
97
  paddingTop: isHighlighted ? 6 : 4,
98
98
  paddingBottom: isHighlighted ? 6 : 4,
99
99
  paddingLeft: depth * INDENT + 8,
@@ -18,7 +18,8 @@ export const Hotkey: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
18
18
  const frame = useCurrentFrame();
19
19
  const { fps } = useVideoConfig();
20
20
 
21
- const actionStart = scene.keys.length * FRAMES_PER_KEY + ACTION_DELAY;
21
+ const keys = scene.keys ?? [];
22
+ const actionStart = keys.length * FRAMES_PER_KEY + ACTION_DELAY;
22
23
  const captionStart = actionStart + 50;
23
24
 
24
25
  const actionProgress = spring({ frame: frame - actionStart, fps, config: theme.motion });
@@ -37,7 +38,7 @@ export const Hotkey: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
37
38
  }}
38
39
  >
39
40
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
40
- {scene.keys.map((key, i) => (
41
+ {keys.map((key, i) => (
41
42
  <React.Fragment key={i}>
42
43
  {i > 0 && (
43
44
  <span
@@ -60,7 +61,7 @@ export const Hotkey: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
60
61
  <div
61
62
  style={{
62
63
  opacity: actionOpacity,
63
- transform: `translateY(${actionY}px)`,
64
+ translate: `0 ${actionY}px`,
64
65
  fontFamily: theme.fontSans,
65
66
  fontSize: 28,
66
67
  fontWeight: 400,