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