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
@@ -0,0 +1,62 @@
1
+ import React from "react";
2
+ import { AbsoluteFill } from "remotion";
3
+ import { Theme } from "../../theme";
4
+ import { Caption } from "./Caption";
5
+
6
+ // Standard horizontal safe inset for scene content — keeps text off the frame
7
+ // edges (the video-layout rules want key content ~120px in on a 1080–1920 frame).
8
+ export const STAGE_INSET = 120;
9
+
10
+ interface StageProps {
11
+ theme: Theme;
12
+ /** Stacking direction of the content. */
13
+ direction?: "row" | "column";
14
+ justify?: React.CSSProperties["justifyContent"];
15
+ align?: React.CSSProperties["alignItems"];
16
+ gap?: number;
17
+ /** Horizontal padding; defaults to the standard safe inset on both sides. */
18
+ padding?: React.CSSProperties["padding"];
19
+ /** Optional caption rendered as the film's bottom pill. */
20
+ caption?: string;
21
+ captionStart?: number;
22
+ bottomInset?: number;
23
+ children: React.ReactNode;
24
+ }
25
+
26
+ /**
27
+ * The shared scene root. Every scene is shot on the one continuous Atmosphere, so
28
+ * the root is ALWAYS transparent — never paint an opaque full-frame fill here or
29
+ * the stage behind it disappears and the reel stops reading as one film. Content
30
+ * is centered by default so leftover space stays symmetric (composed negative
31
+ * space) instead of being dumped against one edge as a void. For a hero + aside
32
+ * composition use `direction="row"` with `justify="space-between"`.
33
+ */
34
+ export const Stage: React.FC<StageProps> = ({
35
+ theme,
36
+ direction = "column",
37
+ justify = "center",
38
+ align = "center",
39
+ gap,
40
+ padding,
41
+ caption,
42
+ captionStart = 50,
43
+ bottomInset = 0,
44
+ children,
45
+ }) => {
46
+ return (
47
+ <AbsoluteFill
48
+ style={{
49
+ background: "transparent",
50
+ display: "flex",
51
+ flexDirection: direction,
52
+ alignItems: align,
53
+ justifyContent: justify,
54
+ gap,
55
+ padding: padding ?? `0 ${STAGE_INSET}px`,
56
+ }}
57
+ >
58
+ {children}
59
+ {caption && <Caption text={caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
60
+ </AbsoluteFill>
61
+ );
62
+ };
@@ -25,6 +25,16 @@ export const Terminal: React.FC<TerminalProps> = ({
25
25
  const frame = useCurrentFrame();
26
26
  const elapsed = frame - startFrame;
27
27
 
28
+ // Size the window to its widest physical line so a short command doesn't sit
29
+ // in a huge empty box. Width is fixed from the full text (not the partial
30
+ // reveal) so it never reflows while typing. Inputs reserve room for "$ ".
31
+ const maxChars = lines.reduce((max, line) => {
32
+ const prefix = line.isOutput ? 0 : 2;
33
+ const widest = line.text.split("\n").reduce((m, l) => Math.max(m, l.length), 0);
34
+ return Math.max(max, widest + prefix);
35
+ }, 0);
36
+ const cols = Math.min(Math.max(maxChars, 18), 78);
37
+
28
38
  const visibleLines: Array<{ text: string; isOutput: boolean; partialText: string }> = [];
29
39
 
30
40
  let cursor = 0;
@@ -59,6 +69,7 @@ export const Terminal: React.FC<TerminalProps> = ({
59
69
  fontFamily: theme.fontMono,
60
70
  fontSize: 22,
61
71
  boxShadow: "0 8px 48px rgba(0,0,0,0.6)",
72
+ width: `min(calc(${cols}ch + 48px), 100%)`,
62
73
  }}
63
74
  >
64
75
  <div
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+ import { AbsoluteFill } from "remotion";
3
+ import { BenchmarkScene as BenchmarkBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { benchmarkFractions } from "../../benchmark";
6
+ import { Bar } from "../primitives/Bar";
7
+ import { Label } from "../primitives/Label";
8
+ import { Caption } from "../primitives/Caption";
9
+
10
+ interface Props {
11
+ scene: BenchmarkBrief;
12
+ theme: Theme;
13
+ bottomInset?: number;
14
+ }
15
+
16
+ const HEADLINE_FRAMES = 20;
17
+ const FRAMES_PER_BAR = 18;
18
+
19
+ export const Benchmark: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
20
+ const fractions = benchmarkFractions(
21
+ scene.bars.map((b) => b.value),
22
+ scene.lowerIsBetter
23
+ );
24
+ const captionStart = HEADLINE_FRAMES + scene.bars.length * FRAMES_PER_BAR + 30;
25
+
26
+ return (
27
+ <AbsoluteFill
28
+ style={{
29
+ background: "transparent",
30
+ display: "flex",
31
+ flexDirection: "column",
32
+ alignItems: "center",
33
+ justifyContent: "center",
34
+ padding: "0 120px",
35
+ gap: 40,
36
+ }}
37
+ >
38
+ {scene.headline && (
39
+ <Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
40
+ )}
41
+
42
+ {scene.metric && (
43
+ <div
44
+ style={{
45
+ fontFamily: theme.fontSans,
46
+ fontSize: 24,
47
+ color: theme.textMuted,
48
+ marginTop: -16,
49
+ letterSpacing: "0.02em",
50
+ }}
51
+ >
52
+ {scene.metric}
53
+ </div>
54
+ )}
55
+
56
+ <div
57
+ style={{
58
+ display: "flex",
59
+ flexDirection: "column",
60
+ gap: 24,
61
+ width: "100%",
62
+ maxWidth: 1200,
63
+ }}
64
+ >
65
+ {scene.bars.map((bar, i) => (
66
+ <Bar
67
+ key={i}
68
+ label={bar.label}
69
+ valueText={bar.display ?? String(bar.value)}
70
+ fraction={fractions[i]}
71
+ hero={bar.hero}
72
+ theme={theme}
73
+ startFrame={HEADLINE_FRAMES + i * FRAMES_PER_BAR}
74
+ />
75
+ ))}
76
+ </div>
77
+
78
+ {scene.caption && (
79
+ <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />
80
+ )}
81
+ </AbsoluteFill>
82
+ );
83
+ };
@@ -7,9 +7,10 @@ import { Caption } from "../primitives/Caption";
7
7
  interface Props {
8
8
  scene: BrowserBrief;
9
9
  theme: Theme;
10
+ bottomInset?: number;
10
11
  }
11
12
 
12
- export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
13
+ export const BrowserFrame: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
13
14
  const frame = useCurrentFrame();
14
15
  const { fps } = useVideoConfig();
15
16
 
@@ -26,7 +27,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
26
27
  return (
27
28
  <AbsoluteFill
28
29
  style={{
29
- background: theme.bg,
30
+ background: "transparent",
30
31
  display: "flex",
31
32
  alignItems: "center",
32
33
  justifyContent: "center",
@@ -37,7 +38,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
37
38
  style={{
38
39
  width: "100%",
39
40
  opacity: windowOpacity,
40
- transform: `scale(${windowScale})`,
41
+ scale: String(windowScale),
41
42
  boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
42
43
  borderRadius: 12,
43
44
  overflow: "hidden",
@@ -133,7 +134,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
133
134
  </div>
134
135
  </div>
135
136
 
136
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
137
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} bottomInset={bottomInset} />}
137
138
  </AbsoluteFill>
138
139
  );
139
140
  };
@@ -1,20 +1,27 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
- 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";
4
4
  import { Theme } from "../../theme";
5
- import { Caption } from "../primitives/Caption";
5
+ import { PlatformPreset, typeScale } from "../../platforms";
6
+ import { Stage } from "../primitives/Stage";
7
+ import { Terminal } from "../primitives/Terminal";
8
+ import { Loop } from "../primitives/Loop";
6
9
 
7
10
  interface Props {
8
11
  scene: CTABrief;
9
12
  theme: Theme;
10
13
  project: ProjectMeta;
14
+ platform?: PlatformPreset;
15
+ bottomInset?: number;
11
16
  }
12
17
 
13
18
  const SNAP_OFFSET = 8;
14
19
 
15
- export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
20
+ export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomInset = 0 }) => {
16
21
  const frame = useCurrentFrame();
17
22
  const { fps } = useVideoConfig();
23
+ const scale = platform ? typeScale(platform) : 1.0;
24
+ const isGif = platform?.output.codec === "gif";
18
25
 
19
26
  const logoProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
20
27
  const titleProgress = spring({ frame: frame + SNAP_OFFSET - (project.logo ? 16 : 0), fps, config: theme.motion });
@@ -24,79 +31,66 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
24
31
  const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
25
32
  const titleY = interpolate(titleProgress, [0, 1], [24, 0]);
26
33
  const cmdOpacity = interpolate(cmdProgress, [0, 1], [0, 1]);
27
- const cmdScale = interpolate(cmdProgress, [0, 1], [0.92, 1]);
28
- 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]);
29
36
 
30
37
  const isAnnouncement = project.mode === "announcement";
31
38
  const displayName = isAnnouncement && project.version
32
39
  ? `${project.name} ${project.version}`
33
40
  : project.name;
34
41
 
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
+
35
46
  return (
36
- <AbsoluteFill
37
- style={{
38
- background: theme.accent,
39
- display: "flex",
40
- flexDirection: "column",
41
- alignItems: "center",
42
- justifyContent: "center",
43
- gap: 32,
44
- padding: "0 120px",
45
- }}
46
- >
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}>
47
51
  {project.logo && (
48
- <div
49
- style={{
50
- opacity: interpolate(logoProgress, [0, 1], [0, 1]),
51
- transform: `scale(${interpolate(logoProgress, [0, 1], [0.85, 1])})`,
52
- background: "#ffffff",
53
- borderRadius: 20,
54
- padding: 16,
55
- boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
56
- }}
57
- >
58
- <Img
59
- src={staticFile(project.logo)}
60
- style={{ height: 80, width: "auto", objectFit: "contain", display: "block" }}
61
- />
62
- </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>
63
71
  )}
64
72
 
65
73
  <div
66
74
  style={{
67
75
  opacity: titleOpacity,
68
- transform: `translateY(${titleY}px)`,
76
+ translate: `0 ${titleY}px`,
69
77
  fontFamily: theme.fontSans,
70
- fontSize: 56,
78
+ fontSize: 60 * scale,
71
79
  fontWeight: 700,
72
- color: theme.textInverse,
73
- letterSpacing: "-0.02em",
80
+ color: theme.text,
81
+ letterSpacing: "-0.03em",
74
82
  textAlign: "center",
75
83
  }}
76
84
  >
77
85
  {isAnnouncement ? "" : "Get started with "}
78
- <span style={{ color: theme.textInverse, fontWeight: 800 }}>{displayName}</span>
86
+ <span style={{ color: theme.accent, fontWeight: 800 }}>{displayName}</span>
79
87
  {isAnnouncement ? " is here." : ""}
80
88
  </div>
81
89
 
82
- <div
83
- style={{
84
- opacity: cmdOpacity,
85
- transform: `scale(${cmdScale})`,
86
- background: theme.bg,
87
- border: `1.5px solid rgba(0,0,0,0.12)`,
88
- borderRadius: 12,
89
- padding: "18px 40px",
90
- fontFamily: theme.fontMono,
91
- fontSize: 28,
92
- color: theme.text,
93
- display: "flex",
94
- alignItems: "center",
95
- gap: 16,
96
- }}
97
- >
98
- <span style={{ color: theme.accent }}>$</span>
99
- {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} />
100
94
  </div>
101
95
 
102
96
  {scene.repoUrl && (
@@ -105,14 +99,29 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
105
99
  opacity: urlFade,
106
100
  fontFamily: theme.fontMono,
107
101
  fontSize: 20,
108
- color: theme.textInverse,
102
+ color: theme.textMuted,
109
103
  }}
110
104
  >
111
105
  {scene.repoUrl}
112
106
  </div>
113
107
  )}
