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
@@ -1,9 +1,14 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, Sequence, useCurrentFrame, interpolate } from "remotion";
2
+ import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remotion";
3
3
  import { Brief, ProjectMeta, Scene } from "./brief";
4
4
  import { PlatformPreset } from "./platforms";
5
5
  import { buildTheme } from "./theme";
6
6
  import { sceneDuration } from "./duration";
7
+ import { audioVolume } from "./audio";
8
+ import { resolveLook } from "./cinematic/look";
9
+ import { Atmosphere, Grain } from "./cinematic/Atmosphere";
10
+ import { Camera } from "./cinematic/Camera";
11
+ import { Enter, transitionFor } from "./cinematic/transitions";
7
12
  import "./fonts";
8
13
  import { Problem } from "./components/scenes/Problem";
9
14
  import { CodeReveal } from "./components/scenes/CodeReveal";
@@ -18,33 +23,9 @@ import { FileTree } from "./components/scenes/FileTree";
18
23
  import { MobileScreen } from "./components/scenes/MobileScreen";
19
24
  import { OSWindow } from "./components/scenes/OSWindowScene";
20
25
  import { Hotkey } from "./components/scenes/HotkeyScene";
21
-
22
- const FADE_IN = 12;
23
- const FADE_OUT = 15;
24
-
25
- const TransitionEnvelope: React.FC<{
26
- durationInFrames: number;
27
- transition: "fade" | "slide" | "zoom";
28
- children: React.ReactNode;
29
- }> = ({ durationInFrames, transition, children }) => {
30
- const frame = useCurrentFrame();
31
- const keys = [0, FADE_IN, durationInFrames - FADE_OUT, durationInFrames];
32
- const opts = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const };
33
-
34
- const opacity = interpolate(frame, keys, [0, 1, 1, 0], opts);
35
-
36
- if (transition === "slide") {
37
- const translateY = interpolate(frame, keys, [30, 0, 0, -30], opts);
38
- return <AbsoluteFill style={{ opacity, transform: `translateY(${translateY}px)` }}>{children}</AbsoluteFill>;
39
- }
40
-
41
- if (transition === "zoom") {
42
- const scale = interpolate(frame, keys, [0.95, 1, 1, 1.04], opts);
43
- return <AbsoluteFill style={{ opacity, transform: `scale(${scale})` }}>{children}</AbsoluteFill>;
44
- }
45
-
46
- return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
47
- };
26
+ import { Hook } from "./components/scenes/Hook";
27
+ import { Clip } from "./components/scenes/Clip";
28
+ import { Benchmark } from "./components/scenes/Benchmark";
48
29
 
49
30
  interface ReelProps {
50
31
  brief: Brief;
@@ -55,7 +36,16 @@ interface ReelProps {
55
36
  }
56
37
 
57
38
  export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
58
- const theme = buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle);
39
+ const { durationInFrames, fps } = useVideoConfig();
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
+ };
47
+ const isGif = platform.output.codec === "gif";
48
+ const shouldRenderAudio = !isGif && Boolean(brief.project.audio);
59
49
 
60
50
  // Cut selection: teaser when requested, otherwise the cut named by the
61
51
  // platform preset, falling back to main when vertical is absent.
@@ -63,7 +53,7 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
63
53
  cut === "teaser"
64
54
  ? brief.cuts.teaser ?? []
65
55
  : platform.cut === "vertical"
66
- ? brief.cuts.vertical ?? brief.cuts.main
56
+ ? (brief.cuts.vertical?.length ? brief.cuts.vertical : brief.cuts.main)
67
57
  : brief.cuts.main;
68
58
 
69
59
  let cursor = 0;
@@ -74,15 +64,39 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
74
64
  return { scene, from, duration };
75
65
  });
76
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
+
77
73
  return (
78
74
  <AbsoluteFill style={{ background: theme.bg }}>
79
- {sequenced.map(({ scene, from, duration }, i) => (
80
- <Sequence key={i} from={from} durationInFrames={duration}>
81
- <TransitionEnvelope durationInFrames={duration} transition={brief.project.transition ?? "fade"}>
82
- <SceneRenderer scene={scene} theme={theme} project={brief.project} />
83
- </TransitionEnvelope>
84
- </Sequence>
85
- ))}
75
+ <Atmosphere theme={theme} look={look} quality={isGif ? "lite" : "full"} />
76
+
77
+ {shouldRenderAudio && brief.project.audio ? (
78
+ <Audio
79
+ src={staticFile(`audio/${brief.project.audio.track}`)}
80
+ loop
81
+ volume={(frame) =>
82
+ audioVolume(frame, durationInFrames, brief.project.audio ? brief.project.audio.volume : undefined)
83
+ }
84
+ />
85
+ ) : null}
86
+
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>
98
+
99
+ {!isGif && <Grain look={look} />}
86
100
  </AbsoluteFill>
87
101
  );
