reelme 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/assets/audio/PROVENANCE.md +16 -0
  2. package/assets/audio/bright-sparks.mp3 +0 -0
  3. package/assets/audio/calm-keys.mp3 +0 -0
  4. package/assets/audio/circuit-pulse.mp3 +0 -0
  5. package/assets/audio/clean-horizon.mp3 +0 -0
  6. package/assets/audio/generate-tracks.mjs +213 -0
  7. package/assets/audio/manifest.json +83 -0
  8. package/assets/audio/midnight-protocol.mp3 +0 -0
  9. package/assets/audio/pixel-bounce.mp3 +0 -0
  10. package/assets/audio/steady-launch.mp3 +0 -0
  11. package/assets/audio/sunny-loop.mp3 +0 -0
  12. package/assets/audio/vector-grid.mp3 +0 -0
  13. package/package.json +2 -1
  14. package/src/cache.mjs +1 -0
  15. package/src/render.mjs +95 -4
  16. package/template/package.json +1 -0
  17. package/template/pnpm-lock.yaml +15 -0
  18. package/template/src/Root.tsx +55 -45
  19. package/template/src/audio.ts +24 -0
  20. package/template/src/benchmark.ts +18 -0
  21. package/template/src/brief.json +1 -0
  22. package/template/src/brief.ts +61 -3
  23. package/template/src/cinematic/Atmosphere.tsx +139 -0
  24. package/template/src/cinematic/Camera.tsx +54 -0
  25. package/template/src/cinematic/look.ts +89 -0
  26. package/template/src/cinematic/transitions.tsx +92 -0
  27. package/template/src/components/primitives/Bar.tsx +82 -0
  28. package/template/src/components/primitives/Caption.tsx +3 -2
  29. package/template/src/components/primitives/Kicker.tsx +46 -0
  30. package/template/src/components/primitives/Label.tsx +18 -24
  31. package/template/src/components/primitives/RevealText.tsx +101 -0
  32. package/template/src/components/primitives/Terminal.tsx +11 -0
  33. package/template/src/components/scenes/Benchmark.tsx +83 -0
  34. package/template/src/components/scenes/BrowserFrame.tsx +4 -3
  35. package/template/src/components/scenes/CTA.tsx +56 -10
  36. package/template/src/components/scenes/Clip.tsx +125 -0
  37. package/template/src/components/scenes/CodeReveal.tsx +4 -3
  38. package/template/src/components/scenes/DataFlow.tsx +4 -3
  39. package/template/src/components/scenes/FeatureList.tsx +13 -8
  40. package/template/src/components/scenes/FileTree.tsx +4 -3
  41. package/template/src/components/scenes/Hook.tsx +55 -0
  42. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  43. package/template/src/components/scenes/MobileScreen.tsx +120 -82
  44. package/template/src/components/scenes/OSWindowScene.tsx +4 -3
  45. package/template/src/components/scenes/Problem.tsx +28 -29
  46. package/template/src/components/scenes/SplitComparison.tsx +65 -92
  47. package/template/src/components/scenes/StatCallout.tsx +93 -4
  48. package/template/src/components/scenes/TerminalScene.tsx +5 -6
  49. package/template/src/duration.ts +12 -1
  50. package/template/src/platforms.ts +4 -0
@@ -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,11 @@ interface ReelProps {
55
36
  }
56
37
 
57
38
  export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
39
+ const { durationInFrames } = useVideoConfig();
58
40
  const theme = buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle);
41
+ const look = resolveLook(brief.project.look, brief.project.tone);
42
+ const isGif = platform.output.codec === "gif";
43
+ const shouldRenderAudio = !isGif && Boolean(brief.project.audio);
59
44
 
60
45
  // Cut selection: teaser when requested, otherwise the cut named by the
61
46
  // platform preset, falling back to main when vertical is absent.
@@ -76,13 +61,29 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
76
61
 
