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
@@ -3,13 +3,15 @@ import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remot
3
3
  import { Brief, ProjectMeta, Scene } from "./brief";
4
4
  import { PlatformPreset } from "./platforms";
5
5
  import { buildTheme } from "./theme";
6
- import { sceneDuration } from "./duration";
6
+ import { sceneDurationsOnGrid } from "./duration";
7
7
  import { audioVolume } from "./audio";
8
- import { resolveLook } from "./cinematic/look";
8
+ import { resolveLook, TransitionStyle } from "./cinematic/look";
9
9
  import { Atmosphere, Grain } from "./cinematic/Atmosphere";
10
10
  import { Camera } from "./cinematic/Camera";
11
11
  import { Enter, transitionFor } from "./cinematic/transitions";
12
12
  import "./fonts";
13
+ import { CUSTOM_SCENES } from "./custom-scenes";
14
+ import { Caption } from "./components/primitives/Caption";
13
15
  import { Problem } from "./components/scenes/Problem";
14
16
  import { CodeReveal } from "./components/scenes/CodeReveal";
15
17
  import { TerminalScene } from "./components/scenes/TerminalScene";
@@ -36,11 +38,17 @@ interface ReelProps {
36
38
  }
37
39
 
38
40
  export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
39
- const { durationInFrames } = useVideoConfig();
40
- const theme = buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle);
41
+ const { durationInFrames, fps } = useVideoConfig();
41
42
  const look = resolveLook(brief.project.look, brief.project.tone);
43
+ // The look owns motion personality: override the tone-derived default so every
44
+ // scene's springs (which read theme.motion) inherit the look's physics.
45
+ const theme = {
46
+ ...buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle, brief.project.secondaryColor),
47
+ motion: look.motion,
48
+ };
42
49
  const isGif = platform.output.codec === "gif";
43
50
  const shouldRenderAudio = !isGif && Boolean(brief.project.audio);
51
+ const bpm = brief.project.audio ? brief.project.audio.bpm : undefined;
44
52
 
45
53
  // Cut selection: teaser when requested, otherwise the cut named by the
46
54
  // platform preset, falling back to main when vertical is absent.
@@ -48,17 +56,26 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
48
56
  cut === "teaser"
49
57
  ? brief.cuts.teaser ?? []
50
58
  : platform.cut === "vertical"
51
- ? brief.cuts.vertical ?? brief.cuts.main
59
+ ? (brief.cuts.vertical?.length ? brief.cuts.vertical : brief.cuts.main)
52
60
  : brief.cuts.main;
53
61
 
62
+ // Cut timing on the music's beat grid (falls back to natural durations when
63
+ // no track/bpm) — the same grid index.ts used for the composition length.
64
+ const durations = sceneDurationsOnGrid(scenes, fps, bpm);
54
65
  let cursor = 0;