88
102
  };
@@ -91,36 +105,44 @@ interface SceneRendererProps {
91
105
  scene: Scene;
92
106
  theme: ReturnType<typeof buildTheme>;
93
107
  project: ProjectMeta;
108
+ platform: PlatformPreset;
94
109
  }
95
110
 
96
- const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project }) => {
111
+ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, platform }) => {
112
+ const bottomInset = platform.safeArea?.bottom ?? 0;
97
113
  switch (scene.type) {
98
114
  case "problem":
99
- return <Problem scene={scene} theme={theme} project={project} />;
115
+ return <Problem scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
100
116
  case "code-reveal":
101
- return <CodeReveal scene={scene} theme={theme} />;
117
+ return <CodeReveal scene={scene} theme={theme} bottomInset={bottomInset} />;
102
118
  case "terminal":
103
- return <TerminalScene scene={scene} theme={theme} />;
119
+ return <TerminalScene scene={scene} theme={theme} bottomInset={bottomInset} />;
104
120
  case "data-flow":
105
- return <DataFlow scene={scene} theme={theme} />;
121
+ return <DataFlow scene={scene} theme={theme} bottomInset={bottomInset} />;
106
122
  case "cta":
107
- return <CTA scene={scene} theme={theme} project={project} />;
123
+ return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
108
124
  case "browser":
109
- return <BrowserFrame scene={scene} theme={theme} />;
125
+ return <BrowserFrame scene={scene} theme={theme} bottomInset={bottomInset} />;
110
126
  case "split":
111
- return <SplitComparison scene={scene} theme={theme} />;
127
+ return <SplitComparison scene={scene} theme={theme} bottomInset={bottomInset} />;
112
128
  case "feature-list":
113
- return <FeatureList scene={scene} theme={theme} />;
129
+ return <FeatureList scene={scene} theme={theme} platform={platform} bottomInset={bottomInset} />;
114
130
  case "stat-callout":
115
- return <StatCallout scene={scene} theme={theme} />;
131
+ return <StatCallout scene={scene} theme={theme} bottomInset={bottomInset} />;
116
132
  case "file-tree":
117
- return <FileTree scene={scene} theme={theme} />;
133
+ return <FileTree scene={scene} theme={theme} bottomInset={bottomInset} />;
118
134
  case "mobile":
119
- return <MobileScreen scene={scene} theme={theme} />;
135
+ return <MobileScreen scene={scene} theme={theme} bottomInset={bottomInset} />;
120
136
  case "os-window":
121
- return <OSWindow scene={scene} theme={theme} />;
137
+ return <OSWindow scene={scene} theme={theme} bottomInset={bottomInset} />;
122
138
  case "hotkey":
123
- return <Hotkey scene={scene} theme={theme} />;
139
+ return <Hotkey scene={scene} theme={theme} bottomInset={bottomInset} />;
140
+ case "hook":
141
+ return <Hook scene={scene} theme={theme} platform={platform} />;
142
+ case "clip":
143
+ return <Clip scene={scene} theme={theme} bottomInset={bottomInset} />;
144
+ case "benchmark":
145
+ return <Benchmark scene={scene} theme={theme} bottomInset={bottomInset} />;
124
146
  default:
125
147
  return null;
126
148
  }
@@ -0,0 +1,24 @@
1
+ const FADE_OUT_FRAMES = 45;
2
+ const DEFAULT_AUDIO_VOLUME = 0.25;
3
+
4
+ function clamp01(value: number) {
5
+ if (Number.isNaN(value)) return DEFAULT_AUDIO_VOLUME;
6
+ return Math.min(1, Math.max(0, value));
7
+ }
8
+
9
+ export function audioVolume(
10
+ frame: number,
11
+ durationInFrames: number,
12
+ base = DEFAULT_AUDIO_VOLUME
13
+ ) {
14
+ const volume = clamp01(base);
15
+ if (durationInFrames <= 0) return 0;
16
+
17
+ const fadeStart = Math.max(0, durationInFrames - FADE_OUT_FRAMES);
18
+ if (frame <= fadeStart) return volume;
19
+ if (frame >= durationInFrames) return 0;
20
+
21
+ const fadeLength = Math.max(1, durationInFrames - fadeStart);
22
+ const progress = (frame - fadeStart) / fadeLength;
23
+ return volume * (1 - progress);
24
+ }
@@ -0,0 +1,18 @@
1
+ // Pure bar-length logic for the benchmark scene, extracted so it can be unit
2
+ // tested. Returns a fraction in (0, 1] per value; the winner is always 1.0 so
3
+ // it reads as the longest bar regardless of metric direction.
4
+ export function benchmarkFractions(
5
+ values: number[],
6
+ lowerIsBetter = false
7
+ ): number[] {
8
+ const safe = values.map((v) => (Number.isFinite(v) && v > 0 ? v : 0));
9
+
10
+ if (lowerIsBetter) {
11
+ const positives = safe.filter((v) => v > 0);
12
+ const min = positives.length ? Math.min(...positives) : 0;
13
+ return safe.map((v) => (v > 0 && min > 0 ? min / v : 0));
14
+ }
15
+
16
+ const max = Math.max(...safe, 0);
17
+ return safe.map((v) => (max > 0 ? v / max : 0));
18
+ }
@@ -10,6 +10,7 @@
10
10
  "tone": "playful",
11
11
  "mode": "intro",
12
12
  "bgStyle": "branded",
13
+ "audio": { "track": "bright-sparks.mp3" },
13
14
  "platforms": ["x", "tiktok", "github-readme"]
14
15
  },