114
108
 
115
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
116
- </AbsoluteFill>
109
+ {showWatermark(project.watermark) && (
110
+ <div
111
+ style={{
112
+ position: "absolute",
113
+ bottom: 40 + bottomInset,
114
+ left: 0,
115
+ right: 0,
116
+ textAlign: "center",
117
+ fontFamily: theme.fontMono,
118
+ fontSize: 18,
119
+ color: theme.textMuted,
120
+ }}
121
+ >
122
+ made with reelme
123
+ </div>
124
+ )}
125
+ </Stage>
117
126
  );
118
127
  };
@@ -0,0 +1,131 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, OffthreadVideo, staticFile } from "remotion";
3
+ import { Gif } from "@remotion/gif";
4
+ import { ClipScene } from "../../brief";
5
+ import { Theme } from "../../theme";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: ClipScene;
10
+ theme: Theme;
11
+ bottomInset?: number;
12
+ }
13
+
14
+ const isGif = (src: string) => src.endsWith(".gif");
15
+
16
+ const MediaElement: React.FC<{ scene: ClipScene }> = ({ scene }) => {
17
+ if (isGif(scene.src)) {
18
+ return (
19
+ <Gif
20
+ src={staticFile(scene.src)}
21
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
22
+ fit="cover"
23
+ />
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;
30
+ return (
31
+ <OffthreadVideo
32
+ src={staticFile(scene.src)}
33
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
34
+ muted
35
+ startFrom={scene.startFrom}
36
+ endAt={endAt}
37
+ />
38
+ );
39
+ };
40
+
41
+ const BrowserChrome: React.FC<{ children: React.ReactNode; theme: Theme }> = ({ children, theme }) => (
42
+ <div
43
+ style={{
44
+ width: "100%",
45
+ height: "100%",
46
+ display: "flex",
47
+ flexDirection: "column",
48
+ boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
49
+ borderRadius: 12,
50
+ overflow: "hidden",
51
+ }}
52
+ >
53
+ <div
54
+ style={{
55
+ background: theme.surface,
56
+ padding: "12px 16px",
57
+ display: "flex",
58
+ alignItems: "center",
59
+ gap: 8,
60
+ borderBottom: `1px solid ${theme.border}`,
61
+ flexShrink: 0,
62
+ }}
63
+ >
64
+ {(["#ff5f56", "#ffbd2e", "#27c93f"] as const).map((c) => (
65
+ <div key={c} style={{ width: 12, height: 12, borderRadius: "50%", background: c }} />
66
+ ))}
67
+ </div>
68
+ <div style={{ flex: 1, overflow: "hidden", position: "relative" }}>{children}</div>
69
+ </div>
70
+ );
71
+
72
+ const MobileChrome: React.FC<{ children: React.ReactNode; theme: Theme }> = ({ children, theme }) => (
73
+ <div
74
+ style={{
75
+ width: "100%",
76
+ height: "100%",
77
+ borderRadius: 40,
78
+ border: `3px solid ${theme.border}`,
79
+ overflow: "hidden",
80
+ boxShadow: "0 40px 100px rgba(0,0,0,0.55)",
81
+ position: "relative",
82
+ }}
83
+ >
84
+ {children}
85
+ </div>
86
+ );
87
+
88
+ export const Clip: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
89
+ const renderMedia = () => <MediaElement scene={scene} />;
90
+
91
+ if (scene.frame === "browser") {
92
+ return (
93
+ <AbsoluteFill
94
+ style={{
95
+ background: "transparent",
96
+ display: "flex",
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ padding: "60px 120px",
100
+ }}
101
+ >
102
+ <BrowserChrome theme={theme}>{renderMedia()}</BrowserChrome>
103
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} bottomInset={bottomInset} />}
104
+ </AbsoluteFill>
105
+ );
106
+ }
107
+
108
+ if (scene.frame === "mobile") {
109
+ return (
110
+ <AbsoluteFill
111
+ style={{
112
+ background: "transparent",
113
+ display: "flex",
114
+ alignItems: "center",
115
+ justifyContent: "center",
116
+ padding: "40px 200px",
117
+ }}
118
+ >
119
+ <MobileChrome theme={theme}>{renderMedia()}</MobileChrome>
120
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} bottomInset={bottomInset} />}
121
+ </AbsoluteFill>
122
+ );
123
+ }
124
+
125
+ return (
126
+ <AbsoluteFill style={{ background: "transparent", borderRadius: 12, overflow: "hidden" }}>
127
+ {renderMedia()}
128
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={30} />}
129
+ </AbsoluteFill>
130
+ );
131
+ };
@@ -4,21 +4,23 @@ 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;
10
11
  theme: Theme;