55
- const sequenced = scenes.map((scene) => {
66
+ const sequenced = scenes.map((scene, i) => {
56
67
  const from = cursor;
57
- const duration = sceneDuration(scene);
68
+ const duration = durations[i];
58
69
  cursor += duration;
59
70
  return { scene, from, duration };
60
71
  });
61
72
 
73
+ // Keep scene content out of the platform's top UI overlay zone (F10). The
74
+ // Atmosphere still fills the whole frame; only the content band starts below
75
+ // safeArea.top. The bottom is left at the frame edge because captions manage
76
+ // their own bottom inset, so this never double-counts.
77
+ const topInset = platform.safeArea?.top ?? 0;
78
+
62
79
  return (
63
80
  <AbsoluteFill style={{ background: theme.bg }}>
64
81
  <Atmosphere theme={theme} look={look} quality={isGif ? "lite" : "full"} />
@@ -73,21 +90,69 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
73
90
  />
74
91
  ) : null}
75
92
 
76
- {sequenced.map(({ scene, from, duration }, i) => (
77
- <Sequence key={i} from={from} durationInFrames={duration}>
78
- <Enter style={transitionFor(look, i, sequenced.length)} look={look} fromBlack={i === 0} seed={i}>
79
- <Camera look={look} durationInFrames={duration} seed={i} disabled={isGif}>
80
- <SceneRenderer scene={scene} theme={theme} project={brief.project} platform={platform} />
81
- </Camera>
82
- </Enter>
83
- </Sequence>
84
- ))}
93
+ <div style={{ position: "absolute", top: topInset, left: 0, right: 0, bottom: 0 }}>
94
+ {sequenced.map(({ scene, from, duration }, i) => (
95
+ <Sequence key={i} from={from} durationInFrames={duration} premountFor={fps}>
96
+ <Enter style={transitionFor(look, i, sequenced.length)} look={look} fromBlack={i === 0} seed={i}>
97
+ <Camera look={look} durationInFrames={duration} seed={i} disabled={isGif}>
98
+ <SceneRenderer scene={scene} theme={theme} project={brief.project} platform={platform} />
99
+ </Camera>
100
+ </Enter>
101
+ </Sequence>
102
+ ))}
103
+ </div>
104
+
105
+ {/* Cut SFX: a subtle cue under each transition makes the edit tactile.
106
+ The end-card gets a rise that starts BEFORE its cut so the swell
107
+ lands on the arrival, not after it. */}
108
+ {shouldRenderAudio &&
109
+ sequenced.map(({ from }, i) => {
110
+ if (i === 0) return null;
111
+ const isLast = i === sequenced.length - 1;
112
+ const style = transitionFor(look, i, sequenced.length);
113
+ const sfx = isLast ? null : cutSfx(style);
114
+ if (isLast) {
115
+ return (
116
+ <Sequence key={`sfx-${i}`} from={Math.max(0, from - 18)} durationInFrames={40}>
117
+ <Audio src={staticFile("audio/sfx/rise.mp3")} volume={0.5} />
118
+ </Sequence>
119
+ );
120
+ }
121
+ if (!sfx) return null;
122
+ return (
123
+ <Sequence key={`sfx-${i}`} from={from} durationInFrames={20}>
124
+ <Audio src={staticFile(`audio/sfx/${sfx.file}`)} volume={sfx.volume} />
125
+ </Sequence>
126
+ );
127
+ })}
85
128
 
86
129
  {!isGif && <Grain look={look} />}
87
130
  </AbsoluteFill>
88
131
  );
89
132
  };
90
133
 
134
+ /**
135
+ * Which cue a transition earns. Fast lateral cuts get air (whoosh); settle
136
+ * cuts get a soft knock; hard cuts stay silent — silence is also a cue.
137
+ */
138
+ function cutSfx(style: TransitionStyle): { file: string; volume: number } | null {
139
+ switch (style) {
140
+ case "whip":
141
+ case "wipe":
142
+ case "flip":
143
+ return { file: "whoosh.mp3", volume: 0.4 };
144
+ case "punch":
145
+ case "zoom":
146
+ return { file: "pop.mp3", volume: 0.45 };
147
+ case "rise":
148
+ case "fade":
149
+ case "dip":
150
+ return { file: "pop.mp3", volume: 0.25 };
151
+ default:
152
+ return null;
153
+ }
154
+ }
155
+
91
156
  interface SceneRendererProps {
92
157
  scene: Scene;
93
158
  theme: ReturnType<typeof buildTheme>;
@@ -97,7 +162,6 @@ interface SceneRendererProps {
97
162
 
98
163
  const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, platform }) => {
99
164
  const bottomInset = platform.safeArea?.bottom ?? 0;
100
- const lite = platform.output.codec === "gif";
101
165
  switch (scene.type) {
102
166
  case "problem":
103
167
  return <Problem scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
@@ -108,9 +172,9 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
108
172
  case "data-flow":
109
173
  return <DataFlow scene={scene} theme={theme} bottomInset={bottomInset} />;
110
174
  case "cta":
111
- return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} lite={lite} />;
175
+ return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
112
176
  case "browser":
113
- return <BrowserFrame scene={scene} theme={theme} bottomInset={bottomInset} />;
177
+ return <BrowserFrame scene={scene} theme={theme} platform={platform} bottomInset={bottomInset} />;
114
178
  case "split":
115
179
  return <SplitComparison scene={scene} theme={theme} bottomInset={bottomInset} />;
116
180
  case "feature-list":
@@ -120,7 +184,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
120
184
  case "file-tree":
121
185
  return <FileTree scene={scene} theme={theme} bottomInset={bottomInset} />;
122
186
  case "mobile":
123
- return <MobileScreen scene={scene} theme={theme} bottomInset={bottomInset} />;
187
+ return <MobileScreen scene={scene} theme={theme} platform={platform} bottomInset={bottomInset} />;
124
188
  case "os-window":
125
189
  return <OSWindow scene={scene} theme={theme} bottomInset={bottomInset} />;
126
190
  case "hotkey":
@@ -128,9 +192,25 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
128
192
  case "hook":
129
193
  return <Hook scene={scene} theme={theme} platform={platform} />;
130
194
  case "clip":
131
- return <Clip scene={scene} theme={theme} />;
195
+ return <Clip scene={scene} theme={theme} bottomInset={bottomInset} />;
132
196
  case "benchmark":
133
197
  return <Benchmark scene={scene} theme={theme} bottomInset={bottomInset} />;
198
+ case "custom": {
199
+ const Custom = CUSTOM_SCENES[scene.component];
200
+ if (!Custom) {
201
+ // The CLI stages and registers referenced components; reaching this
202
+ // means a direct-render path skipped staging. Fail soft on one scene
203
+ // rather than killing the whole reel.
204
+ console.warn(`reelme: custom scene component not registered: ${scene.component}`);
205
+ return null;
206
+ }
207
+ return (
208
+ <>
209
+ <Custom theme={theme} project={project} platform={platform} bottomInset={bottomInset} caption={scene.caption} />
210
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={40} bottomInset={bottomInset} />}
211
+ </>
212
+ );
213
+ }
134
214
  default:
