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.
- package/README.md +10 -3
- package/assets/audio/PROVENANCE.md +18 -11
- package/assets/audio/generate-sfx.mjs +128 -0
- package/assets/audio/light-steps.mp3 +0 -0
- package/assets/audio/lofi-dusk.mp3 +0 -0
- package/assets/audio/manifest.json +56 -63
- package/assets/audio/neon-pulse.mp3 +0 -0
- package/assets/audio/night-drive.mp3 +0 -0
- package/assets/audio/sfx/SOURCES.md +16 -0
- package/assets/audio/sfx/pop.mp3 +0 -0
- package/assets/audio/sfx/rise.mp3 +0 -0
- package/assets/audio/sfx/whoosh.mp3 +0 -0
- package/assets/audio/sunny-bounce.mp3 +0 -0
- package/assets/audio/warm-memories.mp3 +0 -0
- package/package.json +8 -2
- package/src/cache.mjs +246 -16
- package/src/cli.mjs +20 -11
- package/src/render.mjs +98 -19
- package/template/package.json +6 -6
- package/template/pnpm-lock.yaml +177 -122
- package/template/pnpm-workspace.yaml +2 -0
- package/template/src/Root.tsx +101 -21
- package/template/src/brief.json +6 -6
- package/template/src/brief.ts +39 -6
- package/template/src/cinematic/Atmosphere.tsx +176 -30
- package/template/src/cinematic/Camera.tsx +22 -10
- package/template/src/cinematic/look.ts +51 -10
- package/template/src/cinematic/transitions.tsx +29 -9
- package/template/src/components/primitives/Arrow.tsx +8 -5
- package/template/src/components/primitives/Bar.tsx +6 -2
- package/template/src/components/primitives/Caption.tsx +2 -2
- package/template/src/components/primitives/CodeBlock.tsx +21 -7
- package/template/src/components/primitives/Icon.tsx +7 -0
- package/template/src/components/primitives/KeyPill.tsx +1 -1
- package/template/src/components/primitives/Label.tsx +4 -1
- package/template/src/components/primitives/Loop.tsx +65 -0
- package/template/src/components/primitives/RevealText.tsx +35 -7
- package/template/src/components/primitives/Stage.tsx +62 -0
- package/template/src/components/primitives/Terminal.tsx +24 -7
- package/template/src/components/scenes/BrowserFrame.tsx +13 -6
- package/template/src/components/scenes/CTA.tsx +47 -82
- package/template/src/components/scenes/Clip.tsx +31 -12
- package/template/src/components/scenes/CodeReveal.tsx +12 -13
- package/template/src/components/scenes/DataFlow.tsx +47 -19
- package/template/src/components/scenes/FeatureList.tsx +74 -77
- package/template/src/components/scenes/FileTree.tsx +23 -7
- package/template/src/components/scenes/Hook.tsx +4 -3
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +225 -118
- package/template/src/components/scenes/OSWindowScene.tsx +5 -5
- package/template/src/components/scenes/Problem.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +41 -14
- package/template/src/components/scenes/StatCallout.tsx +123 -68
- package/template/src/components/scenes/TerminalScene.tsx +12 -11
- package/template/src/custom-scenes.ts +19 -0
- package/template/src/duration.ts +54 -5
- package/template/src/index.ts +16 -10
- package/template/src/theme.ts +10 -1
- package/template/src/timing.ts +49 -0
- package/assets/audio/bright-sparks.mp3 +0 -0
- package/assets/audio/calm-keys.mp3 +0 -0
- package/assets/audio/circuit-pulse.mp3 +0 -0
- package/assets/audio/clean-horizon.mp3 +0 -0
- package/assets/audio/generate-tracks.mjs +0 -213
- package/assets/audio/midnight-protocol.mp3 +0 -0
- package/assets/audio/pixel-bounce.mp3 +0 -0
- package/assets/audio/steady-launch.mp3 +0 -0
- package/assets/audio/sunny-loop.mp3 +0 -0
- package/assets/audio/vector-grid.mp3 +0 -0
package/template/src/Root.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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
|
-
{
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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}
|
|
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
|
}
|
package/template/src/brief.json
CHANGED
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"title": "Sprout Commands",
|
|
62
62
|
"searchQuery": "sprout",
|
|
63
63
|
"items": [
|
|
64
|
-
{ "icon": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
86
|
-
{ "text": "One-click deploy to Vercel, Fly, or Railway", "icon": "
|
|
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": "
|
|
120
|
+
{ "text": "One-click deploy", "icon": "cloud" }
|
|
121
121
|
],
|
|
122
122
|
"caption": "Start building on day one."
|
|
123
123
|
},
|
package/template/src/brief.ts
CHANGED
|
@@ -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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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
|
|
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
|
-
{
|
|
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" &&
|
|
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.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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={{
|
|
62
|
+
<AbsoluteFill style={{ scale: String(scale), transformOrigin: "center center", willChange: "scale" }}>
|
|
51
63
|
{children}
|
|
52
64
|
</AbsoluteFill>
|
|
53
65
|
);
|