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
|
@@ -0,0 +1,92 @@
|
|
|
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", "punch", "zoom", "whip"],
|
|
12
|
+
blueprint: ["cut", "rise", "cut", "whip"],
|
|
13
|
+
editorial: ["fade", "fade", "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 transform = "";
|
|
47
|
+
let filter: string | undefined;
|
|
48
|
+
|
|
49
|
+
switch (style) {
|
|
50
|
+
case "cut":
|
|
51
|
+
break;
|
|
52
|
+
case "fade":
|
|
53
|
+
opacity = interpolate(frame, [0, fromBlack ? win + 6 : win], [0, 1], ease);
|
|
54
|
+
break;
|
|
55
|
+
case "rise":
|
|
56
|
+
opacity = p;
|
|
57
|
+
transform = `translateY(${interpolate(p, [0, 1], [44, 0])}px)`;
|
|
58
|
+
break;
|
|
59
|
+
case "whip":
|
|
60
|
+
opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
|
|
61
|
+
transform = `translateX(${interpolate(p, [0, 1], [90 * dir, 0])}px)`;
|
|
62
|
+
filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
|
|
63
|
+
break;
|
|
64
|
+
case "punch":
|
|
65
|
+
opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
|
|
66
|
+
transform = `scale(${interpolate(p, [0, 1], [1.12, 1])})`;
|
|
67
|
+
break;
|
|
68
|
+
case "zoom":
|
|
69
|
+
opacity = p;
|
|
70
|
+
transform = `scale(${interpolate(p, [0, 1], [0.9, 1])})`;
|
|
71
|
+
break;
|
|
72
|
+
case "dip":
|
|
73
|
+
// Content snaps in; a black veil over the top lifts away → dip-to-black.
|
|
74
|
+
opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const dipVeil = style === "dip" && (
|
|
79
|
+
<AbsoluteFill
|
|
80
|
+
style={{ background: "#000", opacity: interpolate(frame, [0, win], [1, 0], ease), pointerEvents: "none" }}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<AbsoluteFill style={{ opacity, transform, filter, willChange: "transform, opacity" }}>
|
|
87
|
+
{children}
|
|
88
|
+
</AbsoluteFill>
|
|
89
|
+
{dipVeil}
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate } 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
|
+
const valueOpacity = interpolate(frame - startFrame, [10, 22], [0, 1], {
|
|
26
|
+
extrapolateLeft: "clamp",
|
|
27
|
+
extrapolateRight: "clamp",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div style={{ display: "flex", alignItems: "center", gap: 28 }}>
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
width: 300,
|
|
35
|
+
textAlign: "right",
|
|
36
|
+
flexShrink: 0,
|
|
37
|
+
fontFamily: theme.fontSans,
|
|
38
|
+
fontSize: 30,
|
|
39
|
+
fontWeight: hero ? 700 : 500,
|
|
40
|
+
color: hero ? theme.text : theme.textMuted,
|
|
41
|
+
letterSpacing: "-0.01em",
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{label}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div
|
|
48
|
+
style={{
|
|
49
|
+
flex: 1,
|
|
50
|
+
height: 56,
|
|
51
|
+
background: theme.surface,
|
|
52
|
+
borderRadius: 12,
|
|
53
|
+
display: "flex",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
style={{
|
|
59
|
+
width: `${width * 100}%`,
|
|
60
|
+
height: "100%",
|
|
61
|
+
background: hero ? theme.accent : theme.border,
|
|
62
|
+
borderRadius: 12,
|
|
63
|
+
flexShrink: 0,
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
<div
|
|
67
|
+
style={{
|
|
68
|
+
opacity: valueOpacity,
|
|
69
|
+
marginLeft: 18,
|
|
70
|
+
whiteSpace: "nowrap",
|
|
71
|
+
fontFamily: theme.fontMono,
|
|
72
|
+
fontSize: 30,
|
|
73
|
+
fontWeight: 700,
|
|
74
|
+
color: hero ? theme.accent : theme.textMuted,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{valueText}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
@@ -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,7 +22,7 @@ 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",
|
|
@@ -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,6 +9,7 @@ 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
|
const sizeMap = { sm: 22, md: 32, lg: 48, xl: 72 };
|
|
@@ -20,30 +21,23 @@ export const Label: React.FC<LabelProps> = ({
|
|
|
20
21
|
size = "md",
|
|
21
22
|
align = "center",
|
|
22
23
|
muted = false,
|
|
24
|
+
emphasis,
|
|
23
25
|
}) => {
|
|
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
|
-
|
|
26
|
+
const big = size === "xl" || size === "lg";
|
|
32
27
|
return (
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</div>
|
|
28
|
+
<RevealText
|
|
29
|
+
text={text}
|
|
30
|
+
theme={theme}
|
|
31
|
+
startFrame={startFrame}
|
|
32
|
+
fontSize={sizeMap[size]}
|
|
33
|
+
fontWeight={big ? 700 : 500}
|
|
34
|
+
color={muted ? theme.textMuted : theme.text}
|
|
35
|
+
align={align}
|
|
36
|
+
emphasis={emphasis}
|
|
37
|
+
stagger={big ? 3 : 1.5}
|
|
38
|
+
letterSpacing={size === "xl" ? "-0.03em" : "-0.01em"}
|
|
39
|
+
lineHeight={big ? 1.1 : 1.25}
|
|
40
|
+
glow={big && !muted}
|
|
41
|
+
/>
|
|
48
42
|
);
|
|
49
43
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig, spring, interpolate } 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
|
+
const emphWord = emphasis?.trim().toLowerCase();
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
fontFamily: theme.fontSans,
|
|
53
|
+
fontSize,
|
|
54
|
+
fontWeight,
|
|
55
|
+
color: color ?? theme.text,
|
|
56
|
+
textAlign: align,
|
|
57
|
+
lineHeight,
|
|
58
|
+
letterSpacing,
|
|
59
|
+
maxWidth,
|
|
60
|
+
textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{words.map((word, i) => {
|
|
64
|
+
const elapsed = frame - startFrame - i * stagger;
|
|
65
|
+
const p = spring({ frame: elapsed, fps, config: theme.motion });
|
|
66
|
+
const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
|
|
67
|
+
const opacity = interpolate(elapsed, [0, 6], [0, 1], {
|
|
68
|
+
extrapolateLeft: "clamp",
|
|
69
|
+
extrapolateRight: "clamp",
|
|
70
|
+
});
|
|
71
|
+
const clean = word.replace(/[.,!?:;]/g, "").toLowerCase();
|
|
72
|
+
const isEmph = emphWord && (clean === emphWord || word.toLowerCase().includes(emphWord));
|
|
73
|
+
return (
|
|
74
|
+
<React.Fragment key={i}>
|
|
75
|
+
<span
|
|
76
|
+
style={{
|
|
77
|
+
display: "inline-block",
|
|
78
|
+
overflow: "hidden",
|
|
79
|
+
paddingBottom: "0.14em",
|
|
80
|
+
marginBottom: "-0.14em",
|
|
81
|
+
verticalAlign: "bottom",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<span
|
|
85
|
+
style={{
|
|
86
|
+
display: "inline-block",
|
|
87
|
+
transform: `translateY(${y}%)`,
|
|
88
|
+
opacity,
|
|
89
|
+
color: isEmph ? theme.accent : undefined,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{word}
|
|
93
|
+
</span>
|
|
94
|
+
</span>
|
|
95
|
+
{i < words.length - 1 ? " " : null}
|
|
96
|
+
</React.Fragment>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
@@ -25,6 +25,16 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|
|
25
25
|
const frame = useCurrentFrame();
|
|
26
26
|
const elapsed = frame - startFrame;
|
|
27
27
|
|
|
28
|
+
// Size the window to its widest physical line so a short command doesn't sit
|
|
29
|
+
// in a huge empty box. Width is fixed from the full text (not the partial
|
|
30
|
+
// reveal) so it never reflows while typing. Inputs reserve room for "$ ".
|
|
31
|
+
const maxChars = lines.reduce((max, line) => {
|
|
32
|
+
const prefix = line.isOutput ? 0 : 2;
|
|
33
|
+
const widest = line.text.split("\n").reduce((m, l) => Math.max(m, l.length), 0);
|
|
34
|
+
return Math.max(max, widest + prefix);
|
|
35
|
+
}, 0);
|
|
36
|
+
const cols = Math.min(Math.max(maxChars, 18), 78);
|
|
37
|
+
|
|
28
38
|
const visibleLines: Array<{ text: string; isOutput: boolean; partialText: string }> = [];
|
|
29
39
|
|
|
30
40
|
let cursor = 0;
|
|
@@ -59,6 +69,7 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|
|
59
69
|
fontFamily: theme.fontMono,
|
|
60
70
|
fontSize: 22,
|
|
61
71
|
boxShadow: "0 8px 48px rgba(0,0,0,0.6)",
|
|
72
|
+
width: `min(calc(${cols}ch + 48px), 100%)`,
|
|
62
73
|
}}
|
|
63
74
|
>
|
|
64
75
|
<div
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill } from "remotion";
|
|
3
|
+
import { BenchmarkScene as BenchmarkBrief } from "../../brief";
|
|
4
|
+
import { Theme } from "../../theme";
|
|
5
|
+
import { benchmarkFractions } from "../../benchmark";
|
|
6
|
+
import { Bar } from "../primitives/Bar";
|
|
7
|
+
import { Label } from "../primitives/Label";
|
|
8
|
+
import { Caption } from "../primitives/Caption";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
scene: BenchmarkBrief;
|
|
12
|
+
theme: Theme;
|
|
13
|
+
bottomInset?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HEADLINE_FRAMES = 20;
|
|
17
|
+
const FRAMES_PER_BAR = 18;
|
|
18
|
+
|
|
19
|
+
export const Benchmark: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
20
|
+
const fractions = benchmarkFractions(
|
|
21
|
+
scene.bars.map((b) => b.value),
|
|
22
|
+
scene.lowerIsBetter
|
|
23
|
+
);
|
|
24
|
+
const captionStart = HEADLINE_FRAMES + scene.bars.length * FRAMES_PER_BAR + 30;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<AbsoluteFill
|
|
28
|
+
style={{
|
|
29
|
+
background: "transparent",
|
|
30
|
+
display: "flex",
|
|
31
|
+
flexDirection: "column",
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
justifyContent: "center",
|
|
34
|
+
padding: "0 120px",
|
|
35
|
+
gap: 40,
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{scene.headline && (
|
|
39
|
+
<Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
{scene.metric && (
|
|
43
|
+
<div
|
|
44
|
+
style={{
|
|
45
|
+
fontFamily: theme.fontSans,
|
|
46
|
+
fontSize: 24,
|
|
47
|
+
color: theme.textMuted,
|
|
48
|
+
marginTop: -16,
|
|
49
|
+
letterSpacing: "0.02em",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{scene.metric}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<div
|
|
57
|
+
style={{
|
|
58
|
+
display: "flex",
|
|
59
|
+
flexDirection: "column",
|
|
60
|
+
gap: 24,
|
|
61
|
+
width: "100%",
|
|
62
|
+
maxWidth: 1200,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{scene.bars.map((bar, i) => (
|
|
66
|
+
<Bar
|
|
67
|
+
key={i}
|
|
68
|
+
label={bar.label}
|
|
69
|
+
valueText={bar.display ?? String(bar.value)}
|
|
70
|
+
fraction={fractions[i]}
|
|
71
|
+
hero={bar.hero}
|
|
72
|
+
theme={theme}
|
|
73
|
+
startFrame={HEADLINE_FRAMES + i * FRAMES_PER_BAR}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{scene.caption && (
|
|
79
|
+
<Caption text={scene.caption} theme={theme} startFrame={captionStart} bottomInset={bottomInset} />
|
|
80
|
+
)}
|
|
81
|
+
</AbsoluteFill>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -7,9 +7,10 @@ import { Caption } from "../primitives/Caption";
|
|
|
7
7
|
interface Props {
|
|
8
8
|
scene: BrowserBrief;
|
|
9
9
|
theme: Theme;
|
|
10
|
+
bottomInset?: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
|
|
13
|
+
export const BrowserFrame: React.FC<Props> = ({ scene, theme, bottomInset = 0 }) => {
|
|
13
14
|
const frame = useCurrentFrame();
|
|
14
15
|
const { fps } = useVideoConfig();
|
|
15
16
|
|
|
@@ -26,7 +27,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
|
|
|
26
27
|
return (
|
|
27
28
|
<AbsoluteFill
|
|
28
29
|
style={{
|
|
29
|
-
background:
|
|
30
|
+
background: "transparent",
|
|
30
31
|
display: "flex",
|
|
31
32
|
alignItems: "center",
|
|
32
33
|
justifyContent: "center",
|
|
@@ -133,7 +134,7 @@ export const BrowserFrame: React.FC<Props> = ({ scene, theme }) => {
|
|
|
133
134
|
</div>
|
|
134
135
|
</div>
|
|
135
136
|
|
|
136
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
|
|
137
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} bottomInset={bottomInset} />}
|
|
137
138
|
</AbsoluteFill>
|
|
138
139
|
);
|
|
139
140
|
};
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
|
|
3
|
+
import chroma from "chroma-js";
|
|
3
4
|
import { CTAScene as CTABrief, ProjectMeta } from "../../brief";
|
|
4
5
|
import { Theme } from "../../theme";
|
|
6
|
+
import { PlatformPreset, typeScale } from "../../platforms";
|
|
5
7
|
import { Caption } from "../primitives/Caption";
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
scene: CTABrief;
|
|
9
11
|
theme: Theme;
|
|
10
12
|
project: ProjectMeta;
|
|
13
|
+
platform?: PlatformPreset;
|
|
14
|
+
bottomInset?: number;
|
|
15
|
+
/** gif output: use a flat fill instead of gradients that bloat gif size. */
|
|
16
|
+
lite?: boolean;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
const SNAP_OFFSET = 8;
|
|
14
20
|
|
|
15
|
-
export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
|
|
21
|
+
export const CTA: React.FC<Props> = ({ scene, theme, project, platform, bottomInset = 0, lite = false }) => {
|
|
16
22
|
const frame = useCurrentFrame();
|
|
17
23
|
const { fps } = useVideoConfig();
|
|
24
|
+
const scale = platform ? typeScale(platform) : 1.0;
|
|
18
25
|
|
|
19
26
|
const logoProgress = spring({ frame: frame + SNAP_OFFSET, fps, config: theme.motion });
|
|
20
27
|
const titleProgress = spring({ frame: frame + SNAP_OFFSET - (project.logo ? 16 : 0), fps, config: theme.motion });
|
|
@@ -32,10 +39,19 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
|
|
|
32
39
|
? `${project.name} ${project.version}`
|
|
33
40
|
: project.name;
|
|
34
41
|
|
|
42
|
+
// Contrast-aware text on the brand field: white on saturated/dark accents,
|
|
43
|
+
// near-black on light ones — confident lockup instead of dull inverse grey.
|
|
44
|
+
const onAccent = chroma(theme.accent).luminance() > 0.5 ? "#15151c" : "#ffffff";
|
|
45
|
+
|
|
35
46
|
return (
|
|
36
47
|
<AbsoluteFill
|
|
37
48
|
style={{
|
|
38
|
-
|
|
49
|
+
// Graded brand end-card: a lit radial instead of a flat fill, so the
|
|
50
|
+
// CTA reads as produced as the rest of the film, not a plain slate. The
|
|
51
|
+
// gif path uses a flat fill — a full-frame gradient explodes gif size.
|
|
52
|
+
background: lite
|
|
53
|
+
? theme.accent
|
|
54
|
+
: `radial-gradient(125% 95% at 50% 32%, color-mix(in srgb, ${theme.accent}, #fff 12%) 0%, ${theme.accent} 46%, color-mix(in srgb, ${theme.accent}, #000 24%) 100%)`,
|
|
39
55
|
display: "flex",
|
|
40
56
|
flexDirection: "column",
|
|
41
57
|
alignItems: "center",
|
|
@@ -44,6 +60,17 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
|
|
|
44
60
|
padding: "0 120px",
|
|
45
61
|
}}
|
|
46
62
|
>
|
|
63
|
+
{/* Edge vignette spotlights the lockup so the field doesn't read as empty.
|
|
64
|
+
Skipped on the gif path to keep the frame flat and compressible. */}
|
|
65
|
+
{!lite && (
|
|
66
|
+
<AbsoluteFill
|
|
67
|
+
style={{
|
|
68
|
+
background: "radial-gradient(70% 70% at 50% 50%, transparent 40%, rgba(0,0,0,0.28) 100%)",
|
|
69
|
+
pointerEvents: "none",
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
|
|
47
74
|
{project.logo && (
|
|
48
75
|
<div
|
|
49
76
|
style={{
|
|
@@ -67,15 +94,16 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
|
|
|
67
94
|
opacity: titleOpacity,
|
|
68
95
|
transform: `translateY(${titleY}px)`,
|
|
69
96
|
fontFamily: theme.fontSans,
|
|
70
|
-
fontSize:
|
|
97
|
+
fontSize: 60 * scale,
|
|
71
98
|
fontWeight: 700,
|
|
72
|
-
color:
|
|
73
|
-
letterSpacing: "-0.
|
|
99
|
+
color: onAccent,
|
|
100
|
+
letterSpacing: "-0.03em",
|
|
74
101
|
textAlign: "center",
|
|
102
|
+
textShadow: "0 4px 30px rgba(0,0,0,0.25)",
|
|
75
103
|
}}
|
|
76
104
|
>
|
|
77
105
|
{isAnnouncement ? "" : "Get started with "}
|
|
78
|
-
<span style={{ color:
|
|
106
|
+
<span style={{ color: onAccent, fontWeight: 800 }}>{displayName}</span>
|
|
79
107
|
{isAnnouncement ? " is here." : ""}
|
|
80
108
|
</div>
|
|
81
109
|
|
|
@@ -85,14 +113,15 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
|
|
|
85
113
|
transform: `scale(${cmdScale})`,
|
|
86
114
|
background: theme.bg,
|
|
87
115
|
border: `1.5px solid rgba(0,0,0,0.12)`,
|
|
88
|
-
borderRadius:
|
|
89
|
-
padding: "
|
|
116
|
+
borderRadius: 14,
|
|
117
|
+
padding: "20px 44px",
|
|
90
118
|
fontFamily: theme.fontMono,
|
|
91
119
|
fontSize: 28,
|
|
92
120
|
color: theme.text,
|
|
93
121
|
display: "flex",
|
|
94
122
|
alignItems: "center",
|
|
95
123
|
gap: 16,
|
|
124
|
+
boxShadow: "0 22px 60px rgba(0,0,0,0.30)",
|
|
96
125
|
}}
|
|
97
126
|
>
|
|
98
127
|
<span style={{ color: theme.accent }}>$</span>
|
|
@@ -105,14 +134,31 @@ export const CTA: React.FC<Props> = ({ scene, theme, project }) => {
|
|
|
105
134
|
opacity: urlFade,
|
|
106
135
|
fontFamily: theme.fontMono,
|
|
107
136
|
fontSize: 20,
|
|
108
|
-
color:
|
|
137
|
+
color: onAccent,
|
|
109
138
|
}}
|
|
110
139
|
>
|
|
111
140
|
{scene.repoUrl}
|
|
112
141
|
</div>
|
|
113
142
|
)}
|
|
114
143
|
|
|
115
|
-
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} />}
|
|
144
|
+
{scene.caption && <Caption text={scene.caption} theme={theme} startFrame={60} bottomInset={bottomInset} />}
|
|
145
|
+
|
|
146
|
+
{project.watermark !== false && (
|
|
147
|
+
<div
|
|
148
|
+
style={{
|
|
149
|
+
position: "absolute",
|
|
150
|
+
bottom: 40 + bottomInset,
|
|
151
|
+
left: 0,
|
|
152
|
+
right: 0,
|
|
153
|
+
textAlign: "center",
|
|
154
|
+
fontFamily: theme.fontMono,
|
|
155
|
+
fontSize: 18,
|
|
156
|
+
color: theme.textMuted,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
made with reelme
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
116
162
|
</AbsoluteFill>
|
|
117
163
|
);
|
|
118
164
|
};
|