135
215
  return null;
136
216
  }
@@ -61,11 +61,11 @@
61
61
  "title": "Sprout Commands",
62
62
  "searchQuery": "sprout",
63
63
  "items": [
64
- { "icon": "play", "label": "sprout dev", "value": "Start dev server + Prisma Studio", "highlighted": true },
64
+ { "icon": "terminal", "label": "sprout dev", "value": "Start dev server + Prisma Studio", "highlighted": true },
65
65
  { "icon": "database", "label": "sprout db:push", "value": "Sync schema to database" },
66
- { "icon": "user-check", "label": "sprout auth:setup", "value": "Configure OAuth providers" },
66
+ { "icon": "key", "label": "sprout auth:setup", "value": "Configure OAuth providers" },
67
67
  { "icon": "package", "label": "sprout add", "value": "Add a feature module" },
68
- { "icon": "rocket", "label": "sprout deploy", "value": "Deploy to Vercel, Railway, or Fly" }
68
+ { "icon": "cloud", "label": "sprout deploy", "value": "Deploy to Vercel, Railway, or Fly" }
69
69
  ],
70
70
  "caption": "Every task is one command away."
71
71
  },
@@ -82,8 +82,8 @@
82
82
  { "text": "Type-safe API layer with tRPC out of the box", "icon": "shield" },
83
83
  { "text": "Auth in one command — email, GitHub, Google", "icon": "lock" },
84
84
  { "text": "Database ORM with auto-generated migrations", "icon": "database" },
85
- { "text": "CI/CD templates for GitHub Actions baked in", "icon": "git-branch" },
86
- { "text": "One-click deploy to Vercel, Fly, or Railway", "icon": "rocket" }
85
+ { "text": "CI/CD templates for GitHub Actions baked in", "icon": "settings" },
86
+ { "text": "One-click deploy to Vercel, Fly, or Railway", "icon": "cloud" }
87
87
  ],
88
88
  "caption": "Start building features on day one."
89
89
  },
@@ -117,7 +117,7 @@
117
117
  "items": [
118
118
  { "text": "Type-safe API layer out of the box", "icon": "shield" },
119
119
  { "text": "Auth in one command", "icon": "lock" },
120
- { "text": "One-click deploy", "icon": "rocket" }
120
+ { "text": "One-click deploy", "icon": "cloud" }
121
121
  ],
122
122
  "caption": "Start building on day one."
123
123
  },
@@ -5,6 +5,12 @@ import { LookId } from "./cinematic/look";
5
5
  // the CLI refuses briefs whose schemaVersion doesn't match.
6
6
  export const BRIEF_SCHEMA_VERSION = 2;
7
7
 