15
16
  "cuts": {
@@ -60,11 +61,11 @@
60
61
  "title": "Sprout Commands",
61
62
  "searchQuery": "sprout",
62
63
  "items": [
63
- { "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 },
64
65
  { "icon": "database", "label": "sprout db:push", "value": "Sync schema to database" },
65
- { "icon": "user-check", "label": "sprout auth:setup", "value": "Configure OAuth providers" },
66
+ { "icon": "key", "label": "sprout auth:setup", "value": "Configure OAuth providers" },
66
67
  { "icon": "package", "label": "sprout add", "value": "Add a feature module" },
67
- { "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" }
68
69
  ],
69
70
  "caption": "Every task is one command away."
70
71
  },
@@ -81,8 +82,8 @@
81
82
  { "text": "Type-safe API layer with tRPC out of the box", "icon": "shield" },
82
83
  { "text": "Auth in one command — email, GitHub, Google", "icon": "lock" },
83
84
  { "text": "Database ORM with auto-generated migrations", "icon": "database" },
84
- { "text": "CI/CD templates for GitHub Actions baked in", "icon": "git-branch" },
85
- { "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" }
86
87
  ],
87
88
  "caption": "Start building features on day one."
88
89
  },
@@ -116,7 +117,7 @@
116
117
  "items": [
117
118
  { "text": "Type-safe API layer out of the box", "icon": "shield" },
118
119
  { "text": "Auth in one command", "icon": "lock" },
119
- { "text": "One-click deploy", "icon": "rocket" }
120
+ { "text": "One-click deploy", "icon": "cloud" }
120
121
  ],
121
122
  "caption": "Start building on day one."
122
123
  },
@@ -1,9 +1,16 @@
1
1
  import { PlatformId } from "./platforms";
2
+ import { LookId } from "./cinematic/look";
2
3
 
3
4
  // Brief schema version. Bump on breaking changes to the reelme.json contract;
4
5
  // the CLI refuses briefs whose schemaVersion doesn't match.
5
6
  export const BRIEF_SCHEMA_VERSION = 2;
