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
@@ -36,9 +36,14 @@ interface ReelProps {
36
36
  }
37
37
 
38
38
  export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
39
- const { durationInFrames } = useVideoConfig();
40
- const theme = buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle);
39
+ const { durationInFrames, fps } = useVideoConfig();
41
40
  const look = resolveLook(brief.project.look, brief.project.tone);
41
+ // The look owns motion personality: override the tone-derived default so every
42
+ // scene's springs (which read theme.motion) inherit the look's physics.
43
+ const theme = {
44
+ ...buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle),
45
+ motion: look.motion,
46
+ };
42
47
  const isGif = platform.output.codec === "gif";
43
48
  const shouldRenderAudio = !isGif && Boolean(brief.project.audio);
44
49
 
@@ -48,7 +53,7 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
48
53
  cut === "teaser"
49
54
  ? brief.cuts.teaser ?? []
50
55
  : platform.cut === "vertical"
51
- ? brief.cuts.vertical ?? brief.cuts.main
56
+ ? (brief.cuts.vertical?.length ? brief.cuts.vertical : brief.cuts.main)
52
57
  : brief.cuts.main;
53
58
 
54
59
  let cursor = 0;
@@ -59,6 +64,12 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
59
64
  return { scene, from, duration };
60
65
  });
61
66
 
67
+ // Keep scene content out of the platform's top UI overlay zone (F10). The
68
+ // Atmosphere still fills the whole frame; only the content band starts below
69
+ // safeArea.top. The bottom is left at the frame edge because captions manage
70
+ // their own bottom inset, so this never double-counts.
71
+ const topInset = platform.safeArea?.top ?? 0;
72
+
62
73
  return (
63
74
  <AbsoluteFill style={{ background: theme.bg }}>
64
75
  <Atmosphere theme={theme} look={look} quality={isGif ? "lite" : "full"} />
@@ -73,15 +84,17 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
73
84
  />
74
85
  ) : null}
75
86
 
76
- {sequenced.map(({ scene, from, duration }, i) => (
77
- <Sequence key={i} from={from} durationInFrames={duration}>
78
- <Enter style={transitionFor(look, i, sequenced.length)} look={look} fromBlack={i === 0} seed={i}>
79
- <Camera look={look} durationInFrames={duration} seed={i} disabled={isGif}>
80
- <SceneRenderer scene={scene} theme={theme} project={brief.project} platform={platform} />
81
- </Camera>
82
- </Enter>
83
- </Sequence>
84
- ))}
87
+ <div style={{ position: "absolute", top: topInset, left: 0, right: 0, bottom: 0 }}>
88
+ {sequenced.map(({ scene, from, duration }, i) => (
89
+ <Sequence key={i} from={from} durationInFrames={duration} premountFor={fps}>
90
+ <Enter style={transitionFor(look, i, sequenced.length)} look={look} fromBlack={i === 0} seed={i}>
91
+ <Camera look={look} durationInFrames={duration} seed={i} disabled={isGif}>
92
+ <SceneRenderer scene={scene} theme={theme} project={brief.project} platform={platform} />
93
+ </Camera>
94
+ </Enter>
95
+ </Sequence>
96
+ ))}
97
+ </div>
85
98
 
86
99
  {!isGif && <Grain look={look} />}
87
100
  </AbsoluteFill>