77
62
  return (
78
63
  <AbsoluteFill style={{ background: theme.bg }}>
64
+ <Atmosphere theme={theme} look={look} quality={isGif ? "lite" : "full"} />
65
+
66
+ {shouldRenderAudio && brief.project.audio ? (
67
+ <Audio
68
+ src={staticFile(`audio/${brief.project.audio.track}`)}
69
+ loop
70
+ volume={(frame) =>
71
+ audioVolume(frame, durationInFrames, brief.project.audio ? brief.project.audio.volume : undefined)
72
+ }
73
+ />
74
+ ) : null}
75
+
79
76
  {sequenced.map(({ scene, from, duration }, i) => (
80
77
  <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>
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>
84
83
  </Sequence>
85
84
  ))}
85
+
86
+ {!isGif && <Grain look={look} />}
86
87
  </AbsoluteFill>
87
88
  );
88
89
  };
@@ -91,36 +92,45 @@ interface SceneRendererProps {
91
92
  scene: Scene;
92
93
  theme: ReturnType<typeof buildTheme>;
93
94
  project: ProjectMeta;
95
+ platform: PlatformPreset;
94
96
  }
95
97
 
96
- const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project }) => {
98
+ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, platform }) => {
99
+ const bottomInset = platform.safeArea?.bottom ?? 0;
100
+ const lite = platform.output.codec === "gif";
97
101
  switch (scene.type) {
98
102
  case "problem":
99
- return <Problem scene={scene} theme={theme} project={project} />;
103
+ return <Problem scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
100
104
  case "code-reveal":
101
- return <CodeReveal scene={scene} theme={theme} />;
105
+ return <CodeReveal scene={scene} theme={theme} bottomInset={bottomInset} />;
102
106
  case "terminal":
103
- return <TerminalScene scene={scene} theme={theme} />;
107
+ return <TerminalScene scene={scene} theme={theme} bottomInset={bottomInset} />;
104
108
  case "data-flow":
105
- return <DataFlow scene={scene} theme={theme} />;
109
+ return <DataFlow scene={scene} theme={theme} bottomInset={bottomInset} />;
106
110
  case "cta":
107
- return <CTA scene={scene} theme={theme} project={project} />;
111
+ return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} lite={lite} />;
108
112
  case "browser":
109
- return <BrowserFrame scene={scene} theme={theme} />;
113
+ return <BrowserFrame scene={scene} theme={theme} bottomInset={bottomInset} />;
110
114
  case "split":
111
- return <SplitComparison scene={scene} theme={theme} />;
115
+ return <SplitComparison scene={scene} theme={theme} bottomInset={bottomInset} />;
112
116
  case "feature-list":
113
- return <FeatureList scene={scene} theme={theme} />;
117
+ return <FeatureList scene={scene} theme={theme} platform={platform} bottomInset={bottomInset} />;
114
118
  case "stat-callout":
115
- return <StatCallout scene={scene} theme={theme} />;
119
+ return <StatCallout scene={scene} theme={theme} bottomInset={bottomInset} />;
116
120
  case "file-tree":
117
- return <FileTree scene={scene} theme={theme} />;
121
+ return <FileTree scene={scene} theme={theme} bottomInset={bottomInset} />;
118
122
  case "mobile":
119
- return <MobileScreen scene={scene} theme={theme} />;
123
+ return <MobileScreen scene={scene} theme={theme} bottomInset={bottomInset} />;
120
124
  case "os-window":
121
- return <OSWindow scene={scene} theme={theme} />;
125
+ return <OSWindow scene={scene} theme={theme} bottomInset={bottomInset} />;
122
126
  case "hotkey":
123
- return <Hotkey scene={scene} theme={theme} />;
127
+ return <Hotkey scene={scene} theme={theme} bottomInset={bottomInset} />;
128
+ case "hook":
129
+ return <Hook scene={scene} theme={theme} platform={platform} />;
130
+ case "clip":
131
+ return <Clip scene={scene} theme={theme} />;
132
+ case "benchmark":
133
+ return <Benchmark scene={scene} theme={theme} bottomInset={bottomInset} />;
124
134
  default:
125
135
  return null;
126
136
  }
