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,106 @@
1
+ // Art-direction presets ("looks"). A look is the production design of a reel:
2
+ // how the atmosphere is lit, how the camera moves, how cuts are edited, and how
3
+ // the frame is graded. Two briefs with different looks should read as the work
4
+ // of two different studios, not the same template with a new accent color.
5
+ //
6
+ // Looks are pure data; colors come from the resolved Theme at render time.
7
+
8
+ import { MotionProfile } from "../theme";
9
+
10
+ export type LookId = "keynote" | "noir" | "arcade" | "blueprint" | "editorial";
11
+
12
+ export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom" | "wipe" | "flip";
13
+
14
+ export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
15
+
16
+ export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
17
+
18
+ export interface Grade {
19
+ /** Tint color layered over the whole frame. */
20
+ color: string;
21
+ blend: "multiply" | "screen" | "overlay" | "soft-light";
22
+ alpha: number;
23
+ }
24
+
25
+ export interface CinematicLook {
26
+ id: LookId;
27
+ /** Radial accent-light intensity behind the content (0..1). */
28
+ glow: number;
29
+ /** Whether a second, cooler light source is added for depth. */
30
+ twoTone: boolean;
31
+ /** Vignette darkness at the edges (0..1). */
32
+ vignette: number;
33
+ /** Film-grain opacity on the final frame (0..1); skipped for gif output. */
34
+ grain: number;
35
+ overlay: AtmosphereOverlay;
36
+ /** Base camera move applied to every scene's content. */
37
+ camera: CameraMove;
38
+ /** Camera amplitude multiplier (1 = default). */
39
+ cameraIntensity: number;
40
+ grade: Grade;
41
+ /** Editing energy: higher tightens enter timings and favors hard cuts. */
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;
50
+ }
51
+
52
+ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
53
+ // Clean keynote stage — soft key light, gentle push, long dissolves.
54
+ // Motion: settled, no bounce — elements arrive and hold.
55
+ keynote: {
56
+ glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
57
+ camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
58
+ energy: 0.4,
59
+ motion: { damping: 22, stiffness: 100, mass: 1.0 },
60
+ },
61
+ // Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
62
+ // Motion: heavy and deliberate — weighty mass, slow to settle.
63
+ noir: {
64
+ glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
65
+ camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
66
+ energy: 0.35,
67
+ motion: { damping: 28, stiffness: 78, mass: 1.4 },
68
+ },
69
+ // Saturated arcade — dual lights, scanlines, snappy whips and zooms.
70
+ // Motion: snappy with a playful overshoot — light mass, low damping.
71
+ arcade: {
72
+ glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
73
+ camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
74
+ energy: 0.85,
75
+ motion: { damping: 11, stiffness: 155, mass: 0.7 },
76
+ },
77
+ // Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
78
+ // Motion: stiff and precise — quick, minimal bounce, machined.
79
+ blueprint: {
80
+ glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
81
+ camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
82
+ energy: 0.6,
83
+ motion: { damping: 30, stiffness: 150, mass: 1.1 },
84
+ },
85
+ // Premium brand film — warm grade, big soft vignette, very slow elegant push.
86
+ // Motion: the smoothest — elegant glide, no perceptible bounce.
87
+ editorial: {
88
+ glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
89
+ camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
90
+ energy: 0.3,
91
+ motion: { damping: 26, stiffness: 88, mass: 1.15 },
92
+ },
93
+ };
94
+
95
+ const TONE_DEFAULT: Record<string, LookId> = {
96
+ professional: "keynote",
97
+ playful: "arcade",
98
+ technical: "blueprint",
99
+ };
100
+
101
+ export function resolveLook(look: string | undefined, tone: string | undefined): CinematicLook {
102
+ const id = (look && look in LOOKS ? look : TONE_DEFAULT[tone ?? "professional"] ?? "keynote") as LookId;
103
+ return { id, ...LOOKS[id] };
104
+ }
105
+
106
+ export const LOOK_IDS = Object.keys(LOOKS) as LookId[];
@@ -0,0 +1,110 @@
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", "flip", "zoom", "whip"],
12
+ blueprint: ["cut", "wipe", "cut", "whip"],
13
+ editorial: ["fade", "wipe", "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 scale: string | undefined;
47
+ let translate: string | undefined;
48
+ let filter: string | undefined;
49
+ let transform: string | undefined;
50
+ let clipPath: string | undefined;
51
+
52
+ switch (style) {
53
+ case "cut":
54
+ break;
55
+ case "fade":
56
+ opacity = interpolate(frame, [0, fromBlack ? win + 6 : win], [0, 1], ease);
57
+ break;
58
+ case "rise":
59
+ opacity = p;
60
+ translate = `0 ${interpolate(p, [0, 1], [44, 0])}px`;
61
+ break;
62
+ case "whip":
63
+ opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
64
+ translate = `${interpolate(p, [0, 1], [90 * dir, 0])}px 0`;
65
+ filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
66
+ break;
67
+ case "punch":
68
+ opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
69
+ scale = String(interpolate(p, [0, 1], [1.12, 1]));
70
+ break;
71
+ case "zoom":
72
+ opacity = p;
73
+ scale = String(interpolate(p, [0, 1], [0.9, 1]));
74
+ break;
75
+ case "dip":
76
+ // Content snaps in; a black veil over the top lifts away → dip-to-black.
77
+ opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
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;
92
+ }
93
+
94
+ const dipVeil = style === "dip" && (
95
+ <AbsoluteFill
96
+ style={{ background: "#000", opacity: interpolate(frame, [0, win], [1, 0], ease), pointerEvents: "none" }}
97
+ />
98
+ );
99
+
100
+ return (
101
+ <>
102
+ <AbsoluteFill
103
+ style={{ opacity, scale, translate, filter, transform, clipPath, willChange: "scale, translate, opacity, transform, clip-path" }}
104
+ >
105
+ {children}
106
+ </AbsoluteFill>
107
+ {dipVeil}
108
+ </>
109
+ );
110
+ };
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } 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
+ extrapolateLeft: "clamp",
26
+ extrapolateRight: "clamp",
27
+ });
28
+ const valueOpacity = interpolate(frame - startFrame, [10, 22], [0, 1], {
29
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
30
+ extrapolateLeft: "clamp",
31
+ extrapolateRight: "clamp",
32
+ });
33
+
34
+ return (
35
+ <div style={{ display: "flex", alignItems: "center", gap: 28 }}>
36
+ <div
37
+ style={{
38
+ width: 300,
39
+ textAlign: "right",
40
+ flexShrink: 0,
41
+ fontFamily: theme.fontSans,
42
+ fontSize: 30,
43
+ fontWeight: hero ? 700 : 500,
44
+ color: hero ? theme.text : theme.textMuted,
45
+ letterSpacing: "-0.01em",
46
+ }}
47
+ >
48
+ {label}
49
+ </div>
50
+
51
+ <div
52
+ style={{
53
+ flex: 1,
54
+ height: 56,
55
+ background: theme.surface,
56
+ borderRadius: 12,
57
+ display: "flex",
58
+ alignItems: "center",
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ width: `${width * 100}%`,
64
+ height: "100%",
65
+ background: hero ? theme.accent : theme.border,
66
+ borderRadius: 12,
67
+ flexShrink: 0,
68
+ }}
69
+ />
70
+ <div
71
+ style={{
72
+ opacity: valueOpacity,
73
+ marginLeft: 18,
74
+ whiteSpace: "nowrap",
75
+ fontFamily: theme.fontMono,
76
+ fontSize: 30,
77
+ fontWeight: 700,
78
+ color: hero ? theme.accent : theme.textMuted,
79
+ }}
80
+ >
81
+ {valueText}
82
+ </div>
83
+ </div>
84
+ </div>
85
+ );
86
+ };
@@ -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,13 +22,13 @@ 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",
28
29
  justifyContent: "center",