@@ -97,7 +110,6 @@ interface SceneRendererProps {
97
110
 
98
111
  const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, platform }) => {
99
112
  const bottomInset = platform.safeArea?.bottom ?? 0;
100
- const lite = platform.output.codec === "gif";
101
113
  switch (scene.type) {
102
114
  case "problem":
103
115
  return <Problem scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
@@ -108,7 +120,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
108
120
  case "data-flow":
109
121
  return <DataFlow scene={scene} theme={theme} bottomInset={bottomInset} />;
110
122
  case "cta":
111
- return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} lite={lite} />;
123
+ return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
112
124
  case "browser":
113
125
  return <BrowserFrame scene={scene} theme={theme} bottomInset={bottomInset} />;
114
126
  case "split":
@@ -128,7 +140,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
128
140
  case "hook":
129
141
  return <Hook scene={scene} theme={theme} platform={platform} />;
130
142
  case "clip":
131
- return <Clip scene={scene} theme={theme} />;
143
+ return <Clip scene={scene} theme={theme} bottomInset={bottomInset} />;
132
144
  case "benchmark":
133
145
  return <Benchmark scene={scene} theme={theme} bottomInset={bottomInset} />;
134
146
  default:
@@ -61,11 +61,11 @@
61
61
  "title": "Sprout Commands",
62
62
  "searchQuery": "sprout",
63
63
  "items": [
64
- { "icon": "play", "label": "sprout dev", "value": "Start dev server + Prisma Studio", "highlighted": true },
64
+ { "icon": "terminal", "label": "sprout dev", "value": "Start dev server + Prisma Studio", "highlighted": true },
65
65
  { "icon": "database", "label": "sprout db:push", "value": "Sync schema to database" },
66
- { "icon": "user-check", "label": "sprout auth:setup", "value": "Configure OAuth providers" },
66
+ { "icon": "key", "label": "sprout auth:setup", "value": "Configure OAuth providers" },
67
67
  { "icon": "package", "label": "sprout add", "value": "Add a feature module" },
68
- { "icon": "rocket", "label": "sprout deploy", "value": "Deploy to Vercel, Railway, or Fly" }
68
+ { "icon": "cloud", "label": "sprout deploy", "value": "Deploy to Vercel, Railway, or Fly" }
69
69
  ],
70
70
  "caption": "Every task is one command away."
71
71
  },
@@ -82,8 +82,8 @@
82
82
  { "text": "Type-safe API layer with tRPC out of the box", "icon": "shield" },
83
83
  { "text": "Auth in one command — email, GitHub, Google", "icon": "lock" },
84
84
  { "text": "Database ORM with auto-generated migrations", "icon": "database" },
85
- { "text": "CI/CD templates for GitHub Actions baked in", "icon": "git-branch" },
86
- { "text": "One-click deploy to Vercel, Fly, or Railway", "icon": "rocket" }
85
+ { "text": "CI/CD templates for GitHub Actions baked in", "icon": "settings" },
86
+ { "text": "One-click deploy to Vercel, Fly, or Railway", "icon": "cloud" }
87
87
  ],
88
88
  "caption": "Start building features on day one."
89
89
  },
@@ -117,7 +117,7 @@
117
117
  "items": [
118
118
  { "text": "Type-safe API layer out of the box", "icon": "shield" },
119
119
  { "text": "Auth in one command", "icon": "lock" },
120
- { "text": "One-click deploy", "icon": "rocket" }
120
+ { "text": "One-click deploy", "icon": "cloud" }
121
121
  ],
122
122
  "caption": "Start building on day one."
123
123
  },
@@ -5,6 +5,12 @@ import { LookId } from "./cinematic/look";
5
5
  // the CLI refuses briefs whose schemaVersion doesn't match.
6
6
  export const BRIEF_SCHEMA_VERSION = 2;
7
7
 
8
+ // The CTA footer credit shows unless the brief explicitly opts out. Exported so
9
+ // the component and its test share one definition instead of drifting (F22).
10
+ export function showWatermark(watermark: boolean | undefined): boolean {
11
+ return watermark !== false;
12
+ }
13
+
8
14
  export interface ProjectMeta {
9
15
  name: string;
10
16
  tagline: string;
@@ -17,15 +23,13 @@ export interface ProjectMeta {
17
23
  platforms: PlatformId[];
18
24
  /** Bundled CC0 track selection; false disables audio. */
19
25
  audio?: { track: string; volume?: number } | false;
20
- /** "made with reelme" credit in the CTA footer; default true. (Rendering lands in Phase 2.) */
26
+ /** "made with reelme" credit in the CTA footer; default true. */
21
27
  watermark?: boolean;
22
28
  mode?: "intro" | "announcement";
23
29
  version?: string;
24
30
  logo?: string;
25
31
  font?: string;
26
32
  monoFont?: string;
27
- /** Legacy per-scene transition; superseded by the look's edit rhythm. */
28
- transition?: "fade" | "slide" | "zoom";
29
33
  bgStyle?: "deep" | "branded" | "light";
30
34
  /**
31
35
  * Art-direction preset: lighting, camera, grade, grain, and cut rhythm.
@@ -155,6 +159,12 @@ export interface MobileScene {
155
159
  title?: string;
156
160
  screenshot?: string;
157
161
  caption?: string;
162
+ /** Value-prop shown beside the device (landscape) or above it (vertical).
163
+ * With a headline/points the scene composes as device + copy; without, the
164
+ * device sits centered on its own. */
165
+ headline?: string;
166
+ /** Short supporting bullets next to the device, rendered with accent checks. */
167
+ points?: string[];
158
168
  }
159
169
 
160
170
  export interface ClipScene {
@@ -47,7 +47,7 @@ export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled
47
47
  }
48
48
 
49
49
  return (
50
- <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "center center", willChange: "transform" }}>
50
+ <AbsoluteFill style={{ scale: String(scale), transformOrigin: "center center", willChange: "scale" }}>
51
51
  {children}