@@ -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": {
@@ -1,4 +1,5 @@
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.
@@ -14,7 +15,7 @@ export interface ProjectMeta {
14
15
  tone: "professional" | "playful" | "technical";
15
16
  /** Publishing targets; required, at least one. Presets derive dimensions. */
16
17
  platforms: PlatformId[];
17
- /** Bundled CC0 track selection; false disables audio. (Playback lands in Phase 2.) */
18
+ /** Bundled CC0 track selection; false disables audio. */
18
19
  audio?: { track: string; volume?: number } | false;
19
20
  /** "made with reelme" credit in the CTA footer; default true. (Rendering lands in Phase 2.) */
20
21
  watermark?: boolean;
@@ -23,8 +24,14 @@ export interface ProjectMeta {
23
24
  logo?: string;
24
25
  font?: string;
25
26
  monoFont?: string;
27
+ /** Legacy per-scene transition; superseded by the look's edit rhythm. */
26
28
  transition?: "fade" | "slide" | "zoom";
27
29
  bgStyle?: "deep" | "branded" | "light";
30
+ /**
31
+ * Art-direction preset: lighting, camera, grade, grain, and cut rhythm.
32
+ * Defaults from tone (professional→keynote, playful→arcade, technical→blueprint).
33
+ */
34
+ look?: LookId;
28
35
  }
29
36
 
30
37
  export interface ProblemScene {
@@ -33,6 +40,10 @@ export interface ProblemScene {
33
40
  subtext?: string;
34
41
  caption?: string;
35
42
  hero?: boolean;
43
+ /** Small eyebrow label above the headline (e.g. the product name). */
44
+ kicker?: string;
45
+ /** Composition: centered (default) or left-anchored with negative space. */
46
+ align?: "center" | "left";
36
47
  }
37
48
 
38
49
  export interface CodeRevealScene {
@@ -108,6 +119,8 @@ export interface FeatureListScene {
108
119
  headline?: string;
109
120
  items: Array<string | FeatureItem>;
110
121
  caption?: string;
122
+ /** Composition: centered (default) or left-anchored. */
123
+ align?: "center" | "left";
111
124
  }
112
125
 
113
126
  export interface StatItem {
@@ -120,6 +133,8 @@ export interface StatCalloutScene {
120
133
  headline?: string;
121
134
  stats: StatItem[];
122
135
  caption?: string;
136
+ /** "hero" renders the first stat at giant scale (one dominant number). */
137
+ layout?: "row" | "hero";
123
138
  }
124
139
 
125
140
  export interface FileTreeEntry {
@@ -138,7 +153,16 @@ export interface FileTreeScene {
138
153
  export interface MobileScene {
139
154
  type: "mobile";
140
155
  title?: string;
141
- image?: string;
156
+ screenshot?: string;
157
+ caption?: string;
158
+ }
159
+
160
+ export interface ClipScene {
161
+ type: "clip";
162
+ src: string;
163
+ frame: "browser" | "mobile" | "none";
164
+ startFrom?: number;
165
+ durationInFrames?: number;
142
166
  caption?: string;
143
167
  }
144
168
 
@@ -164,6 +188,37 @@ export interface HotkeyScene {
164
188
  caption?: string;
165
189
  }
166
190
 
191
+ export interface HookScene {
192
+ type: "hook";
193
+ text: string;
194
+ accent?: string;
195
+ /** Small eyebrow label above the hook (e.g. the product name). */
196
+ kicker?: string;
197
+ /** Composition: centered (default) or left-anchored with negative space. */
198
+ align?: "center" | "left";
199
+ }
200
+
201
+ export interface BenchmarkBar {
202
+ label: string;
203
+ /** Raw metric value; bar length is derived from it. */
204
+ value: number;
205
+ /** Text shown on the bar, e.g. "0.3s". Falls back to the value. */
206
+ display?: string;
207
+ /** The project's own bar — highlighted in the accent color. */
208
+ hero?: boolean;
209
+ }
210
+
211
+ export interface BenchmarkScene {
212
+ type: "benchmark";
213
+ headline?: string;
214
+ /** Metric description, e.g. "Search time (lower is better)". */
215
+ metric?: string;
216
+ /** When true, the smallest value wins (gets the longest bar). */
217
+ lowerIsBetter?: boolean;
218
+ bars: BenchmarkBar[];
219
+ caption?: string;
220
+ }
221
+
167
222
  export type Scene =
168
223
  | ProblemScene
169
224
  | CodeRevealScene
@@ -177,7 +232,10 @@ export type Scene =
177
232
  | FileTreeScene
178
233
  | MobileScene
179
234
  | OSWindowScene
180
- | HotkeyScene;
235
+ | HotkeyScene
236
+ | HookScene
237
+ | BenchmarkScene
238
+ | ClipScene;
181
239
 
182
240
  export interface Cuts {
183
241
  /** 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={{ transform: `scale(${scale})`, transformOrigin: "center center", willChange: "transform" }}>
51
+ {children}
52
+ </AbsoluteFill>
53
+ );
54
+ };
@@ -0,0 +1,89 @@
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
+ export type LookId = "keynote" | "noir" | "arcade" | "blueprint" | "editorial";
9
+
10
+ export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom";
11
+
12
+ export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
13
+
14
+ export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
15
+
16
+ export interface Grade {
17
+ /** Tint color layered over the whole frame. */
18
+ color: string;
19
+ blend: "multiply" | "screen" | "overlay" | "soft-light";
20
+ alpha: number;
21
+ }
22
+
23
+ export interface CinematicLook {
24
+ id: LookId;
25
+ /** Radial accent-light intensity behind the content (0..1). */
26
+ glow: number;
27
+ /** Whether a second, cooler light source is added for depth. */
28
+ twoTone: boolean;
29
+ /** Vignette darkness at the edges (0..1). */
30
+ vignette: number;
31
+ /** Film-grain opacity on the final frame (0..1); skipped for gif output. */
32
+ grain: number;
33
+ overlay: AtmosphereOverlay;
34
+ /** Base camera move applied to every scene's content. */
35
+ camera: CameraMove;
36
+ /** Camera amplitude multiplier (1 = default). */
37
+ cameraIntensity: number;
38
+ grade: Grade;
39
+ /** Default cut style; the rhythm picker varies around it. */
40
+ transition: TransitionStyle;
41
+ /** Editing energy: higher tightens enter timings and favors hard cuts. */
42
+ energy: number;
43
+ }
44
+
45
+ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
46
+ // Clean keynote stage — soft key light, gentle push, long dissolves.
47
+ keynote: {
48
+ glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
49
+ camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
50
+ transition: "fade", energy: 0.4,
51
+ },
52
+ // Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
53
+ noir: {
54
+ glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
55
+ camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
56
+ transition: "dip", energy: 0.35,
57
+ },
58
+ // Saturated arcade — dual lights, scanlines, snappy whips and zooms.
59
+ arcade: {
60
+ glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
61
+ camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
62
+ transition: "whip", energy: 0.85,
63
+ },
64
+ // Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
65
+ blueprint: {
66
+ glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
67
+ camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
68
+ transition: "cut", energy: 0.6,
69
+ },
70
+ // Premium brand film — warm grade, big soft vignette, very slow elegant push.
71
+ editorial: {
72
+ glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
73
+ camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
74
+ transition: "fade", energy: 0.3,
75
+ },
76
+ };
77
+
78
+ const TONE_DEFAULT: Record<string, LookId> = {
79
+ professional: "keynote",
80
+ playful: "arcade",
81
+ technical: "blueprint",
82
+ };
83
+
84
+ export function resolveLook(look: string | undefined, tone: string | undefined): CinematicLook {
85
+ const id = (look && look in LOOKS ? look : TONE_DEFAULT[tone ?? "professional"] ?? "keynote") as LookId;
86
+ return { id, ...LOOKS[id] };
87
+ }
88
+
89
+ export const LOOK_IDS = Object.keys(LOOKS) as LookId[];