6
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
+
7
14
  export interface ProjectMeta {
8
15
  name: string;
9
16
  tagline: string;
@@ -14,17 +21,21 @@ export interface ProjectMeta {
14
21
  tone: "professional" | "playful" | "technical";
15
22
  /** Publishing targets; required, at least one. Presets derive dimensions. */
16
23
  platforms: PlatformId[];
17
- /** Bundled CC0 track selection; false disables audio. (Playback lands in Phase 2.) */
24
+ /** Bundled CC0 track selection; false disables audio. */
18
25
  audio?: { track: string; volume?: number } | false;
19
- /** "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. */
20
27
  watermark?: boolean;
21
28
  mode?: "intro" | "announcement";
22
29
  version?: string;
23
30
  logo?: string;
24
31
  font?: string;
25
32
  monoFont?: string;
26
- transition?: "fade" | "slide" | "zoom";
27
33
  bgStyle?: "deep" | "branded" | "light";
34
+ /**
35
+ * Art-direction preset: lighting, camera, grade, grain, and cut rhythm.
36
+ * Defaults from tone (professional→keynote, playful→arcade, technical→blueprint).
37
+ */
38
+ look?: LookId;
28
39
  }
29
40
 
30
41
  export interface ProblemScene {
@@ -33,6 +44,10 @@ export interface ProblemScene {
33
44
  subtext?: string;
34
45
  caption?: string;
35
46
  hero?: boolean;
47
+ /** Small eyebrow label above the headline (e.g. the product name). */
48
+ kicker?: string;
49
+ /** Composition: centered (default) or left-anchored with negative space. */
50
+ align?: "center" | "left";
36
51
  }
37
52
 
38
53
  export interface CodeRevealScene {
@@ -108,6 +123,8 @@ export interface FeatureListScene {
108
123
  headline?: string;
109
124
  items: Array<string | FeatureItem>;
110
125
  caption?: string;
126
+ /** Composition: centered (default) or left-anchored. */
127
+ align?: "center" | "left";
111
128
  }
112
129
 
113
130
  export interface StatItem {
@@ -120,6 +137,8 @@ export interface StatCalloutScene {
120
137
  headline?: string;
121
138
  stats: StatItem[];
122
139
  caption?: string;
140
+ /** "hero" renders the first stat at giant scale (one dominant number). */
141
+ layout?: "row" | "hero";
123
142
  }
124
143
 
125
144
  export interface FileTreeEntry {
@@ -138,7 +157,22 @@ export interface FileTreeScene {
138
157
  export interface MobileScene {
139
158
  type: "mobile";
140
159
  title?: string;
141
- image?: string;
160
+ screenshot?: string;
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[];
168
+ }
169
+
170
+ export interface ClipScene {
171
+ type: "clip";
172
+ src: string;
173
+ frame: "browser" | "mobile" | "none";
174
+ startFrom?: number;
175
+ durationInFrames?: number;
142
176
  caption?: string;
143
177
  }
144
178
 
@@ -164,6 +198,37 @@ export interface HotkeyScene {
164
198
  caption?: string;
165
199
  }
166
200
 
201
+ export interface HookScene {
202
+ type: "hook";
203
+ text: string;
204
+ accent?: string;
205
+ /** Small eyebrow label above the hook (e.g. the product name). */
206
+ kicker?: string;
207
+ /** Composition: centered (default) or left-anchored with negative space. */
208
+ align?: "center" | "left";
209
+ }
210
+
211
+ export interface BenchmarkBar {
212
+ label: string;
213
+ /** Raw metric value; bar length is derived from it. */
214
+ value: number;
215
+ /** Text shown on the bar, e.g. "0.3s". Falls back to the value. */
216
+ display?: string;
217
+ /** The project's own bar — highlighted in the accent color. */
218
+ hero?: boolean;
219
+ }
220
+
221
+ export interface BenchmarkScene {
222
+ type: "benchmark";
223
+ headline?: string;
224
+ /** Metric description, e.g. "Search time (lower is better)". */
225
+ metric?: string;
226
+ /** When true, the smallest value wins (gets the longest bar). */
227
+ lowerIsBetter?: boolean;
228
+ bars: BenchmarkBar[];
229
+ caption?: string;
230
+ }
231
+
167
232
  export type Scene =
168
233
  | ProblemScene
169
234
  | CodeRevealScene
@@ -177,7 +242,10 @@ export type Scene =
177
242
  | FileTreeScene
178
243
  | MobileScene
179
244
  | OSWindowScene
180
- | HotkeyScene;
245
+ | HotkeyScene
246
+ | HookScene
247
+ | BenchmarkScene
248
+ | ClipScene;
181
249
 
182
250
  export interface Cuts {
183
251
  /** The full narrative arc. Always required. */
@@ -0,0 +1,139 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, interpolate, random } from "remotion";
3
+ import chroma from "chroma-js";
4
+ import { Theme } from "../theme";
5
+ import { CinematicLook } from "./look";
6
+
7
+ interface Props {
8
+ theme: Theme;
9
+ look: CinematicLook;
10
+ /** "lite" drops grain and softens overlays for gif output. */
11
+ quality: "full" | "lite";
12
+ }
13
+
14
+ /** Slow oscillation in [-1, 1] with a per-axis phase, for drifting lights. */
15
+ function drift(frame: number, period: number, phase: number) {
16
+ return Math.sin((frame / period) * Math.PI * 2 + phase);
17
+ }
18
+
19
+ /**
20
+ * The continuous stage the reel is shot on. Rendered once, behind every scene,
21
+ * so the background never resets between cuts — that single fact is what turns
22
+ * a deck of slides into one film. Lights drift slowly over the whole runtime.
23
+ */
24
+ export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
25
+ const liveFrame = useCurrentFrame();
26
+ const lite = quality === "lite";
27
+ // Gif output freezes the lights: a frame that changes every tick defeats gif
28
+ // compression and balloons file size. The graded look stays; only motion goes.
29
+ const frame = lite ? 0 : liveFrame;
30
+
31
+ const accent = chroma(theme.accent);
32
+ const cool = accent.set("hsl.h", "+150").desaturate(0.8).hex();
33
+
34
+ // Primary key light drifts across the upper third.
35
+ const keyX = 50 + drift(frame, 520, 0) * 14;
36
+ const keyY = 32 + drift(frame, 680, 1.3) * 8;
37
+ // Secondary fill light (only on two-tone looks) sweeps the opposite corner.
38
+ const fillX = 24 + drift(frame, 600, 2.1) * 12;
39
+ const fillY = 74 + drift(frame, 740, 0.6) * 10;
40
+
41
+ const glowAlpha = look.glow * (lite ? 0.6 : 1);
42
+ // Patterned overlays (1px grid/scanlines) are GIF-compression poison — every
43
+ // frame becomes high-frequency detail. Drop them on the lite path.
44
+ const showOverlay = !lite;
45
+ // Gif compresses flat areas; smooth wide gradients (worst with bright accents)
46
+ // explode its 256-color palette. On lite, the key light is a smaller, tighter
47
+ // pool and the fill light is dropped, leaving most of the frame the flat base.
48
+ const keyGradient = lite
49
+ ? `radial-gradient(42% 38% at ${keyX}% ${keyY}%, ${accent.alpha(0.34 * glowAlpha).css()} 0%, transparent 62%)`
50
+ : `radial-gradient(60% 55% at ${keyX}% ${keyY}%, ${accent.alpha(0.55 * glowAlpha).css()} 0%, ${accent.alpha(0.12 * glowAlpha).css()} 38%, transparent 70%)`;
51
+
52
+ return (
53
+ <AbsoluteFill style={{ background: theme.bg }}>
54
+ {/* Key light */}
55
+ <AbsoluteFill style={{ background: keyGradient }} />
56
+ {/* Fill light */}
57
+ {look.twoTone && !lite && (
58
+ <AbsoluteFill
59
+ style={{
60
+ background: `radial-gradient(55% 50% at ${fillX}% ${fillY}%, ${chroma(cool).alpha(0.4 * glowAlpha).css()} 0%, transparent 68%)`,
61
+ }}
62
+ />
63
+ )}
64
+
65
+ {showOverlay && look.overlay === "grid" && <GridOverlay color={theme.border} lite={lite} />}
66
+ {showOverlay && look.overlay === "scanlines" && <Scanlines lite={lite} />}
67
+
68
+ {/* Grade pass: a subtle full-frame tint that unifies the palette. */}
69
+ <AbsoluteFill
70
+ style={{
71
+ background: look.grade.color,
72
+ mixBlendMode: look.grade.blend,
73
+ opacity: look.grade.alpha * (lite ? 0.5 : 1),
74
+ }}
75
+ />
76
+
77
+ {/* Vignette: darkens the edges so the eye stays centered. Softened on lite
78
+ so the gif stays mostly flat base color and compresses. */}
79
+ <AbsoluteFill
80
+ style={{
81
+ background: `radial-gradient(120% 120% at 50% 48%, transparent 45%, rgba(0,0,0,${lite ? Math.min(look.vignette, 0.22) : look.vignette}) 100%)`,
82
+ }}
83
+ />
84
+ </AbsoluteFill>
85
+ );
86
+ };
87
+
88
+ const GridOverlay: React.FC<{ color: string; lite: boolean }> = ({ color, lite }) => {
89
+ const frame = useCurrentFrame();
90
+ const shift = lite ? 0 : (frame * 0.15) % 64;
91
+ const line = chroma(color).alpha(0.12).css();
92
+ return (
93
+ <AbsoluteFill
94
+ style={{
95
+ backgroundImage: `linear-gradient(${line} 1px, transparent 1px), linear-gradient(90deg, ${line} 1px, transparent 1px)`,
96
+ backgroundSize: "64px 64px",
97
+ backgroundPosition: `${shift}px ${shift}px`,
98
+ maskImage: "radial-gradient(120% 100% at 50% 50%, black 30%, transparent 80%)",
99
+ WebkitMaskImage: "radial-gradient(120% 100% at 50% 50%, black 30%, transparent 80%)",
100
+ }}
101
+ />
102
+ );
103
+ };
104
+
105
+ const Scanlines: React.FC<{ lite: boolean }> = ({ lite }) => (
106
+ <AbsoluteFill
107
+ style={{
108
+ backgroundImage: `repeating-linear-gradient(0deg, rgba(0,0,0,${lite ? 0.04 : 0.07}) 0px, rgba(0,0,0,${lite ? 0.04 : 0.07}) 1px, transparent 2px, transparent 4px)`,
109
+ }}
110
+ />
111
+ );
112
+
113
+ /**
114
+ * Film grain laid over the finished frame. Animated for video (a fresh noise
115
+ * tile every couple of frames reads as real grain); skipped entirely for gif,
116
+ * where it would balloon file size for no benefit.
117
+ */
118
+ export const Grain: React.FC<{ look: CinematicLook }> = ({ look }) => {
119
+ const frame = useCurrentFrame();
120
+ if (look.grain <= 0) return null;
121
+ // Step the noise so it shimmers without recomputing every single frame.
122
+ const seed = Math.floor(frame / 2);
123
+ const baseFreq = 0.9;
124
+ const turbulence = `<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='${baseFreq}' numOctaves='2' seed='${seed % 97}' stitchTiles='stitch'/><feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/></filter><rect width='180' height='180' filter='url(%23n)'/></svg>`;
125
+ const url = `data:image/svg+xml;utf8,${turbulence}`;
126
+ const jitter = interpolate(random(`grain-${seed}`), [0, 1], [-4, 4]);
127
+ return (
128
+ <AbsoluteFill
129
+ style={{
130
+ backgroundImage: `url("${url}")`,
131
+ backgroundSize: "180px 180px",
132
+ backgroundPosition: `${jitter}px ${-jitter}px`,
133
+ opacity: look.grain,
134
+ mixBlendMode: "overlay",
135
+ pointerEvents: "none",
136
+ }}
137
+ />
138
+ );
139
+ };
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
3
+ import { CinematicLook } from "./look";
4
+
5
+ interface Props {
6
+ look: CinematicLook;
7
+ durationInFrames: number;
8
+ /** Per-scene seed so consecutive scenes don't move identically. */
9
+ seed: number;
10
+ /** Disables the move (gif output): a moving frame defeats gif compression. */
11
+ disabled?: boolean;
12
+ children: React.ReactNode;
13
+ }
14
+
15
+ /**
16
+ * A virtual camera over a scene's content. Movement is pure zoom — in, out, or a
17
+ * gentle breathe — anchored to the center so the composition never slides
18
+ * sideways. Amplitudes are small so the frame breathes without softening text.
19
+ */
20
+ export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled, children }) => {
21
+ const frame = useCurrentFrame();
22
+ if (disabled) return <AbsoluteFill>{children}</AbsoluteFill>;
23
+
24
+ const p = interpolate(frame, [0, durationInFrames], [0, 1], {
25
+ extrapolateLeft: "clamp",
26
+ extrapolateRight: "clamp",
27
+ });
28
+ const k = look.cameraIntensity;
29
+
30
+ let scale = 1;
31
+ switch (look.camera) {
32
+ case "push":
33
+ scale = interpolate(p, [0, 1], [1.0, 1 + 0.05 * k]); // slow zoom in
34
+ break;
35
+ case "drift":
36
+ scale = interpolate(p, [0, 1], [1.0, 1 + 0.03 * k]); // gentle zoom in
37
+ break;
38
+ case "pan":
39
+ scale = interpolate(p, [0, 1], [1 + 0.05 * k, 1.0]); // slow zoom out
40
+ break;
41
+ case "float":
42
+ scale = 1 + (0.02 + 0.015 * Math.sin(frame / 42 + seed)) * k; // subtle breathe
43
+ break;
44
+ case "still":
45
+ default:
46
+ break;
47
+ }
48
+
49
+ return (
50
+ <AbsoluteFill style={{ scale: String(scale), transformOrigin: "center center", willChange: "scale" }}>
51
+ {children}
52
+ </AbsoluteFill>
53
+ );
54
+ };