reelme 0.2.2 → 0.3.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 (50) hide show
  1. package/assets/audio/PROVENANCE.md +16 -0
  2. package/assets/audio/bright-sparks.mp3 +0 -0
  3. package/assets/audio/calm-keys.mp3 +0 -0
  4. package/assets/audio/circuit-pulse.mp3 +0 -0
  5. package/assets/audio/clean-horizon.mp3 +0 -0
  6. package/assets/audio/generate-tracks.mjs +213 -0
  7. package/assets/audio/manifest.json +83 -0
  8. package/assets/audio/midnight-protocol.mp3 +0 -0
  9. package/assets/audio/pixel-bounce.mp3 +0 -0
  10. package/assets/audio/steady-launch.mp3 +0 -0
  11. package/assets/audio/sunny-loop.mp3 +0 -0
  12. package/assets/audio/vector-grid.mp3 +0 -0
  13. package/package.json +2 -1
  14. package/src/cache.mjs +1 -0
  15. package/src/render.mjs +95 -4
  16. package/template/package.json +1 -0
  17. package/template/pnpm-lock.yaml +15 -0
  18. package/template/src/Root.tsx +55 -45
  19. package/template/src/audio.ts +24 -0
  20. package/template/src/benchmark.ts +18 -0
  21. package/template/src/brief.json +1 -0
  22. package/template/src/brief.ts +61 -3
  23. package/template/src/cinematic/Atmosphere.tsx +139 -0
  24. package/template/src/cinematic/Camera.tsx +54 -0
  25. package/template/src/cinematic/look.ts +89 -0
  26. package/template/src/cinematic/transitions.tsx +92 -0
  27. package/template/src/components/primitives/Bar.tsx +82 -0
  28. package/template/src/components/primitives/Caption.tsx +3 -2
  29. package/template/src/components/primitives/Kicker.tsx +46 -0
  30. package/template/src/components/primitives/Label.tsx +18 -24
  31. package/template/src/components/primitives/RevealText.tsx +101 -0
  32. package/template/src/components/primitives/Terminal.tsx +11 -0
  33. package/template/src/components/scenes/Benchmark.tsx +83 -0
  34. package/template/src/components/scenes/BrowserFrame.tsx +4 -3
  35. package/template/src/components/scenes/CTA.tsx +56 -10
  36. package/template/src/components/scenes/Clip.tsx +125 -0
  37. package/template/src/components/scenes/CodeReveal.tsx +4 -3
  38. package/template/src/components/scenes/DataFlow.tsx +4 -3
  39. package/template/src/components/scenes/FeatureList.tsx +13 -8
  40. package/template/src/components/scenes/FileTree.tsx +4 -3
  41. package/template/src/components/scenes/Hook.tsx +55 -0
  42. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  43. package/template/src/components/scenes/MobileScreen.tsx +120 -82
  44. package/template/src/components/scenes/OSWindowScene.tsx +4 -3
  45. package/template/src/components/scenes/Problem.tsx +28 -29
  46. package/template/src/components/scenes/SplitComparison.tsx +65 -92
  47. package/template/src/components/scenes/StatCallout.tsx +93 -4
  48. package/template/src/components/scenes/TerminalScene.tsx +5 -6
  49. package/template/src/duration.ts +12 -1
  50. package/template/src/platforms.ts +4 -0
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
3
+ import { CinematicLook, TransitionStyle } from "./look";
4
+
5
+ // Per-look cut rhythm. The picker cycles these across scenes so the edit has a
6
+ // pattern instead of one uniform crossfade everywhere. Scene 0 always opens
7
+ // from black; the final scene lands on a punch so the CTA arrives, not drifts.
8
+ const RHYTHM: Record<string, TransitionStyle[]> = {
9
+ keynote: ["fade", "rise", "fade", "rise"],
10
+ noir: ["dip", "fade", "dip", "rise"],
11
+ arcade: ["whip", "punch", "zoom", "whip"],
12
+ blueprint: ["cut", "rise", "cut", "whip"],
13
+ editorial: ["fade", "fade", "rise", "fade"],
14
+ };
15
+
16
+ export function transitionFor(look: CinematicLook, index: number, total: number): TransitionStyle {
17
+ if (index === 0) return "fade"; // open from black
18
+ if (index === total - 1) return look.energy > 0.7 ? "punch" : "rise";
19
+ const seq = RHYTHM[look.id] ?? RHYTHM.keynote;
20
+ return seq[index % seq.length];
21
+ }
22
+
23
+ interface EnterProps {
24
+ style: TransitionStyle;
25
+ look: CinematicLook;
26
+ /** True only for the first scene: reveal from a real black frame. */
27
+ fromBlack: boolean;
28
+ seed: number;
29
+ children: React.ReactNode;
30
+ }
31
+
32
+ /**
33
+ * Applies a scene's entrance. Scenes are sequenced back-to-back with no exit
34
+ * fade, so a "cut" is a true hard cut and the others are how the shot arrives.
35
+ * The continuous Atmosphere behind keeps even hard cuts feeling connected.
36
+ */
37
+ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, children }) => {
38
+ const frame = useCurrentFrame();
39
+ // Faster entrances for higher-energy looks.
40
+ const win = Math.round(interpolate(look.energy, [0, 1], [18, 8]));
41
+ const dir = seed % 2 === 0 ? 1 : -1;
42
+ const ease = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const };
43
+ const p = interpolate(frame, [0, win], [0, 1], { ...ease, easing: Easing.out(Easing.cubic) });
44
+
45
+ let opacity = 1;
46
+ let transform = "";
47
+ let filter: string | undefined;
48
+
49
+ switch (style) {
50
+ case "cut":
51
+ break;
52
+ case "fade":
53
+ opacity = interpolate(frame, [0, fromBlack ? win + 6 : win], [0, 1], ease);
54
+ break;
55
+ case "rise":
56
+ opacity = p;
57
+ transform = `translateY(${interpolate(p, [0, 1], [44, 0])}px)`;
58
+ break;
59
+ case "whip":
60
+ opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
61
+ transform = `translateX(${interpolate(p, [0, 1], [90 * dir, 0])}px)`;
62
+ filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
63
+ break;
64
+ case "punch":
65
+ opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
66
+ transform = `scale(${interpolate(p, [0, 1], [1.12, 1])})`;
67
+ break;
68
+ case "zoom":
69
+ opacity = p;
70
+ transform = `scale(${interpolate(p, [0, 1], [0.9, 1])})`;
71
+ break;
72
+ case "dip":
73
+ // Content snaps in; a black veil over the top lifts away → dip-to-black.
74
+ opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
75
+ break;
76
+ }
77
+
78
+ const dipVeil = style === "dip" && (
79
+ <AbsoluteFill
80
+ style={{ background: "#000", opacity: interpolate(frame, [0, win], [1, 0], ease), pointerEvents: "none" }}
81
+ />
82
+ );
83
+
84
+ return (
85
+ <>
86
+ <AbsoluteFill style={{ opacity, transform, filter, willChange: "transform, opacity" }}>
87
+ {children}
88
+ </AbsoluteFill>
89
+ {dipVeil}
90
+ </>
91
+ );
92
+ };
@@ -0,0 +1,82 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface Props {
6
+ label: string;
7
+ valueText: string;
8
+ /** Target fill width as a fraction of the track (0..1). */
9
+ fraction: number;
10
+ hero?: boolean;
11
+ theme: Theme;
12
+ startFrame: number;
13
+ }
14
+
15
+ export const Bar: React.FC<Props> = ({ label, valueText, fraction, hero, theme, startFrame }) => {
16
+ const frame = useCurrentFrame();
17
+ const { fps } = useVideoConfig();
18
+
19
+ const progress = spring({
20
+ frame: frame - startFrame,
21
+ fps,
22
+ config: theme.motion,
23
+ });
24
+ const width = interpolate(Math.max(0, progress), [0, 1], [0, Math.max(0, Math.min(1, fraction))]);
25
+ const valueOpacity = interpolate(frame - startFrame, [10, 22], [0, 1], {
26
+ extrapolateLeft: "clamp",
27
+ extrapolateRight: "clamp",
28
+ });
29
+
30
+ return (
31
+ <div style={{ display: "flex", alignItems: "center", gap: 28 }}>
32
+ <div
33
+ style={{
34
+ width: 300,
35
+ textAlign: "right",
36
+ flexShrink: 0,
37
+ fontFamily: theme.fontSans,
38
+ fontSize: 30,
39
+ fontWeight: hero ? 700 : 500,
40
+ color: hero ? theme.text : theme.textMuted,
41
+ letterSpacing: "-0.01em",
42
+ }}
43
+ >
44
+ {label}
45
+ </div>
46
+
47
+ <div
48
+ style={{
49
+ flex: 1,
50
+ height: 56,
51
+ background: theme.surface,
52
+ borderRadius: 12,
53
+ display: "flex",
54
+ alignItems: "center",
55
+ }}
56
+ >
57
+ <div
58
+ style={{
59
+ width: `${width * 100}%`,
60
+ height: "100%",
61
+ background: hero ? theme.accent : theme.border,
62
+ borderRadius: 12,
63
+ flexShrink: 0,
64
+ }}
65
+ />
66
+ <div
67
+ style={{
68
+ opacity: valueOpacity,
69
+ marginLeft: 18,
70
+ whiteSpace: "nowrap",
71
+ fontFamily: theme.fontMono,
72
+ fontSize: 30,
73
+ fontWeight: 700,
74
+ color: hero ? theme.accent : theme.textMuted,
75
+ }}
76
+ >
77
+ {valueText}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ };
@@ -6,9 +6,10 @@ interface CaptionProps {
6
6
  text: string;
7
7
  theme: Theme;
8
8
  startFrame?: number;
9
+ bottomInset?: number;
9
10
  }
10
11
 
11
- export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0 }) => {
12
+ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0, bottomInset = 0 }) => {
12
13
  const frame = useCurrentFrame();
13
14
  const { fps } = useVideoConfig();
14
15
  const elapsed = frame - startFrame;
@@ -21,7 +22,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0 })
21
22
  <div
