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
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
import { MotionProfile } from "../theme";
|
|
9
|
+
|
|
10
|
+
export type LookId = "keynote" | "noir" | "arcade" | "blueprint" | "editorial";
|
|
11
|
+
|
|
12
|
+
export type TransitionStyle = "cut" | "fade" | "dip" | "whip" | "rise" | "punch" | "zoom" | "wipe" | "flip";
|
|
13
|
+
|
|
14
|
+
export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
|
|
15
|
+
|
|
16
|
+
export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
|
|
17
|
+
|
|
18
|
+
export interface Grade {
|
|
19
|
+
/** Tint color layered over the whole frame. */
|
|
20
|
+
color: string;
|
|
21
|
+
blend: "multiply" | "screen" | "overlay" | "soft-light";
|
|
22
|
+
alpha: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CinematicLook {
|
|
26
|
+
id: LookId;
|
|
27
|
+
/** Radial accent-light intensity behind the content (0..1). */
|
|
28
|
+
glow: number;
|
|
29
|
+
/** Whether a second, cooler light source is added for depth. */
|
|
30
|
+
twoTone: boolean;
|
|
31
|
+
/** Vignette darkness at the edges (0..1). */
|
|
32
|
+
vignette: number;
|
|
33
|
+
/** Film-grain opacity on the final frame (0..1); skipped for gif output. */
|
|
34
|
+
grain: number;
|
|
35
|
+
overlay: AtmosphereOverlay;
|
|
36
|
+
/** Base camera move applied to every scene's content. */
|
|
37
|
+
camera: CameraMove;
|
|
38
|
+
/** Camera amplitude multiplier (1 = default). */
|
|
39
|
+
cameraIntensity: number;
|
|
40
|
+
grade: Grade;
|
|
41
|
+
/** Editing energy: higher tightens enter timings and favors hard cuts. */
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
|
|
53
|
+
// Clean keynote stage — soft key light, gentle push, long dissolves.
|
|
54
|
+
// Motion: settled, no bounce — elements arrive and hold.
|
|
55
|
+
keynote: {
|
|
56
|
+
glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
|
|
57
|
+
camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
|
|
58
|
+
energy: 0.4,
|
|
59
|
+
motion: { damping: 22, stiffness: 100, mass: 1.0 },
|
|
60
|
+
},
|
|
61
|
+
// Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
|
|
62
|
+
// Motion: heavy and deliberate — weighty mass, slow to settle.
|
|
63
|
+
noir: {
|
|
64
|
+
glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
|
|
65
|
+
camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
|
|
66
|
+
energy: 0.35,
|
|
67
|
+
motion: { damping: 28, stiffness: 78, mass: 1.4 },
|
|
68
|
+
},
|
|
69
|
+
// Saturated arcade — dual lights, scanlines, snappy whips and zooms.
|
|
70
|
+
// Motion: snappy with a playful overshoot — light mass, low damping.
|
|
71
|
+
arcade: {
|
|
72
|
+
glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
|
|
73
|
+
camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
|
|
74
|
+
energy: 0.85,
|
|
75
|
+
motion: { damping: 11, stiffness: 155, mass: 0.7 },
|
|
76
|
+
},
|
|
77
|
+
// Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
|
|
78
|
+
// Motion: stiff and precise — quick, minimal bounce, machined.
|
|
79
|
+
blueprint: {
|
|
80
|
+
glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
|
|
81
|
+
camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
|
|
82
|
+
energy: 0.6,
|
|
83
|
+
motion: { damping: 30, stiffness: 150, mass: 1.1 },
|
|
84
|
+
},
|
|
85
|
+
// Premium brand film — warm grade, big soft vignette, very slow elegant push.
|
|
86
|
+
// Motion: the smoothest — elegant glide, no perceptible bounce.
|
|
87
|
+
editorial: {
|
|
88
|
+
glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
|
|
89
|
+
camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
|
|
90
|
+
energy: 0.3,
|
|
91
|
+
motion: { damping: 26, stiffness: 88, mass: 1.15 },
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const TONE_DEFAULT: Record<string, LookId> = {
|
|
96
|
+
professional: "keynote",
|
|
97
|
+
playful: "arcade",
|
|
98
|
+
technical: "blueprint",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function resolveLook(look: string | undefined, tone: string | undefined): CinematicLook {
|
|
102
|
+
const id = (look && look in LOOKS ? look : TONE_DEFAULT[tone ?? "professional"] ?? "keynote") as LookId;
|
|
103
|
+
return { id, ...LOOKS[id] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const LOOK_IDS = Object.keys(LOOKS) as LookId[];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
|
|
3
|
+
import { CinematicLook, TransitionStyle } from "./look";
|
|
4
|
+
|
|
5
|
+
// Per-look cut rhythm. The picker cycles these across scenes so the edit has a
|
|
6
|
+
// pattern instead of one uniform crossfade everywhere. Scene 0 always opens
|
|
7
|
+
// from black; the final scene lands on a punch so the CTA arrives, not drifts.
|
|
8
|
+
const RHYTHM: Record<string, TransitionStyle[]> = {
|
|
9
|
+
keynote: ["fade", "rise", "fade", "rise"],
|
|
10
|
+
noir: ["dip", "fade", "dip", "rise"],
|
|
11
|
+
arcade: ["whip", "flip", "zoom", "whip"],
|
|
12
|
+
blueprint: ["cut", "wipe", "cut", "whip"],
|
|
13
|
+
editorial: ["fade", "wipe", "rise", "fade"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function transitionFor(look: CinematicLook, index: number, total: number): TransitionStyle {
|
|
17
|
+
if (index === 0) return "fade"; // open from black
|
|
18
|
+
if (index === total - 1) return look.energy > 0.7 ? "punch" : "rise";
|
|
19
|
+
const seq = RHYTHM[look.id] ?? RHYTHM.keynote;
|
|
20
|
+
return seq[index % seq.length];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface EnterProps {
|
|
24
|
+
style: TransitionStyle;
|
|
25
|
+
look: CinematicLook;
|
|
26
|
+
/** True only for the first scene: reveal from a real black frame. */
|
|
27
|
+
fromBlack: boolean;
|
|
28
|
+
seed: number;
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Applies a scene's entrance. Scenes are sequenced back-to-back with no exit
|
|
34
|
+
* fade, so a "cut" is a true hard cut and the others are how the shot arrives.
|
|
35
|
+
* The continuous Atmosphere behind keeps even hard cuts feeling connected.
|
|
36
|
+
*/
|
|
37
|
+
export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, children }) => {
|
|
38
|
+
const frame = useCurrentFrame();
|
|
39
|
+
// Faster entrances for higher-energy looks.
|
|
40
|
+
const win = Math.round(interpolate(look.energy, [0, 1], [18, 8]));
|
|
41
|
+
const dir = seed % 2 === 0 ? 1 : -1;
|
|
42
|
+
const ease = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const };
|
|
43
|
+
const p = interpolate(frame, [0, win], [0, 1], { ...ease, easing: Easing.out(Easing.cubic) });
|
|
44
|
+
|
|
45
|
+
let opacity = 1;
|
|
46
|
+
let scale: string | undefined;
|
|
47
|
+
let translate: string | undefined;
|
|
48
|
+
let filter: string | undefined;
|
|
49
|
+
let transform: string | undefined;
|
|
50
|
+
let clipPath: string | undefined;
|
|
51
|
+
|
|
52
|
+
switch (style) {
|
|
53
|
+
case "cut":
|
|
54
|
+
break;
|
|
55
|
+
case "fade":
|
|
56
|
+
opacity = interpolate(frame, [0, fromBlack ? win + 6 : win], [0, 1], ease);
|
|
57
|
+
break;
|
|
58
|
+
case "rise":
|
|
59
|
+
opacity = p;
|
|
60
|
+
translate = `0 ${interpolate(p, [0, 1], [44, 0])}px`;
|
|
61
|
+
break;
|
|
62
|
+
case "whip":
|
|
63
|
+
opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
|
|
64
|
+
translate = `${interpolate(p, [0, 1], [90 * dir, 0])}px 0`;
|
|
65
|
+
filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
|
|
66
|
+
break;
|
|
67
|
+
case "punch":
|
|
68
|
+
opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
|
|
69
|
+
scale = String(interpolate(p, [0, 1], [1.12, 1]));
|
|
70
|
+
break;
|
|
71
|
+
case "zoom":
|
|
72
|
+
opacity = p;
|
|
73
|
+
scale = String(interpolate(p, [0, 1], [0.9, 1]));
|
|
74
|
+
break;
|
|
75
|
+
case "dip":
|
|
76
|
+
// Content snaps in; a black veil over the top lifts away → dip-to-black.
|
|
77
|
+
opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
|
|
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;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const dipVeil = style === "dip" && (
|
|
95
|
+
<AbsoluteFill
|
|
96
|
+
style={{ background: "#000", opacity: interpolate(frame, [0, win], [1, 0], ease), pointerEvents: "none" }}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<AbsoluteFill
|
|
103
|
+
style={{ opacity, scale, translate, filter, transform, clipPath, willChange: "scale, translate, opacity, transform, clip-path" }}
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</AbsoluteFill>
|
|
107
|
+
{dipVeil}
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion";
|
|
3
|
+
import { Theme } from "../../theme";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
label: string;
|
|
7
|
+
valueText: string;
|
|
8
|
+
/** Target fill width as a fraction of the track (0..1). */
|
|
9
|
+
fraction: number;
|
|
10
|
+
hero?: boolean;
|
|
11
|
+
theme: Theme;
|
|
12
|
+
startFrame: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Bar: React.FC<Props> = ({ label, valueText, fraction, hero, theme, startFrame }) => {
|
|
16
|
+
const frame = useCurrentFrame();
|
|
17
|
+
const { fps } = useVideoConfig();
|
|
18
|
+
|
|
19
|
+
const progress = spring({
|
|
20
|
+
frame: frame - startFrame,
|
|
21
|
+
fps,
|
|
22
|
+
config: theme.motion,
|
|
23
|
+
});
|
|
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
|
+
});
|
|
28
|
+
const valueOpacity = interpolate(frame - startFrame, [10, 22], [0, 1], {
|
|
29
|
+
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
|
30
|
+
extrapolateLeft: "clamp",
|
|
31
|
+
extrapolateRight: "clamp",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div style={{ display: "flex", alignItems: "center", gap: 28 }}>
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
width: 300,
|
|
39
|
+
textAlign: "right",
|
|
40
|
+
flexShrink: 0,
|
|
41
|
+
fontFamily: theme.fontSans,
|
|
42
|
+
fontSize: 30,
|
|
43
|
+
fontWeight: hero ? 700 : 500,
|
|
44
|
+
color: hero ? theme.text : theme.textMuted,
|
|
45
|
+
letterSpacing: "-0.01em",
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{label}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div
|
|
52
|
+
style={{
|
|
53
|
+
flex: 1,
|
|
54
|
+
height: 56,
|
|
55
|
+
background: theme.surface,
|
|
56
|
+
borderRadius: 12,
|
|
57
|
+
display: "flex",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
width: `${width * 100}%`,
|
|
64
|
+
height: "100%",
|
|
65
|
+
background: hero ? theme.accent : theme.border,
|
|
66
|
+
borderRadius: 12,
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
<div
|
|
71
|
+
style={{
|
|
72
|
+
opacity: valueOpacity,
|
|
73
|
+
marginLeft: 18,
|
|
74
|
+
whiteSpace: "nowrap",
|
|
75
|
+
fontFamily: theme.fontMono,
|
|
76
|
+
fontSize: 30,
|
|
77
|
+
fontWeight: 700,
|
|
78
|
+
color: hero ? theme.accent : theme.textMuted,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{valueText}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -6,9 +6,10 @@ interface CaptionProps {
|
|
|
6
6
|
text: string;
|
|
7
7
|
theme: Theme;
|
|
8
8
|
startFrame?: number;
|
|
9
|
+
bottomInset?: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0 }) => {
|
|
12
|
+
export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0, bottomInset = 0 }) => {
|
|
12
13
|
const frame = useCurrentFrame();
|
|
13
14
|
const { fps } = useVideoConfig();
|
|
14
15
|
const elapsed = frame - startFrame;
|
|
@@ -21,13 +22,13 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0 })
|
|
|
21
22
|
<div
|
|
22
23
|
style={{
|
|
23
24
|
position: "absolute",
|
|
24
|
-
bottom: 72,
|
|
25
|
+
bottom: 72 + bottomInset,
|
|
25
26
|
left: 0,
|
|
26
27
|
right: 0,
|
|
27
28
|
display: "flex",
|
|
28
29
|
justifyContent: "center",
|
|
29
30
|
opacity,
|
|
30
|
-
|
|
31
|
+
translate: `0 ${translateY}px`,
|
|
31
32
|
}}
|
|
32
33
|
>
|
|
33
34
|
<div
|
|
@@ -37,7 +38,7 @@ export const Caption: React.FC<CaptionProps> = ({ text, theme, startFrame = 0 })
|
|
|
37
38
|
borderRadius: 999,
|
|
38
39
|
padding: "10px 28px",
|
|
39
40
|
fontFamily: theme.fontSans,
|
|
40
|
-
fontSize:
|
|
41
|
+
fontSize: 32,
|
|
41
42
|
fontWeight: 500,
|
|
42
43
|
color: theme.text,
|
|
43
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;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
|
+
import { Theme } from "../../theme";
|
|
4
|
+
|
|
5
|
+
interface KickerProps {
|
|
6
|
+
text: string;
|
|
7
|
+
theme: Theme;
|
|
8
|
+
startFrame?: number;
|
|
9
|
+
align?: "left" | "center";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A small eyebrow label above a headline: an accent tick + uppercase mono tag. */
|
|
13
|
+
export const Kicker: React.FC<KickerProps> = ({ text, theme, startFrame = 0, align = "left" }) => {
|
|
14
|
+
const frame = useCurrentFrame();
|
|
15
|
+
const { fps } = useVideoConfig();
|
|
16
|
+
const p = spring({ frame: frame - startFrame, fps, config: theme.motion });
|
|
17
|
+
const opacity = interpolate(Math.max(0, p), [0, 1], [0, 1]);
|
|
18
|
+
const lineW = interpolate(Math.max(0, p), [0, 1], [0, 40]);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
display: "flex",
|
|
24
|
+
alignItems: "center",
|
|
25
|
+
gap: 14,
|
|
26
|
+
justifyContent: align === "center" ? "center" : "flex-start",
|
|
27
|
+
marginBottom: 22,
|
|
28
|
+
opacity,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<div style={{ width: lineW, height: 3, background: theme.accent, borderRadius: 2 }} />
|
|
32
|
+
<span
|
|
33
|
+
style={{
|
|
34
|
+
fontFamily: theme.fontMono,
|
|
35
|
+
fontSize: 22,
|
|
36
|
+
fontWeight: 600,
|
|
37
|
+
letterSpacing: "0.22em",
|
|
38
|
+
textTransform: "uppercase",
|
|
39
|
+
color: theme.accent,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{text}
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
|
3
2
|
import { Theme } from "../../theme";
|
|
3
|
+
import { RevealText } from "./RevealText";
|
|
4
4
|
|
|
5
5
|
interface LabelProps {
|
|
6
6
|
text: string;
|
|
@@ -9,9 +9,13 @@ interface LabelProps {
|
|
|
9
9
|
size?: "sm" | "md" | "lg" | "xl";
|
|
10
10
|
align?: "left" | "center" | "right";
|
|
11
11
|
muted?: boolean;
|
|
12
|
+
emphasis?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
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 };
|
|
15
19
|
|
|
16
20
|
export const Label: React.FC<LabelProps> = ({
|
|
17
21
|
text,
|
|
@@ -20,30 +24,23 @@ export const Label: React.FC<LabelProps> = ({
|
|
|
20
24
|
size = "md",
|
|
21
25
|
align = "center",
|
|
22
26
|
muted = false,
|
|
27
|
+
emphasis,
|
|
23
28
|
}) => {
|
|
24
|
-
const
|
|
25
|
-
const { fps } = useVideoConfig();
|
|
26
|
-
const elapsed = frame - startFrame;
|
|
27
|
-
|
|
28
|
-
const progress = spring({ frame: elapsed, fps, config: theme.motion });
|
|
29
|
-
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
|
30
|
-
const translateY = interpolate(progress, [0, 1], [20, 0]);
|
|
31
|
-
|
|
29
|
+
const big = size === "xl" || size === "lg";
|
|
32
30
|
return (
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</div>
|
|
31
|
+
<RevealText
|
|
32
|
+
text={text}
|
|
33
|
+
theme={theme}
|
|
34
|
+
startFrame={startFrame}
|
|
35
|
+
fontSize={sizeMap[size]}
|
|
36
|
+
fontWeight={big ? 700 : 500}
|
|
37
|
+
color={muted ? theme.textMuted : theme.text}
|
|
38
|
+
align={align}
|
|
39
|
+
emphasis={emphasis}
|
|
40
|
+
stagger={big ? 3 : 1.5}
|
|
41
|
+
letterSpacing={size === "xl" ? "-0.03em" : "-0.01em"}
|
|
42
|
+
lineHeight={big ? 1.1 : 1.25}
|
|
43
|
+
glow={big && !muted}
|
|
44
|
+
/>
|
|
48
45
|
);
|
|
49
46
|
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion";
|
|
3
|
+
import { Theme } from "../../theme";
|
|
4
|
+
|
|
5
|
+
interface RevealTextProps {
|
|
6
|
+
text: string;
|
|
7
|
+
theme: Theme;
|
|
8
|
+
fontSize: number;
|
|
9
|
+
startFrame?: number;
|
|
10
|
+
fontWeight?: number;
|
|
11
|
+
color?: string;
|
|
12
|
+
/** A whole word (or exact substring) rendered in the accent color. */
|
|
13
|
+
emphasis?: string;
|
|
14
|
+
align?: "left" | "center" | "right";
|
|
15
|
+
/** Frames between each word's rise. */
|
|
16
|
+
stagger?: number;
|
|
17
|
+
letterSpacing?: string;
|
|
18
|
+
lineHeight?: number;
|
|
19
|
+
/** Soft shadow so the type sits in the lit scene instead of floating flat. */
|
|
20
|
+
glow?: boolean;
|
|
21
|
+
maxWidth?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Masked kinetic typography: each word rises out from behind a clip edge with a
|
|
26
|
+
* staggered spring — the headline reveal used in premium launch films, not a
|
|
27
|
+
* flat fade. Words wrap naturally; descenders are preserved by the mask padding.
|
|
28
|
+
*/
|
|
29
|
+
export const RevealText: React.FC<RevealTextProps> = ({
|
|
30
|
+
text,
|
|
31
|
+
theme,
|
|
32
|
+
fontSize,
|
|
33
|
+
startFrame = 0,
|
|
34
|
+
fontWeight = 800,
|
|
35
|
+
color,
|
|
36
|
+
emphasis,
|
|
37
|
+
align = "center",
|
|
38
|
+
stagger = 3,
|
|
39
|
+
letterSpacing = "-0.03em",
|
|
40
|
+
lineHeight = 1.06,
|
|
41
|
+
glow = true,
|
|
42
|
+
maxWidth,
|
|
43
|
+
}) => {
|
|
44
|
+
const frame = useCurrentFrame();
|
|
45
|
+
const { fps } = useVideoConfig();
|
|
46
|
+
const words = text.split(" ");
|
|
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
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
fontFamily: theme.fontSans,
|
|
75
|
+
fontSize,
|
|
76
|
+
fontWeight,
|
|
77
|
+
color: color ?? theme.text,
|
|
78
|
+
textAlign: align,
|
|
79
|
+
lineHeight,
|
|
80
|
+
letterSpacing,
|
|
81
|
+
maxWidth,
|
|
82
|
+
textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{words.map((word, i) => {
|
|
86
|
+
const elapsed = frame - startFrame - i * stagger;
|
|
87
|
+
const p = spring({ frame: elapsed, fps, config: theme.motion });
|
|
88
|
+
const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
|
|
89
|
+
const opacity = interpolate(elapsed, [0, 6], [0, 1], {
|
|
90
|
+
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
|
91
|
+
extrapolateLeft: "clamp",
|
|
92
|
+
extrapolateRight: "clamp",
|
|
93
|
+
});
|
|
94
|
+
const isEmph = emphIndices.has(i);
|
|
95
|
+
return (
|
|
96
|
+
<React.Fragment key={i}>
|
|
97
|
+
<span
|
|
98
|
+
style={{
|
|
99
|
+
display: "inline-block",
|
|
100
|
+
overflow: "hidden",
|
|
101
|
+
paddingBottom: "0.14em",
|
|
102
|
+
marginBottom: "-0.14em",
|
|
103
|
+
verticalAlign: "bottom",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<span
|
|
107
|
+
style={{
|
|
108
|
+
display: "inline-block",
|
|
109
|
+
translate: `0 ${y}%`,
|
|
110
|
+
opacity,
|
|
111
|
+
color: isEmph ? theme.accent : undefined,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{word}
|
|
115
|
+
</span>
|
|
116
|
+
</span>
|
|
117
|
+
{i < words.length - 1 ? " " : null}
|
|
118
|
+
</React.Fragment>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
};
|