29
30
  opacity,
30
- transform: `translateY(${translateY}px)`,
31
+ translate: `0 ${translateY}px`,
31
32
  }}
32
33
  >
33
34
  <div
@@ -37,7 +38,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0 })
37
38
  borderRadius: 999,
38
39
  padding: "10px 28px",
39
40
  fontFamily: theme.fontSans,
40
- fontSize: 22,
41
+ fontSize: 32,
41
42
  fontWeight: 500,
42
43
  color: theme.text,
43
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
  >
@@ -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,9 +9,13 @@ 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
- 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 };
15
19
 
16
20
  export const Label: React.FC<LabelProps> = ({
17
21
  text,
@@ -20,30 +24,23 @@ export const Label: React.FC<LabelProps> = ({
20
24
  size = "md",
21
25
  align = "center",
22
26
  muted = false,
27
+ emphasis,
23
28
  }) => {
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
-
29
+ const big = size === "xl" || size === "lg";
32
30
  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>
31
+ <RevealText
32
+ text={text}
33
+ theme={theme}
34
+ startFrame={startFrame}
35
+ fontSize={sizeMap[size]}
36
+ fontWeight={big ? 700 : 500}
37
+ color={muted ? theme.textMuted : theme.text}
38
+ align={align}
39
+ emphasis={emphasis}
40
+ stagger={big ? 3 : 1.5}
41
+ letterSpacing={size === "xl" ? "-0.03em" : "-0.01em"}
42
+ lineHeight={big ? 1.1 : 1.25}
43
+ glow={big && !muted}
44
+ />
48
45
  );
49
46
  };
@@ -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
+ };
@@ -0,0 +1,123 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } 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
+
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
+ }
70
+
71
+ return (
72
+ <div
73
+ style={{
74
+ fontFamily: theme.fontSans,
75
+ fontSize,
76
+ fontWeight,
77
+ color: color ?? theme.text,
78
+ textAlign: align,
79
+ lineHeight,
80
+ letterSpacing,
81
+ maxWidth,
82
+ textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
83
+ }}
84
+ >
85
+ {words.map((word, i) => {
86
+ const elapsed = frame - startFrame - i * stagger;
87
+ const p = spring({ frame: elapsed, fps, config: theme.motion });
88
+ const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
89
+ const opacity = interpolate(elapsed, [0, 6], [0, 1], {
90
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
91
+ extrapolateLeft: "clamp",
92
+ extrapolateRight: "clamp",
93
+ });
94
+ const isEmph = emphIndices.has(i);
95
+ return (
96
+ <React.Fragment key={i}>
97
+ <span
98
+ style={{
99
+ display: "inline-block",
100
+ overflow: "hidden",
101
+ paddingBottom: "0.14em",
102
+ marginBottom: "-0.14em",
103
+ verticalAlign: "bottom",
104
+ }}
105
+ >
106
+ <span
107
+ style={{
108
+ display: "inline-block",
109
+ translate: `0 ${y}%`,
110
+ opacity,
111
+ color: isEmph ? theme.accent : undefined,
112
+ }}
113
+ >
114
+ {word}
115
+ </span>
116
+ </span>
117
+ {i < words.length - 1 ? " " : null}
118
+ </React.Fragment>
119
+ );
120
+ })}
121
+ </div>
122
+ );
123
+ };