22
23
  style={{
23
24
  position: "absolute",
24
- bottom: 72,
25
+ bottom: 72 + bottomInset,
25
26
  left: 0,
26
27
  right: 0,
27
28
  display: "flex",
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface KickerProps {
6
+ text: string;
7
+ theme: Theme;
8
+ startFrame?: number;
9
+ align?: "left" | "center";
10
+ }
11
+
12
+ /** A small eyebrow label above a headline: an accent tick + uppercase mono tag. */
13
+ export const Kicker: React.FC<KickerProps> = ({ text, theme, startFrame = 0, align = "left" }) => {
14
+ const frame = useCurrentFrame();
15
+ const { fps } = useVideoConfig();
16
+ const p = spring({ frame: frame - startFrame, fps, config: theme.motion });
17
+ const opacity = interpolate(Math.max(0, p), [0, 1], [0, 1]);
18
+ const lineW = interpolate(Math.max(0, p), [0, 1], [0, 40]);
19
+
20
+ return (
21
+ <div
22
+ style={{
23
+ display: "flex",
24
+ alignItems: "center",
25
+ gap: 14,
26
+ justifyContent: align === "center" ? "center" : "flex-start",
27
+ marginBottom: 22,
28
+ opacity,
29
+ }}
30
+ >
31
+ <div style={{ width: lineW, height: 3, background: theme.accent, borderRadius: 2 }} />
32
+ <span
33
+ style={{
34
+ fontFamily: theme.fontMono,
35
+ fontSize: 22,
36
+ fontWeight: 600,
37
+ letterSpacing: "0.22em",
38
+ textTransform: "uppercase",
39
+ color: theme.accent,
40
+ }}
41
+ >
42
+ {text}
43
+ </span>
44
+ </div>
45
+ );
46
+ };
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
- import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
2
  import { Theme } from "../../theme";
3
+ import { RevealText } from "./RevealText";
4
4
 
5
5
  interface LabelProps {
6
6
  text: string;
@@ -9,6 +9,7 @@ interface LabelProps {
9
9
  size?: "sm" | "md" | "lg" | "xl";
10
10
  align?: "left" | "center" | "right";
11
11
  muted?: boolean;
12
+ emphasis?: string;
12
13
  }
13
14
 
14
15
  const sizeMap = { sm: 22, md: 32, lg: 48, xl: 72 };
@@ -20,30 +21,23 @@ export const Label: React.FC<LabelProps> = ({
20
21
  size = "md",
21
22
  align = "center",
22
23
  muted = false,
24
+ emphasis,
23
25
  }) => {
24
- const frame = useCurrentFrame();
25
- const { fps } = useVideoConfig();
26
- const elapsed = frame - startFrame;
27
-
28
- const progress = spring({ frame: elapsed, fps, config: theme.motion });
29
- const opacity = interpolate(progress, [0, 1], [0, 1]);
30
- const translateY = interpolate(progress, [0, 1], [20, 0]);
31
-
26
+ const big = size === "xl" || size === "lg";
32
27
  return (
33
- <div
34
- style={{
35
- opacity,
36
- transform: `translateY(${translateY}px)`,
37
- fontFamily: theme.fontSans,
38
- fontSize: sizeMap[size],
39
- fontWeight: size === "xl" || size === "lg" ? 700 : 500,
40
- color: muted ? theme.textMuted : theme.text,
41
- textAlign: align,
42
- lineHeight: 1.25,
43
- letterSpacing: size === "xl" ? "-0.02em" : "-0.01em",
44
- }}
45
- >
46
- {text}
47
- </div>
28
+ <RevealText
29
+ text={text}
30
+ theme={theme}
31
+ startFrame={startFrame}
32
+ fontSize={sizeMap[size]}
33
+ fontWeight={big ? 700 : 500}
34
+ color={muted ? theme.textMuted : theme.text}
35
+ align={align}
36
+ emphasis={emphasis}
37
+ stagger={big ? 3 : 1.5}
38
+ letterSpacing={size === "xl" ? "-0.03em" : "-0.01em"}
39
+ lineHeight={big ? 1.1 : 1.25}
40
+ glow={big && !muted}
41
+ />
48
42
  );
49
43
  };
@@ -0,0 +1,101 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { Theme } from "../../theme";
4
+
5
+ interface RevealTextProps {
6
+ text: string;
7
+ theme: Theme;
8
+ fontSize: number;
9
+ startFrame?: number;
10
+ fontWeight?: number;
11
+ color?: string;
12
+ /** A whole word (or exact substring) rendered in the accent color. */
13
+ emphasis?: string;
14
+ align?: "left" | "center" | "right";
15
+ /** Frames between each word's rise. */
16
+ stagger?: number;
17
+ letterSpacing?: string;
18
+ lineHeight?: number;
19
+ /** Soft shadow so the type sits in the lit scene instead of floating flat. */
20
+ glow?: boolean;
21
+ maxWidth?: number;
22
+ }
23
+
24
+ /**
25
+ * Masked kinetic typography: each word rises out from behind a clip edge with a
26
+ * staggered spring — the headline reveal used in premium launch films, not a
27
+ * flat fade. Words wrap naturally; descenders are preserved by the mask padding.
28
+ */
29
+ export const RevealText: React.FC<RevealTextProps> = ({
30
+ text,
31
+ theme,
32
+ fontSize,
33
+ startFrame = 0,
34
+ fontWeight = 800,
35
+ color,
36
+ emphasis,
37
+ align = "center",
38
+ stagger = 3,
39
+ letterSpacing = "-0.03em",
40
+ lineHeight = 1.06,
41
+ glow = true,
42
+ maxWidth,
43
+ }) => {
44
+ const frame = useCurrentFrame();
45
+ const { fps } = useVideoConfig();
46
+ const words = text.split(" ");
47
+ const emphWord = emphasis?.trim().toLowerCase();
48
+
49
+ return (
50
+ <div
51
+ style={{
52
+ fontFamily: theme.fontSans,
53
+ fontSize,
54
+ fontWeight,
55
+ color: color ?? theme.text,
56
+ textAlign: align,
57
+ lineHeight,
58
+ letterSpacing,
59
+ maxWidth,
60
+ textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
61
+ }}
62
+ >
63
+ {words.map((word, i) => {
64
+ const elapsed = frame - startFrame - i * stagger;
65
+ const p = spring({ frame: elapsed, fps, config: theme.motion });
66
+ const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
67
+ const opacity = interpolate(elapsed, [0, 6], [0, 1], {
68
+ extrapolateLeft: "clamp",
69
+ extrapolateRight: "clamp",
70
+ });
71
+ const clean = word.replace(/[.,!?:;]/g, "").toLowerCase();
72
+ const isEmph = emphWord && (clean === emphWord || word.toLowerCase().includes(emphWord));
73
+ return (
74
+ <React.Fragment key={i}>
75
+ <span
76
+ style={{
77
+ display: "inline-block",
78
+ overflow: "hidden",
79
+ paddingBottom: "0.14em",
80
+ marginBottom: "-0.14em",
81
+ verticalAlign: "bottom",
82
+ }}
83
+ >
84
+ <span
85
+ style={{
86
+ display: "inline-block",
87
+ transform: `translateY(${y}%)`,
88
+ opacity,
89
+ color: isEmph ? theme.accent : undefined,
90
+ }}
91
+ >
92
+ {word}
93
+ </span>
94
+ </span>
95
+ {i < words.length - 1 ? " " : null}
96
+ </React.Fragment>
97
+ );
98
+ })}
99
+ </div>
100
+ );
101
+ };
@@ -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",
@@ -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
2
  import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
+ import chroma from "chroma-js";
3
4
  import { CTAScene as CTABrief, ProjectMeta } from "../../brief";
4
5
  import { Theme } from "../../theme";
6
+ import { PlatformPreset, typeScale } from "../../platforms";
5
7
  import { Caption } from "../primitives/Caption";
6
8
 
7
9
  interface Props {
8
10
  scene: CTABrief;
9
11
  theme: Theme;
10
12
  project: ProjectMeta;
13
+ platform?: PlatformPreset;
14
+ bottomInset?: number;
15
+ /** gif output: use a flat fill instead of gradients that bloat gif size. */
16
+ lite?: boolean;
11
17
  }
12
18
 
13
19
  const SNAP_OFFSET = 8;
14
20
 
15
- export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
21
+ export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomInset = 0, lite = false }) => {
16
22
  const frame = useCurrentFrame();
17
23
  const { fps } = useVideoConfig();
24
+ const scale = platform ? typeScale(platform) : 1.0;
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 });
@@ -32,10 +39,19 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
32
39
  ? `${project.name} ${project.version}`