52
52
  </AbsoluteFill>
53
53
  );
@@ -5,9 +5,11 @@
5
5
  //
6
6
  // Looks are pure data; colors come from the resolved Theme at render time.
7
7
 
8
+ import { MotionProfile } from "../theme";
9
+
8
10
  export type LookId = "keynote" | "noir" | "arcade" | "blueprint" | "editorial";
9
11
 
10
- export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom";
12
+ export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom" | "wipe" | "flip";
11
13
 
12
14
  export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
13
15
 
@@ -36,42 +38,57 @@ export interface CinematicLook {
36
38
  /** Camera amplitude multiplier (1 = default). */
37
39
  cameraIntensity: number;
38
40
  grade: Grade;
39
- /** Default cut style; the rhythm picker varies around it. */
40
- transition: TransitionStyle;
41
41
  /** Editing energy: higher tightens enter timings and favors hard cuts. */
42
42
  energy: number;
43
+ /**
44
+ * Spring physics for in-scene element animation. Motion is part of the
45
+ * production design: a look should *move* differently, not just look
46
+ * different. The Reel feeds this into the theme so every scene's springs
47
+ * inherit the look's personality (smooth, snappy, weighty, ...).
48
+ */
49
+ motion: MotionProfile;
43
50
  }
44
51
 
45
52
  const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
46
53
  // Clean keynote stage — soft key light, gentle push, long dissolves.
54
+ // Motion: settled, no bounce — elements arrive and hold.
47
55
  keynote: {
48
56
  glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
49
57
  camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
50
- transition: "fade", energy: 0.4,
58
+ energy: 0.4,
59
+ motion: { damping: 22, stiffness: 100, mass: 1.0 },
51
60
  },
52
61
  // Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
62
+ // Motion: heavy and deliberate — weighty mass, slow to settle.
53
63
  noir: {
54
64
  glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
55
65
  camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
56
- transition: "dip", energy: 0.35,
66
+ energy: 0.35,
67
+ motion: { damping: 28, stiffness: 78, mass: 1.4 },
57
68
  },
58
69
  // Saturated arcade — dual lights, scanlines, snappy whips and zooms.
70
+ // Motion: snappy with a playful overshoot — light mass, low damping.
59
71
  arcade: {
60
72
  glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
61
73
  camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
62
- transition: "whip", energy: 0.85,
74
+ energy: 0.85,
75
+ motion: { damping: 11, stiffness: 155, mass: 0.7 },
63
76
  },
64
77
  // Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
78
+ // Motion: stiff and precise — quick, minimal bounce, machined.
65
79
  blueprint: {
66
80
  glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
67
81
  camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
68
- transition: "cut", energy: 0.6,
82
+ energy: 0.6,
83
+ motion: { damping: 30, stiffness: 150, mass: 1.1 },
69
84
  },
70
85
  // Premium brand film — warm grade, big soft vignette, very slow elegant push.
86
+ // Motion: the smoothest — elegant glide, no perceptible bounce.
71
87
  editorial: {
72
88
  glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
73
89
  camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
74
- transition: "fade", energy: 0.3,
90
+ energy: 0.3,
91
+ motion: { damping: 26, stiffness: 88, mass: 1.15 },
75
92
  },
76
93
  };
77
94
 
