reelme 0.3.0 → 0.5.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 (69) hide show
  1. package/README.md +10 -3
  2. package/assets/audio/PROVENANCE.md +18 -11
  3. package/assets/audio/generate-sfx.mjs +128 -0
  4. package/assets/audio/light-steps.mp3 +0 -0
  5. package/assets/audio/lofi-dusk.mp3 +0 -0
  6. package/assets/audio/manifest.json +56 -63
  7. package/assets/audio/neon-pulse.mp3 +0 -0
  8. package/assets/audio/night-drive.mp3 +0 -0
  9. package/assets/audio/sfx/SOURCES.md +16 -0
  10. package/assets/audio/sfx/pop.mp3 +0 -0
  11. package/assets/audio/sfx/rise.mp3 +0 -0
  12. package/assets/audio/sfx/whoosh.mp3 +0 -0
  13. package/assets/audio/sunny-bounce.mp3 +0 -0
  14. package/assets/audio/warm-memories.mp3 +0 -0
  15. package/package.json +8 -2
  16. package/src/cache.mjs +246 -16
  17. package/src/cli.mjs +20 -11
  18. package/src/render.mjs +98 -19
  19. package/template/package.json +6 -6
  20. package/template/pnpm-lock.yaml +177 -122
  21. package/template/pnpm-workspace.yaml +2 -0
  22. package/template/src/Root.tsx +101 -21
  23. package/template/src/brief.json +6 -6
  24. package/template/src/brief.ts +39 -6
  25. package/template/src/cinematic/Atmosphere.tsx +176 -30
  26. package/template/src/cinematic/Camera.tsx +22 -10
  27. package/template/src/cinematic/look.ts +51 -10
  28. package/template/src/cinematic/transitions.tsx +29 -9
  29. package/template/src/components/primitives/Arrow.tsx +8 -5
  30. package/template/src/components/primitives/Bar.tsx +6 -2
  31. package/template/src/components/primitives/Caption.tsx +2 -2
  32. package/template/src/components/primitives/CodeBlock.tsx +21 -7
  33. package/template/src/components/primitives/Icon.tsx +7 -0
  34. package/template/src/components/primitives/KeyPill.tsx +1 -1
  35. package/template/src/components/primitives/Label.tsx +4 -1
  36. package/template/src/components/primitives/Loop.tsx +65 -0
  37. package/template/src/components/primitives/RevealText.tsx +35 -7
  38. package/template/src/components/primitives/Stage.tsx +62 -0
  39. package/template/src/components/primitives/Terminal.tsx +24 -7
  40. package/template/src/components/scenes/BrowserFrame.tsx +13 -6
  41. package/template/src/components/scenes/CTA.tsx +47 -82
  42. package/template/src/components/scenes/Clip.tsx +31 -12
  43. package/template/src/components/scenes/CodeReveal.tsx +12 -13
  44. package/template/src/components/scenes/DataFlow.tsx +47 -19
  45. package/template/src/components/scenes/FeatureList.tsx +74 -77
  46. package/template/src/components/scenes/FileTree.tsx +23 -7
  47. package/template/src/components/scenes/Hook.tsx +4 -3
  48. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  49. package/template/src/components/scenes/MobileScreen.tsx +225 -118
  50. package/template/src/components/scenes/OSWindowScene.tsx +5 -5
  51. package/template/src/components/scenes/Problem.tsx +1 -1
  52. package/template/src/components/scenes/SplitComparison.tsx +41 -14
  53. package/template/src/components/scenes/StatCallout.tsx +123 -68
  54. package/template/src/components/scenes/TerminalScene.tsx +12 -11
  55. package/template/src/custom-scenes.ts +19 -0
  56. package/template/src/duration.ts +54 -5
  57. package/template/src/index.ts +16 -10
  58. package/template/src/theme.ts +10 -1
  59. package/template/src/timing.ts +49 -0
  60. package/assets/audio/bright-sparks.mp3 +0 -0
  61. package/assets/audio/calm-keys.mp3 +0 -0
  62. package/assets/audio/circuit-pulse.mp3 +0 -0
  63. package/assets/audio/clean-horizon.mp3 +0 -0
  64. package/assets/audio/generate-tracks.mjs +0 -213
  65. package/assets/audio/midnight-protocol.mp3 +0 -0
  66. package/assets/audio/pixel-bounce.mp3 +0 -0
  67. package/assets/audio/steady-launch.mp3 +0 -0
  68. package/assets/audio/sunny-loop.mp3 +0 -0
  69. package/assets/audio/vector-grid.mp3 +0 -0