33
40
  : project.name;
34
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";
45
+
35
46
  return (
36
47
  <AbsoluteFill
37
48
  style={{
38
- background: theme.accent,
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%)`,
39
55
  display: "flex",
40
56
  flexDirection: "column",
41
57
  alignItems: "center",
@@ -44,6 +60,17 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
44
60
  padding: "0 120px",
45
61
  }}
46
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
74
  {project.logo && (
48
75
  <div
49
76
  style={{
@@ -67,15 +94,16 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
67
94
  opacity: titleOpacity,
68
95
  transform: `translateY(${titleY}px)`,
69
96
  fontFamily: theme.fontSans,
70
- fontSize: 56,
97
+ fontSize: 60 * scale,
71
98
  fontWeight: 700,
72
- color: theme.textInverse,
73
- letterSpacing: "-0.02em",
99
+ color: onAccent,
100
+ letterSpacing: "-0.03em",
74
101
  textAlign: "center",
102
+ textShadow: "0 4px 30px rgba(0,0,0,0.25)",
75
103
  }}
76
104
  >
77
105
  {isAnnouncement ? "" : "Get started with "}
78
- <span style={{ color: theme.textInverse, fontWeight: 800 }}>{displayName}</span>
106
+ <span style={{ color: onAccent, fontWeight: 800 }}>{displayName}</span>
79
107
  {isAnnouncement ? " is here." : ""}
80
108
  </div>
81
109
 
@@ -85,14 +113,15 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
85
113
  transform: `scale(${cmdScale})`,
86
114
  background: theme.bg,
87
115
  border: `1.5px solid rgba(0,0,0,0.12)`,
88
- borderRadius: 12,
89
- padding: "18px 40px",
116
+ borderRadius: 14,
117
+ padding: "20px 44px",
90
118
  fontFamily: theme.fontMono,
91
119
  fontSize: 28,
92
120
  color: theme.text,
93
121
  display: "flex",
94
122
  alignItems: "center",
95
123
  gap: 16,
124
+ boxShadow: "0 22px 60px rgba(0,0,0,0.30)",
96
125
  }}
97
126
  >
98
127
  <span style={{ color: theme.accent }}>$</span>
@@ -105,14 +134,31 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
105
134
  opacity: urlFade,
106
135
  fontFamily: theme.fontMono,
107
136
  fontSize: 20,
108
- color: theme.textInverse,
137
+ color: onAccent,
109
138
  }}
110
139
  >
111
140
  {scene.repoUrl}
112
141
  </div>
113
142
  )}
114
143
 
115
- {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
144
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} bottomInset={bottomInset} />}
145
+
146
+ {project.watermark !== false && (
147
+ <div
148
+ style={{
149
+ position: "absolute",
150
+ bottom: 40 + bottomInset,
151
+ left: 0,
152
+ right: 0,
153
+ textAlign: "center",
154
+ fontFamily: theme.fontMono,
155
+ fontSize: 18,
156
+ color: theme.textMuted,
157
+ }}
158
+ >
159
+ made with reelme
160
+ </div>
161
+ )}
116
162
  </AbsoluteFill>
117
163
  );
118
164
  };