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.
- package/assets/audio/PROVENANCE.md +16 -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 +213 -0
- package/assets/audio/manifest.json +83 -0
- 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/package.json +2 -1
- package/src/cache.mjs +1 -0
- package/src/render.mjs +95 -4
- package/template/package.json +1 -0
- package/template/pnpm-lock.yaml +15 -0
- package/template/src/Root.tsx +55 -45
- package/template/src/audio.ts +24 -0
- package/template/src/benchmark.ts +18 -0
- package/template/src/brief.json +1 -0
- package/template/src/brief.ts +61 -3
- package/template/src/cinematic/Atmosphere.tsx +139 -0
- package/template/src/cinematic/Camera.tsx +54 -0
- package/template/src/cinematic/look.ts +89 -0
- package/template/src/cinematic/transitions.tsx +92 -0
- package/template/src/components/primitives/Bar.tsx +82 -0
- package/template/src/components/primitives/Caption.tsx +3 -2
- package/template/src/components/primitives/Kicker.tsx +46 -0
- package/template/src/components/primitives/Label.tsx +18 -24
- package/template/src/components/primitives/RevealText.tsx +101 -0
- package/template/src/components/primitives/Terminal.tsx +11 -0
- package/template/src/components/scenes/Benchmark.tsx +83 -0
- package/template/src/components/scenes/BrowserFrame.tsx +4 -3
- package/template/src/components/scenes/CTA.tsx +56 -10
- package/template/src/components/scenes/Clip.tsx +125 -0
- package/template/src/components/scenes/CodeReveal.tsx +4 -3
- package/template/src/components/scenes/DataFlow.tsx +4 -3
- package/template/src/components/scenes/FeatureList.tsx +13 -8
- package/template/src/components/scenes/FileTree.tsx +4 -3
- package/template/src/components/scenes/Hook.tsx +55 -0
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +120 -82
- package/template/src/components/scenes/OSWindowScene.tsx +4 -3
- package/template/src/components/scenes/Problem.tsx +28 -29
- package/template/src/components/scenes/SplitComparison.tsx +65 -92
- package/template/src/components/scenes/StatCallout.tsx +93 -4
- package/template/src/components/scenes/TerminalScene.tsx +5 -6
- package/template/src/duration.ts +12 -1
- package/template/src/platforms.ts +4 -0
package/template/src/Root.tsx
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { AbsoluteFill, Sequence,
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
<
|
|
82
|
-
<
|
|
83
|
-
|
|
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
|
+
}
|
package/template/src/brief.json
CHANGED
package/template/src/brief.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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[];
|