@@ -5,14 +5,31 @@
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
 
14
16
  export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
15
17
 
18
+ /**
19
+ * The backdrop SYSTEM a look is staged on. This is the look's silhouette: two
20
+ * looks with different backdrops read as different films even before grading.
21
+ * The old single recipe (dark field + drifting radial orbs) was the template's
22
+ * fingerprint — every reel shared it, and an orb in empty space reads as a
23
+ * smudge, not a light. Each system covers the frame with intent instead:
24
+ *
25
+ * - mesh: full-bleed multi-hue gradient field (accent + accent2), slow drift
26
+ * - spotlight: one elliptical light pool aimed where the content sits
27
+ * - sweep: saturated full-frame diagonal gradient between the brand hues
28
+ * - grid: structural engineering grid — the graphic IS the backdrop
29
+ * - field: rich near-solid editorial color field with a soft top wash
30
+ */
31
+ export type BackdropSystem = "mesh" | "spotlight" | "sweep" | "grid" | "field";
32
+
16
33
  export interface Grade {
17
34
  /** Tint color layered over the whole frame. */
18
35
  color: string;
@@ -22,7 +39,9 @@ export interface Grade {
22
39
 
23
40
  export interface CinematicLook {
24
41
  id: LookId;
25
- /** Radial accent-light intensity behind the content (0..1). */
42
+ /** Which backdrop system stages the whole reel. */
43
+ backdrop: BackdropSystem;
44
+ /** Backdrop light/color intensity (0..1). */
26
45
  glow: number;
27
46
  /** Whether a second, cooler light source is added for depth. */
28
47
  twoTone: boolean;
@@ -31,47 +50,69 @@ export interface CinematicLook {
31
50
  /** Film-grain opacity on the final frame (0..1); skipped for gif output. */
32
51
  grain: number;
33
52
  overlay: AtmosphereOverlay;
34
- /** Base camera move applied to every scene's content. */
53
+ /** Arrival move for every scene's content: a zoom that eases to a rest. */
35
54
  camera: CameraMove;
36
55
  /** Camera amplitude multiplier (1 = default). */
37
56
  cameraIntensity: number;
38
57
  grade: Grade;
39
- /** Default cut style; the rhythm picker varies around it. */
40
- transition: TransitionStyle;
41
58
  /** Editing energy: higher tightens enter timings and favors hard cuts. */
42
59
  energy: number;
60
+ /**
61
+ * Spring physics for in-scene element animation. Motion is part of the
62
+ * production design: a look should *move* differently, not just look
63
+ * different. The Reel feeds this into the theme so every scene's springs
64
+ * inherit the look's personality (smooth, snappy, weighty, ...).
65
+ */
66
+ motion: MotionProfile;
43
67
  }
44
68
 
45
69
  const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
46
70
  // Clean keynote stage — soft key light, gentle push, long dissolves.
71
+ // Motion: settled, no bounce — elements arrive and hold.
47
72
  keynote: {
73
+ backdrop: "mesh",
48
74
  glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
49
75
  camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
50
- transition: "fade", energy: 0.4,
76
+ energy: 0.4,
77
+ motion: { damping: 22, stiffness: 100, mass: 1.0 },
51
78
  },
52
79
  // Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
80
+ // Motion: heavy and deliberate — weighty mass, slow to settle.
53
81
  noir: {
82
+ backdrop: "spotlight",
54
83
  glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
55
84
  camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
56
- transition: "dip", energy: 0.35,
85
+ energy: 0.35,
86
+ motion: { damping: 28, stiffness: 78, mass: 1.4 },
57
87
  },
58
88
  // Saturated arcade — dual lights, scanlines, snappy whips and zooms.
89
+ // Motion: snappy but settled — light mass, high stiffness, near-critical
90
+ // damping. Energy comes from speed, not from overshoot: a bouncing element
91
+ // reads as a toy, not a cut (gallery feedback).
59
92
  arcade: {
93
+ backdrop: "sweep",
60
94
  glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
61
95
  camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
62
- transition: "whip", energy: 0.85,
96
+ energy: 0.85,
97
+ motion: { damping: 20, stiffness: 155, mass: 0.7 },
63
98
  },
64
99
  // Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
100
+ // Motion: stiff and precise — quick, minimal bounce, machined.
65
101
  blueprint: {
102
+ backdrop: "grid",
66
103
  glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
67
104
  camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
68
- transition: "cut", energy: 0.6,
105
+ energy: 0.6,
106
+ motion: { damping: 30, stiffness: 150, mass: 1.1 },
69
107
  },
70
108
  // Premium brand film — warm grade, big soft vignette, very slow elegant push.
109
+ // Motion: the smoothest — elegant glide, no perceptible bounce.
71
110
  editorial: {
111
+ backdrop: "field",
72
112
  glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
73
113
  camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
74
- transition: "fade", energy: 0.3,
114
+ energy: 0.3,
115
+ motion: { damping: 26, stiffness: 88, mass: 1.15 },
75
116
  },
76
117
  };
77
118
 
@@ -8,9 +8,11 @@ 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
+ // No flip: a hinged swing reads as a gimmick next to the rest of the edit
12
+ // (gallery feedback) arcade's energy now comes from fast zooms and whips.
13
+ arcade: ["zoom", "whip", "zoom", "fade"],
14
+ blueprint: ["cut", "wipe", "cut", "whip"],
15
+ editorial: ["fade", "wipe", "rise", "fade"],
14
16
  };
15
17
 
16
18
  export function transitionFor(look: CinematicLook, index: number, total: number): TransitionStyle {
@@ -43,8 +45,11 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
43
45
  const p = interpolate(frame, [0, win], [0, 1], { ...ease, easing: Easing.out(Easing.cubic) });
44
46
 
45
47
  let opacity = 1;
46
- let transform = "";
48
+ let scale: string | undefined;
49
+ let translate: string | undefined;
47
50
  let filter: string | undefined;
51
+ let transform: string | undefined;
52
+ let clipPath: string | undefined;
48
53
 
49
54
  switch (style) {
50
55
  case "cut":
@@ -54,25 +59,38 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
54
59
  break;
55
60
  case "rise":
56
61
  opacity = p;
57
- transform = `translateY(${interpolate(p, [0, 1], [44, 0])}px)`;
62
+ translate = `0 ${interpolate(p, [0, 1], [44, 0])}px`;
58
63
  break;
59
64
  case "whip":
60
65
  opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
61
- transform = `translateX(${interpolate(p, [0, 1], [90 * dir, 0])}px)`;
66
+ translate = `${interpolate(p, [0, 1], [90 * dir, 0])}px 0`;
62
67
  filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
63
68
  break;
64
69
  case "punch":
65
70
  opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
66
- transform = `scale(${interpolate(p, [0, 1], [1.12, 1])})`;
71
+ scale = String(interpolate(p, [0, 1], [1.07, 1]));
67
72
  break;
68
73
  case "zoom":
69
74
  opacity = p;
70
- transform = `scale(${interpolate(p, [0, 1], [0.9, 1])})`;
75
+ scale = String(interpolate(p, [0, 1], [0.9, 1]));
71
76
  break;
72
77
  case "dip":
73
78
  // Content snaps in; a black veil over the top lifts away → dip-to-black.
74
79
  opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
75
80
  break;
81
+ case "wipe": {
82
+ // A hard edge sweeps across, revealing the shot. Direction alternates by
83
+ // seed so consecutive wipes don't all travel the same way.
84
+ opacity = interpolate(frame, [0, win * 0.3], [0, 1], ease);
85
+ const remain = interpolate(p, [0, 1], [100, 0]);
86
+ clipPath = dir > 0 ? `inset(0 ${remain}% 0 0)` : `inset(0 0 0 ${remain}%)`;
87
+ break;
88
+ }
89
+ case "flip":
90
+ // Shot swings in on a vertical hinge — a single playful rotation.
91
+ opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
92
+ transform = `perspective(1400px) rotateY(${interpolate(p, [0, 1], [62 * dir, 0])}deg)`;
93
+ break;
76
94
  }
77
95
 
78
96
  const dipVeil = style === "dip" && (
@@ -83,7 +101,9 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
83
101
 
84
102
  return (
85
103
  <>
86
- <AbsoluteFill style={{ opacity, transform, filter, willChange: "transform, opacity" }}>
104
+ <AbsoluteFill
105
+ style={{ opacity, scale, translate, filter, transform, clipPath, willChange: "scale, translate, opacity, transform, clip-path" }}
106
+ >
87
107
  {children}
88
108
  </AbsoluteFill>
89
109
  {dipVeil}
@@ -10,11 +10,14 @@ interface ArrowProps {
10
10
  label?: string;
11
11
  theme: Theme;
12
12
  startFrame?: number;
13
+ /** Line weight; supporting graphics need real presence at video size. */
14
+ strokeWidth?: number;
15
+ labelSize?: number;
13
16
  }
14
17
 
15
- const ARROW_HEAD_SIZE = 10;
18
+ const ARROW_HEAD_SIZE = 14;
16
19
 
17
- export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0 }) => {
20
+ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0, strokeWidth = 3.5, labelSize = 22 }) => {
18
21
  const frame = useCurrentFrame();
19
22
  const { fps } = useVideoConfig();
20
23
  const elapsed = frame - startFrame;
@@ -50,7 +53,7 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
50
53
  x2={x2short}
51
54
  y2={y2short}
52
55
  stroke={theme.accent}
53
- strokeWidth={2.5}
56
+ strokeWidth={strokeWidth}
54
57
  strokeDasharray={len}
55
58
  strokeDashoffset={dashOffset}
56
59
  strokeLinecap="round"
@@ -68,9 +71,9 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
68
71
  {label && progress > 0.5 && (
69
72
  <text
70
73
  x={midX}
71
- y={midY - 10}
74
+ y={midY - 14}
72
75
  fill={theme.textMuted}
73
- fontSize={15}
76
+ fontSize={labelSize}
74
77
  textAnchor="middle"
75
78
  fontFamily={theme.fontSans}
76
79
  opacity={interpolate(progress, [0.5, 1], [0, 1])}
@@ -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",
@@ -61,19 +61,30 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
61
61
  framesPerLine = 9,
62
62
  }) => {
63
63
  const frame = useCurrentFrame();
64
- const { fps } = useVideoConfig();
64
+ const { fps, width: frameWidth } = useVideoConfig();
65
65
  const elapsed = frame - startFrame;
66
66
  const lines = code.split("\n");
67
+ // Scale type with the frame so the editor window commands wide canvases
68
+ // (ch-based width sizing inherits this automatically).
69
+ const fontSize = Math.max(22, Math.round(frameWidth * 0.0135));
67
70
  const visibleCount = Math.max(0, Math.min(lines.length, Math.floor(elapsed / framesPerLine) + 1));
68
71
 
72
+ // Size the window to the longest line of the FULL snippet (not the partial
73
+ // reveal) so it arrives at its final width and never stretches to fill the
74
+ // frame — an editor window several times wider than its code reads as empty
75
+ // chrome (gallery feedback). 83px covers gutter, line numbers, and padding.
76
+ const cols = Math.min(Math.max(lines.reduce((m, l) => Math.max(m, l.length), 0), 24), 88);
77
+
69
78
  return (
70
- <div style={{ fontFamily: theme.fontMono, fontSize: 20, lineHeight: 1.7 }}>
79
+ <div style={{ fontFamily: theme.fontMono, fontSize, lineHeight: 1.7, width: `calc(${cols}ch + 83px)`, maxWidth: "100%" }}>
71
80
  <div
72
81
  style={{
73
82
  background: "#0d1117",
74
- borderRadius: 10,
83
+ borderRadius: 12,
75
84
  overflow: "hidden",
76
- boxShadow: "0 8px 48px rgba(0,0,0,0.6)",
85
+ // Contact shadow + brand-light halo so the window sits in the stage.
86
+ boxShadow: `0 30px 90px -18px ${theme.accent}59, 0 24px 60px rgba(0,0,0,0.55)`,
87
+ outline: "1px solid rgba(255,255,255,0.09)",
77
88
  }}
78
89
  >
79
90
  {/* Title bar */}
@@ -95,9 +106,11 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
95
106
  </span>
96
107
  </div>
97
108
 
98
- {/* Code lines */}
109
+ {/* Code lines. All rows are laid out from frame 0 (unrevealed ones
110
+ invisible) so the window opens at its final height — the reveal
111
+ happens inside stable chrome instead of inflating it. */}
99
112
  <div style={{ padding: "20px 0" }}>
100
- {lines.slice(0, visibleCount).map((line, i) => {
113
+ {lines.map((line, i) => {
101
114
  const lineNum = i + 1;
102
115
  const isHighlight = highlightLine !== undefined && lineNum === highlightLine;
103
116
  const highlightOpacity =
@@ -115,6 +128,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
115
128
  style={{
116
129
  display: "flex",
117
130
  padding: "0 24px",
131
+ visibility: i < visibleCount ? "visible" : "hidden",
118
132
  background: isHighlight
119
133
  ? `rgba(${hexToRgb(theme.accent)}, ${0.15 * highlightOpacity})`
120
134
  : "transparent",
@@ -128,7 +142,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
128
142
  color: "#484f58",
129
143
  minWidth: 32,
130
144
  userSelect: "none",
131
- fontSize: 16,
145
+ fontSize: Math.round(fontSize * 0.8),
132
146
  paddingTop: 1,
133
147
  }}
134
148
  >
@@ -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
@@ -57,7 +79,6 @@ export const RevealText: React.FC<RevealTextProps> = ({
57
79
  lineHeight,
58
80
  letterSpacing,
59
81
  maxWidth,
60
- textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
61
82
  }}
62
83
  >
63
84
  {words.map((word, i) => {
@@ -65,17 +86,23 @@ export const RevealText: React.FC<RevealTextProps> = ({
65
86
  const p = spring({ frame: elapsed, fps, config: theme.motion });
66
87
  const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
67
88
  const opacity = interpolate(elapsed, [0, 6], [0, 1], {
89
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
68
90
  extrapolateLeft: "clamp",
69
91
  extrapolateRight: "clamp",
70
92
  });
71
- const clean = word.replace(/[.,!?:;]/g, "").toLowerCase();
72
- const isEmph = emphWord && (clean === emphWord || word.toLowerCase().includes(emphWord));
93
+ const isEmph = emphIndices.has(i);
94
+ // The clip mask exists only for the rise. Once the word is at rest the
95
+ // mask is released and the glow fades in — a soft 40px shadow inside an
96
+ // overflow-hidden span gets sheared into visible rectangles, so the two
97
+ // must never coexist (gallery feedback: "shadow looks cut off").
98
+ const settled = y < 1;
99
+ const shadowAlpha = glow && settled ? interpolate(p, [0.991, 1], [0, 0.45], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) : 0;
73
100
  return (
74
101
  <React.Fragment key={i}>
75
102
  <span
76
103
  style={{
77
104
  display: "inline-block",
78
- overflow: "hidden",
105
+ overflow: settled ? "visible" : "hidden",
79
106
  paddingBottom: "0.14em",
80
107
  marginBottom: "-0.14em",
81
108
  verticalAlign: "bottom",
@@ -84,9 +111,10 @@ export const RevealText: React.FC<RevealTextProps> = ({
84
111
  <span
85
112
  style={{
86
113
  display: "inline-block",
87
- transform: `translateY(${y}%)`,
114
+ translate: `0 ${y}%`,
88
115
  opacity,
89
116
  color: isEmph ? theme.accent : undefined,
117
+ textShadow: shadowAlpha > 0 ? `0 6px 40px rgba(0,0,0,${shadowAlpha.toFixed(3)})` : undefined,
90
118
  }}
91
119
  >
92
120
  {word}
@@ -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
+ };