@@ -8,9 +8,9 @@ import { CinematicLook, TransitionStyle } from "./look";
8
8
  const RHYTHM: Record<string, TransitionStyle[]> = {
9
9
  keynote: ["fade", "rise", "fade", "rise"],
10
10
  noir: ["dip", "fade", "dip", "rise"],
11
- arcade: ["whip", "punch", "zoom", "whip"],
12
- blueprint: ["cut", "rise", "cut", "whip"],
13
- editorial: ["fade", "fade", "rise", "fade"],
11
+ arcade: ["whip", "flip", "zoom", "whip"],
12
+ blueprint: ["cut", "wipe", "cut", "whip"],
13
+ editorial: ["fade", "wipe", "rise", "fade"],
14
14
  };
15
15
 
16
16
  export function transitionFor(look: CinematicLook, index: number, total: number): TransitionStyle {
@@ -43,8 +43,11 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
43
43
  const p = interpolate(frame, [0, win], [0, 1], { ...ease, easing: Easing.out(Easing.cubic) });
44
44
 
45
45
  let opacity = 1;
46
- let transform = "";
46
+ let scale: string | undefined;
47
+ let translate: string | undefined;
47
48
  let filter: string | undefined;
49
+ let transform: string | undefined;
50
+ let clipPath: string | undefined;
48
51
 
49
52
  switch (style) {
50
53
  case "cut":
@@ -54,25 +57,38 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
54
57
  break;
55
58
  case "rise":
56
59
  opacity = p;
57
- transform = `translateY(${interpolate(p, [0, 1], [44, 0])}px)`;
60
+ translate = `0 ${interpolate(p, [0, 1], [44, 0])}px`;
58
61
  break;
59
62
  case "whip":
60
63
  opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
61
- transform = `translateX(${interpolate(p, [0, 1], [90 * dir, 0])}px)`;
64
+ translate = `${interpolate(p, [0, 1], [90 * dir, 0])}px 0`;
62
65
  filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
63
66
  break;
64
67
  case "punch":
65
68
  opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
66
- transform = `scale(${interpolate(p, [0, 1], [1.12, 1])})`;
69
+ scale = String(interpolate(p, [0, 1], [1.12, 1]));
67
70
  break;
68
71
  case "zoom":
69
72
  opacity = p;
70
- transform = `scale(${interpolate(p, [0, 1], [0.9, 1])})`;
73
+ scale = String(interpolate(p, [0, 1], [0.9, 1]));
71
74
  break;
72
75
  case "dip":
73
76
  // Content snaps in; a black veil over the top lifts away → dip-to-black.
74
77
  opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
75
78
  break;
79
+ case "wipe": {
80
+ // A hard edge sweeps across, revealing the shot. Direction alternates by
81
+ // seed so consecutive wipes don't all travel the same way.
82
+ opacity = interpolate(frame, [0, win * 0.3], [0, 1], ease);
83
+ const remain = interpolate(p, [0, 1], [100, 0]);
84
+ clipPath = dir > 0 ? `inset(0 ${remain}% 0 0)` : `inset(0 0 0 ${remain}%)`;
85
+ break;
86
+ }
87
+ case "flip":
88
+ // Shot swings in on a vertical hinge — a single playful rotation.
89
+ opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
90
+ transform = `perspective(1400px) rotateY(${interpolate(p, [0, 1], [62 * dir, 0])}deg)`;
91
+ break;
76
92
  }
77
93
 
78
94
  const dipVeil = style === "dip" && (
@@ -83,7 +99,9 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
83
99
 
84
100
  return (
85
101
  <>
86
- <AbsoluteFill style={{ opacity, transform, filter, willChange: "transform, opacity" }}>
102
+ <AbsoluteFill
103
+ style={{ opacity, scale, translate, filter, transform, clipPath, willChange: "scale, translate, opacity, transform, clip-path" }}
104
+ >
87
105
  {children}
88
106
  </AbsoluteFill>
89
107
  {dipVeil}
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion";
3
3
  import { Theme } from "../../theme";
4
4
 
5
5
  interface Props {
@@ -21,8 +21,12 @@ export const Bar: React.FC<Props> = ({ label, valueText, fraction, hero, theme,
21
21
  fps,
22
22
  config: theme.motion,
23
23
  });
24
- const width = interpolate(Math.max(0, progress), [0, 1], [0, Math.max(0, Math.min(1, fraction))]);
24
+ const width = interpolate(Math.max(0, progress), [0, 1], [0, Math.max(0, Math.min(1, fraction))], {
25
+ extrapolateLeft: "clamp",
26
+ extrapolateRight: "clamp",
27
+ });
25
28
  const valueOpacity = interpolate(frame - startFrame, [10, 22], [0, 1], {
29
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
26
30
  extrapolateLeft: "clamp",
27
31
  extrapolateRight: "clamp",
28
32
  });
@@ -28,7 +28,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0, b
28
28
  display: "flex",
29
29
  justifyContent: "center",
30
30
  opacity,
31
- transform: `translateY(${translateY}px)`,
31
+ translate: `0 ${translateY}px`,
32
32
  }}
33
33
  >
34
34
  <div
@@ -38,7 +38,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0, b
38
38
  borderRadius: 999,
39
39
  padding: "10px 28px",
40
40
  fontFamily: theme.fontSans,
41
- fontSize: 22,
41
+ fontSize: 32,
42
42
  fontWeight: 500,
43
43
  color: theme.text,
44
44
  letterSpacing: "-0.01em",
@@ -54,6 +54,13 @@ const ICON_MAP: Record<string, LucideIcon> = {
54
54
  zap: Zap,
55
55
  };
56
56
 
57
+ // Whether a name resolves to a real icon. Callers use this to fall back visibly
58
+ // (e.g. to a numbered marker) instead of rendering an empty slot when a brief
59
+ // names an icon outside the registry (F6).
60
+ export function hasIcon(name: string): boolean {
61
+ return name.toLowerCase() in ICON_MAP;
62
+ }
63
+
57
64
  export interface IconProps {
58
65
  name: string;
59
66
  size?: number;
@@ -34,7 +34,7 @@ export const KeyPill: React.FC<KeyPillProps> = ({ label, theme, startFrame = 0 }
34
34
  color: theme.text,
35
35
  minWidth: 60,
36
36
  letterSpacing: "-0.01em",
37
- transform: `scale(${scale})`,
37
+ scale: String(scale),
38
38
  opacity,
39
39
  }}
40
40
  >
@@ -12,7 +12,10 @@ interface LabelProps {
12
12
  emphasis?: string;
13
13
  }
14
14
 
15
- const sizeMap = { sm: 22, md: 32, lg: 48, xl: 72 };
15
+ // Floors from the video-layout rules at 1080-wide: labels >=32, supporting >=44,
16
+ // headline >=84. Each step meets or clears its floor so text stays readable at
17
+ // viewing distance.
18
+ const sizeMap = { sm: 32, md: 44, lg: 60, xl: 88 };
16
19
 
17
20
  export const Label: React.FC<LabelProps> = ({
18
21
  text,
@@ -0,0 +1,65 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "remotion";
3
+
4
+ type LoopProperty = "scale" | "translateX" | "translateY" | "rotate";
5
+
6
+ interface Props {
7
+ /** Which transform channel oscillates. */
8
+ property?: LoopProperty;
9
+ /**
10
+ * Peak deviation from rest: px for translate, degrees for rotate, and a
11
+ * scale delta for scale (0.02 = breathes between 0.98 and 1.02).
12
+ */
13
+ amplitude?: number;
14
+ /** Seconds for one full cycle. */
15
+ period?: number;
16
+ /** 0..1 phase offset so several looped elements don't move in lockstep. */
17
+ phase?: number;
18
+ /**
19
+ * Skip the motion (gif output): a perpetually moving element changes every
20
+ * frame and defeats gif compression, same reasoning as the Camera.
21
+ */
22
+ disabled?: boolean;
23
+ children: React.ReactNode;
24
+ }
25
+
26
+ /**
27
+ * Applies a subtle, continuous, seamless oscillation to a held element — the
28
+ * declarative "loop" channel the entrance springs don't cover. Motion is a sine
29
+ * driven by useCurrentFrame, so it loops without a seam and renders
30
+ * deterministically. Keep amplitudes small: this is meant to read as "alive",
31
+ * not as a bounce.
32
+ */
33
+ export const Loop: React.FC<Props> = ({
34
+ property = "translateY",
35
+ amplitude = 6,
36
+ period = 3.5,
37
+ phase = 0,
38
+ disabled,
39
+ children,
40
+ }) => {
41
+ const frame = useCurrentFrame();
42
+ const { fps } = useVideoConfig();
43
+ if (disabled) return <>{children}</>;
44
+
45
+ const v = Math.sin((frame / (period * fps) + phase) * Math.PI * 2) * amplitude;
46
+
47
+ let style: React.CSSProperties;
48
+ switch (property) {
49
+ case "scale":
50
+ style = { scale: String(1 + v) };
51
+ break;
52
+ case "translateX":
53
+ style = { translate: `${v}px 0` };
54
+ break;
55
+ case "rotate":
56
+ style = { rotate: `${v}deg` };
57
+ break;
58
+ case "translateY":
59
+ default:
60
+ style = { translate: `0 ${v}px` };
61
+ break;
62
+ }
63
+
64
+ return <div style={{ ...style, willChange: "transform" }}>{children}</div>;
65
+ };
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion";
3
3
  import { Theme } from "../../theme";
4
4
 
5
5
  interface RevealTextProps {
@@ -44,7 +44,29 @@ export const RevealText: React.FC<RevealTextProps> = ({
44
44
  const frame = useCurrentFrame();
45
45
  const { fps } = useVideoConfig();
46
46
  const words = text.split(" ");
47
- const emphWord = emphasis?.trim().toLowerCase();
47
+
48
+ // Resolve which word indices the accent covers. A multi-word accent phrase
49
+ // ("one command") must emphasize the whole contiguous run, not silently no-op
50
+ // because no single word equals the phrase (F2). Punctuation is stripped from
51
+ // both sides so "video." matches "video".
52
+ const clean = (w: string) => w.replace(/[.,!?:;]/g, "").toLowerCase();
53
+ const cleanedWords = words.map(clean);
54
+ const emphIndices = new Set<number>();
55
+ const accentWords = emphasis?.trim() ? emphasis.trim().split(/\s+/).map(clean).filter(Boolean) : [];
56
+ if (accentWords.length === 1) {
57
+ const a = accentWords[0];
58
+ cleanedWords.forEach((c, i) => {
59
+ if (c === a || c.includes(a)) emphIndices.add(i);
60
+ });
61
+ } else if (accentWords.length > 1) {
62
+ for (let i = 0; i + accentWords.length <= cleanedWords.length; i++) {
63
+ const matches = accentWords.every((a, j) => {
64
+ const c = cleanedWords[i + j];
65
+ return c === a || c.includes(a);
66
+ });
67
+ if (matches) accentWords.forEach((_, j) => emphIndices.add(i + j));
68
+ }
69
+ }
48
70
 
49
71
  return (
50
72
  <div
@@ -65,11 +87,11 @@ export const RevealText: React.FC<RevealTextProps> = ({
65
87
  const p = spring({ frame: elapsed, fps, config: theme.motion });
66
88
  const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
67
89
  const opacity = interpolate(elapsed, [0, 6], [0, 1], {
90
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
68
91
  extrapolateLeft: "clamp",
69
92
  extrapolateRight: "clamp",
70
93
  });
71
- const clean = word.replace(/[.,!?:;]/g, "").toLowerCase();
72
- const isEmph = emphWord && (clean === emphWord || word.toLowerCase().includes(emphWord));
94
+ const isEmph = emphIndices.has(i);
73
95
  return (
74
96
  <React.Fragment key={i}>
75
97
  <span
@@ -84,7 +106,7 @@ export const RevealText: React.FC<RevealTextProps> = ({
84
106
  <span
85
107
  style={{
86
108
  display: "inline-block",
87
- transform: `translateY(${y}%)`,
109
+ translate: `0 ${y}%`,
88
110
  opacity,
89
111
  color: isEmph ? theme.accent : undefined,
90
112
  }}
@@ -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
+ };
@@ -38,7 +38,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme, bottomInset = 0 })
38
38
  style={{
39
39
  width: "100%",
40
40
  opacity: windowOpacity,
41
- transform: `scale(${windowScale})`,
41
+ scale: String(windowScale),
42
42
  boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
43
43
  borderRadius: 12,
44
44
  overflow: "hidden",