8
+ // The CTA footer credit shows unless the brief explicitly opts out. Exported so
9
+ // the component and its test share one definition instead of drifting (F22).
10
+ export function showWatermark(watermark: boolean | undefined): boolean {
11
+ return watermark !== false;
12
+ }
13
+
8
14
  export interface ProjectMeta {
9
15
  name: string;
10
16
  tagline: string;
@@ -12,20 +18,28 @@ export interface ProjectMeta {
12
18
  installCommand: string;
13
19
  repoUrl: string;
14
20
  primaryColor: string;
21
+ /**
22
+ * Second brand hue for backdrops and gradients (taken from the project's
23
+ * logo or site). Without it one is derived from primaryColor — a real brand
24
+ * pair reads noticeably less generic than a derived one.
25
+ */
26
+ secondaryColor?: string;
15
27
  tone: "professional" | "playful" | "technical";
16
28
  /** Publishing targets; required, at least one. Presets derive dimensions. */
17
29
  platforms: PlatformId[];
18
- /** Bundled CC0 track selection; false disables audio. */
19
- audio?: { track: string; volume?: number } | false;
20
- /** "made with reelme" credit in the CTA footer; default true. (Rendering lands in Phase 2.) */
30
+ /**
31
+ * Bundled CC0 track selection; false disables audio (music, SFX, and beat
32
+ * quantization together). `bpm` is injected by the CLI from the audio
33
+ * manifest at stage time — briefs don't set it by hand.
34
+ */
35
+ audio?: { track: string; volume?: number; bpm?: number } | false;
36
+ /** "made with reelme" credit in the CTA footer; default true. */
21
37
  watermark?: boolean;
22
38
  mode?: "intro" | "announcement";
23
39
  version?: string;
24
40
  logo?: string;
25
41
  font?: string;
26
42
  monoFont?: string;
27
- /** Legacy per-scene transition; superseded by the look's edit rhythm. */
28
- transition?: "fade" | "slide" | "zoom";
29
43
  bgStyle?: "deep" | "branded" | "light";
30
44
  /**
31
45
  * Art-direction preset: lighting, camera, grade, grain, and cut rhythm.
@@ -155,6 +169,12 @@ export interface MobileScene {
155
169
  title?: string;
156
170
  screenshot?: string;
157
171
  caption?: string;
172
+ /** Value-prop shown beside the device (landscape) or above it (vertical).
173
+ * With a headline/points the scene composes as device + copy; without, the
174
+ * device sits centered on its own. */
175
+ headline?: string;
176
+ /** Short supporting bullets next to the device, rendered with accent checks. */
177
+ points?: string[];
158
178
  }
159
179
 
160
180
  export interface ClipScene {
@@ -188,6 +208,18 @@ export interface HotkeyScene {
188
208
  caption?: string;
189
209
  }
190
210
 
211
+ export interface CustomScene {
212
+ type: "custom";
213
+ /**
214
+ * Repo-relative path to a bespoke scene component (a .tsx default export),
215
+ * usually authored by the skill for THIS project's one differentiating
216
+ * moment. The CLI stages the file and registers it (custom-scenes.ts).
217
+ */
218
+ component: string;
219
+ durationInFrames: number;
220
+ caption?: string;
221
+ }
222
+
191
223
  export interface HookScene {
192
224
  type: "hook";
193
225
  text: string;
@@ -235,7 +267,8 @@ export type Scene =
235
267
  | HotkeyScene
236
268
  | HookScene
237
269
  | BenchmarkScene
238
- | ClipScene;
270
+ | ClipScene
271
+ | CustomScene;
239
272
 
240
273
  export interface Cuts {
241
274
  /** The full narrative arc. Always required. */
@@ -19,50 +19,31 @@ function drift(frame: number, period: number, phase: number) {
19
19
  /**
20
20
  * The continuous stage the reel is shot on. Rendered once, behind every scene,
21
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.
22
+ * a deck of slides into one film.
23
+ *
24
+ * Each look stages the film on a distinct backdrop SYSTEM (look.backdrop)
25
+ * instead of one shared orb recipe. The system covers the frame with intent —
26
+ * a full gradient field, a structural grid, an aimed light — so no region is
27
+ * ever a flat void with a detached glow floating in it.
23
28
  */
24
29
  export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
25
30
  const liveFrame = useCurrentFrame();
26
31
  const lite = quality === "lite";
27
- // Gif output freezes the lights: a frame that changes every tick defeats gif
32
+ // Gif output freezes the motion: a frame that changes every tick defeats gif
28
33
  // compression and balloons file size. The graded look stays; only motion goes.
29
34
  const frame = lite ? 0 : liveFrame;
30
35
 
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
36
  // Patterned overlays (1px grid/scanlines) are GIF-compression poison — every
43
37
  // frame becomes high-frequency detail. Drop them on the lite path.
44
38
  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
39
 
52
40
  return (
53
41
  <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
- )}
42
+ <Backdrop theme={theme} look={look} frame={frame} lite={lite} />
64
43
 
65
- {showOverlay && look.overlay === "grid" && <GridOverlay color={theme.border} lite={lite} />}
44
+ {showOverlay && look.overlay === "grid" && look.backdrop !== "grid" && (
45
+ <GridOverlay color={theme.border} lite={lite} />
46
+ )}
66
47
  {showOverlay && look.overlay === "scanlines" && <Scanlines lite={lite} />}
67
48
 
68
49
  {/* Grade pass: a subtle full-frame tint that unifies the palette. */}
@@ -85,6 +66,171 @@ export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
85
66
  );
86
67
  };
87
68
 
69
+ /** Dispatch to the look's backdrop system. */
70
+ const Backdrop: React.FC<{ theme: Theme; look: CinematicLook; frame: number; lite: boolean }> = ({
71
+ theme,
72
+ look,
73
+ frame,
74
+ lite,
75
+ }) => {
76
+ const accent = chroma(theme.accent);
77
+ const accent2 = chroma(theme.accent2);
78
+ // On light stages the color has to stay pastel or text contrast dies.
79
+ const lightBg = chroma(theme.bg).luminance() > 0.5;
80
+ // Lite keeps most of the backdrop's identity: at half strength the gif's
81
+ // stage read as a flat dark void, which is exactly the look this system
82
+ // replaced. Slightly larger gif files are the accepted price.
83
+ const k = look.glow * (lite ? 0.75 : 1) * (lightBg ? 0.5 : 1);
84
+
85
+ switch (look.backdrop) {
86
+ case "mesh": {
87
+ // Full-bleed gradient field: three large soft color poles (brand hue,
88
+ // second hue, and a deep counter-tone) drifting very slowly. The whole
89
+ // frame carries color variation — no dead flat region, no orb smudge.
90
+ const ax = 22 + drift(frame, 620, 0) * 10;
91
+ const ay = 24 + drift(frame, 760, 1.1) * 8;
92
+ const bx = 80 + drift(frame, 680, 2.3) * 10;
93
+ const by = 30 + drift(frame, 820, 0.5) * 10;
94
+ const cx = 55 + drift(frame, 720, 3.4) * 12;
95
+ const cy = 92 + drift(frame, 900, 1.9) * 6;
96
+ const deep = accent.set("hsl.h", "-30").darken(1.2);
97
+ return (
98
+ <>
99
+ <AbsoluteFill
100
+ style={{
101
+ background: `radial-gradient(70% 65% at ${ax}% ${ay}%, ${accent.alpha(0.5 * k).css()} 0%, transparent 72%)`,
102
+ }}
103
+ />
104
+ <AbsoluteFill
105
+ style={{
106
+ background: `radial-gradient(65% 60% at ${bx}% ${by}%, ${accent2.alpha(0.38 * k).css()} 0%, transparent 70%)`,
107
+ }}
108
+ />
109
+ <AbsoluteFill
110
+ style={{
111
+ background: `radial-gradient(80% 55% at ${cx}% ${cy}%, ${deep.alpha(0.4 * k).css()} 0%, transparent 75%)`,
112
+ }}
113
+ />
114
+ </>
115
+ );
116
+ }
117
+
118
+ case "spotlight": {
119
+ // One aimed light: an elliptical pool centered on the content zone
120
+ // (slightly above frame center), like a subject lit on a dark stage.
121
+ // The light falls ON the content instead of drifting in empty space.
122
+ const sway = drift(frame, 780, 0.4) * 3;
123
+ const warm = accent.brighten(0.4);
124
+ return (
125
+ <>
126
+ <AbsoluteFill
127
+ style={{
128
+ background: `radial-gradient(52% 44% at ${50 + sway}% 42%, ${warm.alpha(0.34 * k).css()} 0%, ${warm.alpha(0.08 * k).css()} 55%, transparent 74%)`,
129
+ }}
130
+ />
131
+ {/* Faint floor bounce so the lower frame isn't pure void. */}
132
+ <AbsoluteFill
133
+ style={{
134
+ background: `radial-gradient(90% 30% at 50% 104%, ${accent2.alpha(0.14 * k).css()} 0%, transparent 70%)`,
135
+ }}
136
+ />
137
+ </>
138
+ );
139
+ }
140
+
141
+ case "sweep": {
142
+ // Saturated full-frame diagonal wash between the two brand hues, with a
143
+ // slow-moving highlight band. Vibrant edge to edge — arcade energy comes
144
+ // from color, not from floating lights.
145
+ const shiftA = 12 + drift(frame, 560, 0.8) * 8;
146
+ const shiftB = 88 + drift(frame, 640, 2.6) * 8;
147
+ const bandY = 50 + drift(frame, 700, 1.5) * 16;
148
+ return (
149
+ <>
150
+ <AbsoluteFill
151
+ style={{
152
+ background: `linear-gradient(128deg, ${accent.alpha(0.42 * k).css()} 0%, transparent ${shiftA + 34}%), linear-gradient(308deg, ${accent2.alpha(0.36 * k).css()} 0%, transparent ${100 - shiftB + 42}%)`,
153
+ }}
154
+ />
155
+ <AbsoluteFill
156
+ style={{
157
+ background: `linear-gradient(180deg, transparent ${bandY - 24}%, ${accent.brighten(1).alpha(0.1 * k).css()} ${bandY}%, transparent ${bandY + 24}%)`,
158
+ }}
159
+ />
160
+ </>
161
+ );
162
+ }
163
+
164
+ case "grid": {
165
+ // The engineering grid IS the backdrop: structural lines with a soft
166
+ // cool wash rising from the baseline. Reads as a drafting table, not a
167
+ // dark room with a lamp.
168
+ return (
169
+ <>
170
+ <AbsoluteFill
171
+ style={{
172
+ background: `linear-gradient(180deg, transparent 55%, ${accent.alpha(0.16 * k).css()} 100%)`,
173
+ }}
174
+ />
175
+ <StructuralGrid color={theme.border} accent={theme.accent} frame={frame} lite={lite} />
176
+ </>
177
+ );
178
+ }
179
+
180
+ case "field":
181
+ default: {
182
+ // Editorial poster field: a rich duotone wash — second hue at the top,
183
+ // brand hue rising from below — over the deep base. Near-solid, carried
184
+ // by grain and grade rather than by lights.
185
+ return (
186
+ <AbsoluteFill
187
+ style={{
188
+ background: `linear-gradient(180deg, ${accent2.alpha(0.2 * k).css()} 0%, transparent 40%, transparent 62%, ${accent.alpha(0.3 * k).css()} 100%)`,
189
+ }}
190
+ />
191
+ );
192
+ }
193
+ }
194
+ };
195
+
196
+ /**
197
+ * Blueprint's structural grid: minor + major lines and a center crosshair
198
+ * tick, strong enough to read as the actual set design instead of a texture.
199
+ */
200
+ const StructuralGrid: React.FC<{ color: string; accent: string; frame: number; lite: boolean }> = ({
201
+ color,
202
+ accent,
203
+ frame,
204
+ lite,
205
+ }) => {
206
+ const shift = lite ? 0 : (frame * 0.12) % 72;
207
+ const minor = chroma(color).alpha(lite ? 0.08 : 0.13).css();
208
+ const major = chroma(color).alpha(lite ? 0.14 : 0.22).css();
209
+ const tick = chroma(accent).alpha(0.4).css();
210
+ return (
211
+ <>
212
+ <AbsoluteFill
213
+ style={{
214
+ backgroundImage: `linear-gradient(${minor} 1px, transparent 1px), linear-gradient(90deg, ${minor} 1px, transparent 1px), linear-gradient(${major} 1px, transparent 1px), linear-gradient(90deg, ${major} 1px, transparent 1px)`,
215
+ backgroundSize: "72px 72px, 72px 72px, 360px 360px, 360px 360px",
216
+ backgroundPosition: `${shift}px ${shift}px`,
217
+ maskImage: "radial-gradient(130% 110% at 50% 50%, black 35%, transparent 85%)",
218
+ WebkitMaskImage: "radial-gradient(130% 110% at 50% 50%, black 35%, transparent 85%)",
219
+ }}
220
+ />
221
+ {/* Center crosshair tick: the drafting-table registration mark. */}
222
+ {!lite && (
223
+ <AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
224
+ <div style={{ position: "relative", width: 28, height: 28, opacity: 0.5 }}>
225
+ <div style={{ position: "absolute", left: "50%", top: 0, bottom: 0, width: 1, background: tick }} />
226
+ <div style={{ position: "absolute", top: "50%", left: 0, right: 0, height: 1, background: tick }} />
227
+ </div>
228
+ </AbsoluteFill>
229
+ )}
230
+ </>
231
+ );
232
+ };
233
+
88
234
  const GridOverlay: React.FC<{ color: string; lite: boolean }> = ({ color, lite }) => {
89
235
  const frame = useCurrentFrame();
90
236
  const shift = lite ? 0 : (frame * 0.15) % 64;
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
2
+ import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
3
3
  import { CinematicLook } from "./look";
4
4
 
5
5
  interface Props {
@@ -12,34 +12,46 @@ interface Props {
12
12
  children: React.ReactNode;
13
13
  }
14
14
 
15
+ // The arrival window: how long the camera keeps settling after the cut before
16
+ // coming to a complete rest, as a fraction of the scene (capped in frames).
17
+ const SETTLE_FRACTION = 0.55;
18
+ const SETTLE_MAX_FRAMES = 90;
19
+
15
20
  /**
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.
21
+ * A virtual camera over a scene's content. Each shot ARRIVES with a slow zoom
22
+ * that eases to a complete rest, then holds perfectly still. A zoom that runs
23
+ * for the whole scene re-rasterizes crisp text at a new sub-pixel scale every
24
+ * frame, which reads as a constant vibration on bold type — so all movement is
25
+ * front-loaded into the arrival while the eye is still adjusting to the cut,
26
+ * and the hold (where the viewer actually reads) stays rock solid. Zoom only,
27
+ * anchored to center, so the composition never slides sideways.
19
28
  */
20
29
  export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled, children }) => {
21
30
  const frame = useCurrentFrame();
22
31
  if (disabled) return <AbsoluteFill>{children}</AbsoluteFill>;
32
+ void seed;
23
33
 
24
- const p = interpolate(frame, [0, durationInFrames], [0, 1], {
34
+ const settleFrames = Math.min(Math.round(durationInFrames * SETTLE_FRACTION), SETTLE_MAX_FRAMES);
35
+ const p = interpolate(frame, [0, settleFrames], [0, 1], {
25
36
  extrapolateLeft: "clamp",
26
37
  extrapolateRight: "clamp",
38
+ easing: Easing.out(Easing.cubic),
27
39
  });
28
40
  const k = look.cameraIntensity;
29
41
 
30
42
  let scale = 1;
31
43
  switch (look.camera) {
32
44
  case "push":
33
- scale = interpolate(p, [0, 1], [1.0, 1 + 0.05 * k]); // slow zoom in
45
+ scale = interpolate(p, [0, 1], [1 - 0.045 * k, 1.0]); // slow zoom in to rest
34
46
  break;
35
47
  case "drift":
36
- scale = interpolate(p, [0, 1], [1.0, 1 + 0.03 * k]); // gentle zoom in
48
+ scale = interpolate(p, [0, 1], [1 - 0.03 * k, 1.0]); // gentle zoom in to rest
37
49
  break;
38
50
  case "pan":
39
- scale = interpolate(p, [0, 1], [1 + 0.05 * k, 1.0]); // slow zoom out
51
+ scale = interpolate(p, [0, 1], [1 + 0.03 * k, 1.0]); // slow zoom out to rest
40
52
  break;
41
53
  case "float":
42
- scale = 1 + (0.02 + 0.015 * Math.sin(frame / 42 + seed)) * k; // subtle breathe
54
+ scale = interpolate(p, [0, 1], [1 - 0.02 * k, 1.0]); // soft zoom in to rest
43
55
  break;
44
56
  case "still":
45
57
  default:
@@ -47,7 +59,7 @@ export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled
47
59
  }
48
60
 
49
61
  return (
50
- <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "center center", willChange: "transform" }}>
62
+ <AbsoluteFill style={{ scale: String(scale), transformOrigin: "center center", willChange: "scale" }}>
51
63
  {children}
52
64
  </AbsoluteFill>
53
65
  );