reelme 0.3.0 → 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/generate-tracks.mjs +6 -1
- package/package.json +8 -2
- package/src/cache.mjs +154 -14
- package/src/cli.mjs +20 -11
- package/src/render.mjs +91 -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 +27 -15
- package/template/src/brief.json +6 -6
- package/template/src/brief.ts +13 -3
- package/template/src/cinematic/Camera.tsx +1 -1
- package/template/src/cinematic/look.ts +25 -8
- package/template/src/cinematic/transitions.tsx +27 -9
- package/template/src/components/primitives/Bar.tsx +6 -2
- package/template/src/components/primitives/Caption.tsx +2 -2
- 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 +27 -5
- package/template/src/components/primitives/Stage.tsx +62 -0
- package/template/src/components/scenes/BrowserFrame.tsx +1 -1
- package/template/src/components/scenes/CTA.tsx +45 -82
- package/template/src/components/scenes/Clip.tsx +9 -3
- package/template/src/components/scenes/CodeReveal.tsx +6 -5
- package/template/src/components/scenes/DataFlow.tsx +1 -1
- package/template/src/components/scenes/FeatureList.tsx +74 -77
- package/template/src/components/scenes/FileTree.tsx +1 -1
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +222 -107
- package/template/src/components/scenes/OSWindowScene.tsx +4 -4
- package/template/src/components/scenes/Problem.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +1 -1
- package/template/src/components/scenes/StatCallout.tsx +123 -68
- package/template/src/components/scenes/TerminalScene.tsx +12 -11
- package/template/src/duration.ts +12 -2
- package/template/src/index.ts +7 -5
- package/template/src/timing.ts +49 -0
package/template/src/Root.tsx
CHANGED
|
@@ -36,9 +36,14 @@ interface ReelProps {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
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);
|
|
39
|
+
const { durationInFrames, fps } = useVideoConfig();
|
|
41
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
|
+
};
|
|
42
47
|
const isGif = platform.output.codec === "gif";
|
|
43
48
|
const shouldRenderAudio = !isGif && Boolean(brief.project.audio);
|
|
44
49
|
|
|
@@ -48,7 +53,7 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
|
|
|
48
53
|
cut === "teaser"
|
|
49
54
|
? brief.cuts.teaser ?? []
|
|
50
55
|
: platform.cut === "vertical"
|
|
51
|
-
? brief.cuts.vertical
|
|
56
|
+
? (brief.cuts.vertical?.length ? brief.cuts.vertical : brief.cuts.main)
|
|
52
57
|
: brief.cuts.main;
|
|
53
58
|
|
|
54
59
|
let cursor = 0;
|
|
@@ -59,6 +64,12 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
|
|
|
59
64
|
return { scene, from, duration };
|
|
60
65
|
});
|
|
61
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
|
+
|
|
62
73
|
return (
|
|
63
74
|
<AbsoluteFill style={{ background: theme.bg }}>
|
|
64
75
|
<Atmosphere theme={theme} look={look} quality={isGif ? "lite" : "full"} />
|
|
@@ -73,15 +84,17 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
|
|
|
73
84
|
/>
|
|
74
85
|
) : null}
|
|
75
86
|
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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>
|
|
85
98
|
|
|
86
99
|
{!isGif && <Grain look={look} />}
|
|
87
100
|
</AbsoluteFill>
|
|
@@ -97,7 +110,6 @@ interface SceneRendererProps {
|
|
|
97
110
|
|
|
98
111
|
const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, platform }) => {
|
|
99
112
|
const bottomInset = platform.safeArea?.bottom ?? 0;
|
|
100
|
-
const lite = platform.output.codec === "gif";
|
|
101
113
|
switch (scene.type) {
|
|
102
114
|
case "problem":
|
|
103
115
|
return <Problem scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
|
|
@@ -108,7 +120,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
|
|
|
108
120
|
case "data-flow":
|
|
109
121
|
return <DataFlow scene={scene} theme={theme} bottomInset={bottomInset} />;
|
|
110
122
|
case "cta":
|
|
111
|
-
return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset}
|
|
123
|
+
return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
|
|
112
124
|
case "browser":
|
|
113
125
|
return <BrowserFrame scene={scene} theme={theme} bottomInset={bottomInset} />;
|
|
114
126
|
case "split":
|
|
@@ -128,7 +140,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
|
|
|
128
140
|
case "hook":
|
|
129
141
|
return <Hook scene={scene} theme={theme} platform={platform} />;
|
|
130
142
|
case "clip":
|
|
131
|
-
return <Clip scene={scene} theme={theme} />;
|
|
143
|
+
return <Clip scene={scene} theme={theme} bottomInset={bottomInset} />;
|
|
132
144
|
case "benchmark":
|
|
133
145
|
return <Benchmark scene={scene} theme={theme} bottomInset={bottomInset} />;
|
|
134
146
|
default:
|
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;
|
|
@@ -17,15 +23,13 @@ export interface ProjectMeta {
|
|
|
17
23
|
platforms: PlatformId[];
|
|
18
24
|
/** Bundled CC0 track selection; false disables audio. */
|
|
19
25
|
audio?: { track: string; volume?: number } | false;
|
|
20
|
-
/** "made with reelme" credit in the CTA footer; default true.
|
|
26
|
+
/** "made with reelme" credit in the CTA footer; default true. */
|
|
21
27
|
watermark?: boolean;
|
|
22
28
|
mode?: "intro" | "announcement";
|
|
23
29
|
version?: string;
|
|
24
30
|
logo?: string;
|
|
25
31
|
font?: string;
|
|
26
32
|
monoFont?: string;
|
|
27
|
-
/** Legacy per-scene transition; superseded by the look's edit rhythm. */
|
|
28
|
-
transition?: "fade" | "slide" | "zoom";
|
|
29
33
|
bgStyle?: "deep" | "branded" | "light";
|
|
30
34
|
/**
|
|
31
35
|
* Art-direction preset: lighting, camera, grade, grain, and cut rhythm.
|
|
@@ -155,6 +159,12 @@ export interface MobileScene {
|
|
|
155
159
|
title?: string;
|
|
156
160
|
screenshot?: string;
|
|
157
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[];
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
export interface ClipScene {
|
|
@@ -47,7 +47,7 @@ export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
return (
|
|
50
|
-
<AbsoluteFill style={{
|
|
50
|
+
<AbsoluteFill style={{ scale: String(scale), transformOrigin: "center center", willChange: "scale" }}>
|
|
51
51
|
{children}
|
|
52
52
|
</AbsoluteFill>
|
|
53
53
|
);
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Looks are pure data; colors come from the resolved Theme at render time.
|
|
7
7
|
|
|
8
|
+
import { MotionProfile } from "../theme";
|
|
9
|
+
|
|
8
10
|
export type LookId = "keynote" | "noir" | "arcade" | "blueprint" | "editorial";
|
|
9
11
|
|
|
10
|
-
export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom";
|
|
12
|
+
export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom" | "wipe" | "flip";
|
|
11
13
|
|
|
12
14
|
export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
|
|
13
15
|
|
|
@@ -36,42 +38,57 @@ export interface CinematicLook {
|
|
|
36
38
|
/** Camera amplitude multiplier (1 = default). */
|
|
37
39
|
cameraIntensity: number;
|
|
38
40
|
grade: Grade;
|
|
39
|
-
/** Default cut style; the rhythm picker varies around it. */
|
|
40
|
-
transition: TransitionStyle;
|
|
41
41
|
/** Editing energy: higher tightens enter timings and favors hard cuts. */
|
|
42
42
|
energy: number;
|
|
43
|
+
/**
|
|
44
|
+
* Spring physics for in-scene element animation. Motion is part of the
|
|
45
|
+
* production design: a look should *move* differently, not just look
|
|
46
|
+
* different. The Reel feeds this into the theme so every scene's springs
|
|
47
|
+
* inherit the look's personality (smooth, snappy, weighty, ...).
|
|
48
|
+
*/
|
|
49
|
+
motion: MotionProfile;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
|
|
46
53
|
// Clean keynote stage — soft key light, gentle push, long dissolves.
|
|
54
|
+
// Motion: settled, no bounce — elements arrive and hold.
|
|
47
55
|
keynote: {
|
|
48
56
|
glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
|
|
49
57
|
camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
|
|
50
|
-
|
|
58
|
+
energy: 0.4,
|
|
59
|
+
motion: { damping: 22, stiffness: 100, mass: 1.0 },
|
|
51
60
|
},
|
|
52
61
|
// Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
|
|
62
|
+
// Motion: heavy and deliberate — weighty mass, slow to settle.
|
|
53
63
|
noir: {
|
|
54
64
|
glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
|
|
55
65
|
camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
|
|
56
|
-
|
|
66
|
+
energy: 0.35,
|
|
67
|
+
motion: { damping: 28, stiffness: 78, mass: 1.4 },
|
|
57
68
|
},
|
|
58
69
|
// Saturated arcade — dual lights, scanlines, snappy whips and zooms.
|
|
70
|
+
// Motion: snappy with a playful overshoot — light mass, low damping.
|
|
59
71
|
arcade: {
|
|
60
72
|
glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
|
|
61
73
|
camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
|
|
62
|
-
|
|
74
|
+
energy: 0.85,
|
|
75
|
+
motion: { damping: 11, stiffness: 155, mass: 0.7 },
|
|
63
76
|
},
|
|
64
77
|
// Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
|
|
78
|
+
// Motion: stiff and precise — quick, minimal bounce, machined.
|
|
65
79
|
blueprint: {
|
|
66
80
|
glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
|
|
67
81
|
camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
|
|
68
|
-
|
|
82
|
+
energy: 0.6,
|
|
83
|
+
motion: { damping: 30, stiffness: 150, mass: 1.1 },
|
|
69
84
|
},
|
|
70
85
|
// Premium brand film — warm grade, big soft vignette, very slow elegant push.
|
|
86
|
+
// Motion: the smoothest — elegant glide, no perceptible bounce.
|
|
71
87
|
editorial: {
|
|
72
88
|
glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
|
|
73
89
|
camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
|
|
74
|
-
|
|
90
|
+
energy: 0.3,
|
|
91
|
+
motion: { damping: 26, stiffness: 88, mass: 1.15 },
|
|
75
92
|
},
|
|
76
93
|
};
|
|
77
94
|
|
|
@@ -8,9 +8,9 @@ import { CinematicLook, TransitionStyle } from "./look";
|
|
|
8
8
|
const RHYTHM: Record<string, TransitionStyle[]> = {
|
|
9
9
|
keynote: ["fade", "rise", "fade", "rise"],
|
|
10
10
|
noir: ["dip", "fade", "dip", "rise"],
|
|
11
|
-
arcade: ["whip", "
|
|
12
|
-
blueprint: ["cut", "
|
|
13
|
-
editorial: ["fade", "
|
|
11
|
+
arcade: ["whip", "flip", "zoom", "whip"],
|
|
12
|
+
blueprint: ["cut", "wipe", "cut", "whip"],
|
|
13
|
+
editorial: ["fade", "wipe", "rise", "fade"],
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export function transitionFor(look: CinematicLook, index: number, total: number): TransitionStyle {
|
|
@@ -43,8 +43,11 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
43
43
|
const p = interpolate(frame, [0, win], [0, 1], { ...ease, easing: Easing.out(Easing.cubic) });
|
|
44
44
|
|
|
45
45
|
let opacity = 1;
|
|
46
|
-
let
|
|
46
|
+
let scale: string | undefined;
|
|
47
|
+
let translate: string | undefined;
|
|
47
48
|
let filter: string | undefined;
|
|
49
|
+
let transform: string | undefined;
|
|
50
|
+
let clipPath: string | undefined;
|
|
48
51
|
|
|
49
52
|
switch (style) {
|
|
50
53
|
case "cut":
|
|
@@ -54,25 +57,38 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
54
57
|
break;
|
|
55
58
|
case "rise":
|
|
56
59
|
opacity = p;
|
|
57
|
-
|
|
60
|
+
translate = `0 ${interpolate(p, [0, 1], [44, 0])}px`;
|
|
58
61
|
break;
|
|
59
62
|
case "whip":
|
|
60
63
|
opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
|
|
61
|
-
|
|
64
|
+
translate = `${interpolate(p, [0, 1], [90 * dir, 0])}px 0`;
|
|
62
65
|
filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
|
|
63
66
|
break;
|
|
64
67
|
case "punch":
|
|
65
68
|
opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
|
|
66
|
-
|
|
69
|
+
scale = String(interpolate(p, [0, 1], [1.12, 1]));
|
|
67
70
|
break;
|
|
68
71
|
case "zoom":
|
|
69
72
|
opacity = p;
|
|
70
|
-
|
|
73
|
+
scale = String(interpolate(p, [0, 1], [0.9, 1]));
|
|
71
74
|
break;
|
|
72
75
|
case "dip":
|
|
73
76
|
// Content snaps in; a black veil over the top lifts away → dip-to-black.
|
|
74
77
|
opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
|
|
75
78
|
break;
|
|
79
|
+
case "wipe": {
|
|
80
|
+
// A hard edge sweeps across, revealing the shot. Direction alternates by
|
|
81
|
+
// seed so consecutive wipes don't all travel the same way.
|
|
82
|
+
opacity = interpolate(frame, [0, win * 0.3], [0, 1], ease);
|
|
83
|
+
const remain = interpolate(p, [0, 1], [100, 0]);
|
|
84
|
+
clipPath = dir > 0 ? `inset(0 ${remain}% 0 0)` : `inset(0 0 0 ${remain}%)`;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "flip":
|
|
88
|
+
// Shot swings in on a vertical hinge — a single playful rotation.
|
|
89
|
+
opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
|
|
90
|
+
transform = `perspective(1400px) rotateY(${interpolate(p, [0, 1], [62 * dir, 0])}deg)`;
|
|
91
|
+
break;
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
const dipVeil = style === "dip" && (
|
|
@@ -83,7 +99,9 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
83
99
|
|
|
84
100
|
return (
|
|
85
101
|
<>
|
|
86
|
-
<AbsoluteFill
|
|
102
|
+
<AbsoluteFill
|
|
103
|
+
style={{ opacity, scale, translate, filter, transform, clipPath, willChange: "scale, translate, opacity, transform, clip-path" }}
|
|
104
|
+
>
|
|
87
105
|
{children}
|
|
88
106
|
</AbsoluteFill>
|
|
89
107
|
{dipVeil}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion";
|
|
3
3
|
import { Theme } from "../../theme";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
@@ -21,8 +21,12 @@ export const Bar: React.FC<Props> = ({ label, valueText, fraction, hero, theme,
|
|
|
21
21
|
fps,
|
|
22
22
|
config: theme.motion,
|
|
23
23
|
});
|
|
24
|
-
const width = interpolate(Math.max(0, progress), [0, 1], [0, Math.max(0, Math.min(1, fraction))]
|
|
24
|
+
const width = interpolate(Math.max(0, progress), [0, 1], [0, Math.max(0, Math.min(1, fraction))], {
|
|
25
|
+
extrapolateLeft: "clamp",
|
|
26
|
+
extrapolateRight: "clamp",
|
|
27
|
+
});
|
|
25
28
|
const valueOpacity = interpolate(frame - startFrame, [10, 22], [0, 1], {
|
|
29
|
+
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
|
26
30
|
extrapolateLeft: "clamp",
|
|
27
31
|
extrapolateRight: "clamp",
|
|
28
32
|
});
|
|
@@ -28,7 +28,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0, b
|
|
|
28
28
|
display: "flex",
|
|
29
29
|
justifyContent: "center",
|
|
30
30
|
opacity,
|
|
31
|
-
|
|
31
|
+
translate: `0 ${translateY}px`,
|
|
32
32
|
}}
|
|
33
33
|
>
|
|
34
34
|
<div
|
|
@@ -38,7 +38,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0, b
|
|
|
38
38
|
borderRadius: 999,
|
|
39
39
|
padding: "10px 28px",
|
|
40
40
|
fontFamily: theme.fontSans,
|
|
41
|
-
fontSize:
|
|
41
|
+
fontSize: 32,
|
|
42
42
|
fontWeight: 500,
|
|
43
43
|
color: theme.text,
|
|
44
44
|
letterSpacing: "-0.01em",
|
|
@@ -54,6 +54,13 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|
|
54
54
|
zap: Zap,
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
// Whether a name resolves to a real icon. Callers use this to fall back visibly
|
|
58
|
+
// (e.g. to a numbered marker) instead of rendering an empty slot when a brief
|
|
59
|
+
// names an icon outside the registry (F6).
|
|
60
|
+
export function hasIcon(name: string): boolean {
|
|
61
|
+
return name.toLowerCase() in ICON_MAP;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
export interface IconProps {
|
|
58
65
|
name: string;
|
|
59
66
|
size?: number;
|
|
@@ -12,7 +12,10 @@ interface LabelProps {
|
|
|
12
12
|
emphasis?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Floors from the video-layout rules at 1080-wide: labels >=32, supporting >=44,
|
|
16
|
+
// headline >=84. Each step meets or clears its floor so text stays readable at
|
|
17
|
+
// viewing distance.
|
|
18
|
+
const sizeMap = { sm: 32, md: 44, lg: 60, xl: 88 };
|
|
16
19
|
|
|
17
20
|
export const Label: React.FC<LabelProps> = ({
|
|
18
21
|
text,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
|
|
4
|
+
type LoopProperty = "scale" | "translateX" | "translateY" | "rotate";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Which transform channel oscillates. */
|
|
8
|
+
property?: LoopProperty;
|
|
9
|
+
/**
|
|
10
|
+
* Peak deviation from rest: px for translate, degrees for rotate, and a
|
|
11
|
+
* scale delta for scale (0.02 = breathes between 0.98 and 1.02).
|
|
12
|
+
*/
|
|
13
|
+
amplitude?: number;
|
|
14
|
+
/** Seconds for one full cycle. */
|
|
15
|
+
period?: number;
|
|
16
|
+
/** 0..1 phase offset so several looped elements don't move in lockstep. */
|
|
17
|
+
phase?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Skip the motion (gif output): a perpetually moving element changes every
|
|
20
|
+
* frame and defeats gif compression, same reasoning as the Camera.
|
|
21
|
+
*/
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Applies a subtle, continuous, seamless oscillation to a held element — the
|
|
28
|
+
* declarative "loop" channel the entrance springs don't cover. Motion is a sine
|
|
29
|
+
* driven by useCurrentFrame, so it loops without a seam and renders
|
|
30
|
+
* deterministically. Keep amplitudes small: this is meant to read as "alive",
|
|
31
|
+
* not as a bounce.
|
|
32
|
+
*/
|
|
33
|
+
export const Loop: React.FC<Props> = ({
|
|
34
|
+
property = "translateY",
|
|
35
|
+
amplitude = 6,
|
|
36
|
+
period = 3.5,
|
|
37
|
+
phase = 0,
|
|
38
|
+
disabled,
|
|
39
|
+
children,
|
|
40
|
+
}) => {
|
|
41
|
+
const frame = useCurrentFrame();
|
|
42
|
+
const { fps } = useVideoConfig();
|
|
43
|
+
if (disabled) return <>{children}</>;
|
|
44
|
+
|
|
45
|
+
const v = Math.sin((frame / (period * fps) + phase) * Math.PI * 2) * amplitude;
|
|
46
|
+
|
|
47
|
+
let style: React.CSSProperties;
|
|
48
|
+
switch (property) {
|
|
49
|
+
case "scale":
|
|
50
|
+
style = { scale: String(1 + v) };
|
|
51
|
+
break;
|
|
52
|
+
case "translateX":
|
|
53
|
+
style = { translate: `${v}px 0` };
|
|
54
|
+
break;
|
|
55
|
+
case "rotate":
|
|
56
|
+
style = { rotate: `${v}deg` };
|
|
57
|
+
break;
|
|
58
|
+
case "translateY":
|
|
59
|
+
default:
|
|
60
|
+
style = { translate: `0 ${v}px` };
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return <div style={{ ...style, willChange: "transform" }}>{children}</div>;
|
|
65
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion";
|
|
3
3
|
import { Theme } from "../../theme";
|
|
4
4
|
|
|
5
5
|
interface RevealTextProps {
|
|
@@ -44,7 +44,29 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
44
44
|
const frame = useCurrentFrame();
|
|
45
45
|
const { fps } = useVideoConfig();
|
|
46
46
|
const words = text.split(" ");
|
|
47
|
-
|
|
47
|
+
|
|
48
|
+
// Resolve which word indices the accent covers. A multi-word accent phrase
|
|
49
|
+
// ("one command") must emphasize the whole contiguous run, not silently no-op
|
|
50
|
+
// because no single word equals the phrase (F2). Punctuation is stripped from
|
|
51
|
+
// both sides so "video." matches "video".
|
|
52
|
+
const clean = (w: string) => w.replace(/[.,!?:;]/g, "").toLowerCase();
|
|
53
|
+
const cleanedWords = words.map(clean);
|
|
54
|
+
const emphIndices = new Set<number>();
|
|
55
|
+
const accentWords = emphasis?.trim() ? emphasis.trim().split(/\s+/).map(clean).filter(Boolean) : [];
|
|
56
|
+
if (accentWords.length === 1) {
|
|
57
|
+
const a = accentWords[0];
|
|
58
|
+
cleanedWords.forEach((c, i) => {
|
|
59
|
+
if (c === a || c.includes(a)) emphIndices.add(i);
|
|
60
|
+
});
|
|
61
|
+
} else if (accentWords.length > 1) {
|
|
62
|
+
for (let i = 0; i + accentWords.length <= cleanedWords.length; i++) {
|
|
63
|
+
const matches = accentWords.every((a, j) => {
|
|
64
|
+
const c = cleanedWords[i + j];
|
|
65
|
+
return c === a || c.includes(a);
|
|
66
|
+
});
|
|
67
|
+
if (matches) accentWords.forEach((_, j) => emphIndices.add(i + j));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
48
70
|
|
|
49
71
|
return (
|
|
50
72
|
<div
|
|
@@ -65,11 +87,11 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
65
87
|
const p = spring({ frame: elapsed, fps, config: theme.motion });
|
|
66
88
|
const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
|
|
67
89
|
const opacity = interpolate(elapsed, [0, 6], [0, 1], {
|
|
90
|
+
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
|
68
91
|
extrapolateLeft: "clamp",
|
|
69
92
|
extrapolateRight: "clamp",
|
|
70
93
|
});
|
|
71
|
-
const
|
|
72
|
-
const isEmph = emphWord && (clean === emphWord || word.toLowerCase().includes(emphWord));
|
|
94
|
+
const isEmph = emphIndices.has(i);
|
|
73
95
|
return (
|
|
74
96
|
<React.Fragment key={i}>
|
|
75
97
|
<span
|
|
@@ -84,7 +106,7 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
84
106
|
<span
|
|
85
107
|
style={{
|
|
86
108
|
display: "inline-block",
|
|
87
|
-
|
|
109
|
+
translate: `0 ${y}%`,
|
|
88
110
|
opacity,
|
|
89
111
|
color: isEmph ? theme.accent : undefined,
|
|
90
112
|
}}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill } from "remotion";
|
|
3
|
+
import { Theme } from "../../theme";
|
|
4
|
+
import { Caption } from "./Caption";
|
|
5
|
+
|
|
6
|
+
// Standard horizontal safe inset for scene content — keeps text off the frame
|
|
7
|
+
// edges (the video-layout rules want key content ~120px in on a 1080–1920 frame).
|
|
8
|
+
export const STAGE_INSET = 120;
|
|
9
|
+
|
|
10
|
+
interface StageProps {
|
|
11
|
+
theme: Theme;
|
|
12
|
+
/** Stacking direction of the content. */
|
|
13
|
+
direction?: "row" | "column";
|
|
14
|
+
justify?: React.CSSProperties["justifyContent"];
|
|
15
|
+
align?: React.CSSProperties["alignItems"];
|
|
16
|
+
gap?: number;
|
|
17
|
+
/** Horizontal padding; defaults to the standard safe inset on both sides. */
|
|
18
|
+
padding?: React.CSSProperties["padding"];
|
|
19
|
+
/** Optional caption rendered as the film's bottom pill. */
|
|
20
|
+
caption?: string;
|
|
21
|
+
captionStart?: number;
|
|
22
|
+
bottomInset?: number;
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The shared scene root. Every scene is shot on the one continuous Atmosphere, so
|
|
28
|
+
* the root is ALWAYS transparent — never paint an opaque full-frame fill here or
|
|
29
|
+
* the stage behind it disappears and the reel stops reading as one film. Content
|
|
30
|
+
* is centered by default so leftover space stays symmetric (composed negative
|
|
31
|
+
* space) instead of being dumped against one edge as a void. For a hero + aside
|
|
32
|
+
* composition use `direction="row"` with `justify="space-between"`.
|
|
33
|
+
*/
|
|
34
|
+
export const Stage: React.FC<StageProps> = ({
|
|
35
|
+
theme,
|
|
36
|
+
direction = "column",
|
|
37
|
+
justify = "center",
|
|
38
|
+
align = "center",
|
|
39
|
+
gap,
|
|
40
|
+
padding,
|
|
41
|
+
caption,
|
|
42
|
+
captionStart = 50,
|
|
43
|
+
bottomInset = 0,
|
|
44
|
+
children,
|
|
45
|
+
}) => {
|
|
46
|
+
return (
|
|
47
|
+
<AbsoluteFill
|
|
48
|
+
style={{
|
|
49
|
+
background: "transparent",
|
|
50
|
+
display: "flex",
|
|
51
|
+
flexDirection: direction,
|
|
52
|
+
alignItems: align,
|
|
53
|
+
justifyContent: justify,
|
|
54
|
+
gap,
|
|
55
|
+
padding: padding ?? `0 ${STAGE_INSET}px`,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
{caption && <Caption text={caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />}
|
|
60
|
+
</AbsoluteFill>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
@@ -38,7 +38,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme, bottomInset = 0 })
|
|
|
38
38
|
style={{
|
|
39
39
|
width: "100%",
|
|
40
40
|
opacity: windowOpacity,
|
|
41
|
-
|
|
41
|
+
scale: String(windowScale),
|
|
42
42
|
boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
|
|
43
43
|
borderRadius: 12,
|
|
44
44
|
overflow: "hidden",
|