12
+ bottomInset?: number;
11
13
  }
12
14
 
13
- export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
14
- const lines = scene.code.split("\n");
15
- // Caption appears after all lines are revealed: startFrame=14, framesPerLine=9, plus 20-frame buffer
16
- const captionStart = 14 + lines.length * 9 + 20;
15
+ export const CodeReveal: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
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);
17
19
 
18
20
  return (
19
21
  <AbsoluteFill
20
22
  style={{
21
- background: theme.bg,
23
+ background: "transparent",
22
24
  display: "flex",
23
25
  alignItems: "center",
24
26
  justifyContent: "center",
@@ -31,11 +33,11 @@ export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
31
33
  language={scene.language}
32
34
  highlightLine={scene.highlightLine}
33
35
  theme={theme}
34
- startFrame={14}
35
- framesPerLine={9}
36
+ startFrame={CODE_START}
37
+ framesPerLine={CODE_FRAMES_PER_LINE}
36
38
  />
37
39
  </div>
38
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
40
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
39
41
  </AbsoluteFill>
40
42
  );
41
43
  };
@@ -8,12 +8,13 @@ import { Caption } from "../primitives/Caption";
8
8
  interface Props {
9
9
  scene: DataFlowBrief;
10
10
  theme: Theme;
11
+ bottomInset?: number;
11
12
  }
12
13
 
13
14
  const NODE_W = 200;
14
15
  const NODE_H = 64;
15
16
 
16
- export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
17
+ export const DataFlow: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
17
18
  const frame = useCurrentFrame();
18
19
  const { fps, width, height } = useVideoConfig();
19
20
 
@@ -38,7 +39,7 @@ export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
38
39
  const captionStart = (nodeCount - 1) * STEP + NODE_SETTLE + 20;
39
40
 
40
41
  return (
41
- <AbsoluteFill style={{ background: theme.bg }}>
42
+ <AbsoluteFill style={{ background: "transparent" }}>
42
43
  {/* SVG layer for arrows */}
43
44
  <svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
44
45
  {scene.edges.map((edge, i) => {
@@ -87,7 +88,7 @@ export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
87
88
  fontWeight: 600,
88
89
  color: theme.text,
89
90
  opacity,
90
- transform: `scale(${scale})`,
91
+ scale: String(scale),
91
92
  boxShadow: `0 4px 24px rgba(0,0,0,0.3)`,
92
93
  }}
93
94
  >
@@ -96,7 +97,7 @@ export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
96
97
  );
97
98
  })}
98
99
 
99
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
100
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
100
101
  </AbsoluteFill>
101
102
  );
102
103
  };