reelme 0.3.0 → 0.5.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 +18 -11
- package/assets/audio/generate-sfx.mjs +128 -0
- package/assets/audio/light-steps.mp3 +0 -0
- package/assets/audio/lofi-dusk.mp3 +0 -0
- package/assets/audio/manifest.json +56 -63
- package/assets/audio/neon-pulse.mp3 +0 -0
- package/assets/audio/night-drive.mp3 +0 -0
- package/assets/audio/sfx/SOURCES.md +16 -0
- package/assets/audio/sfx/pop.mp3 +0 -0
- package/assets/audio/sfx/rise.mp3 +0 -0
- package/assets/audio/sfx/whoosh.mp3 +0 -0
- package/assets/audio/sunny-bounce.mp3 +0 -0
- package/assets/audio/warm-memories.mp3 +0 -0
- package/package.json +8 -2
- package/src/cache.mjs +246 -16
- package/src/cli.mjs +20 -11
- package/src/render.mjs +98 -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 +101 -21
- package/template/src/brief.json +6 -6
- package/template/src/brief.ts +39 -6
- package/template/src/cinematic/Atmosphere.tsx +176 -30
- package/template/src/cinematic/Camera.tsx +22 -10
- package/template/src/cinematic/look.ts +51 -10
- package/template/src/cinematic/transitions.tsx +29 -9
- package/template/src/components/primitives/Arrow.tsx +8 -5
- package/template/src/components/primitives/Bar.tsx +6 -2
- package/template/src/components/primitives/Caption.tsx +2 -2
- package/template/src/components/primitives/CodeBlock.tsx +21 -7
- 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 +35 -7
- package/template/src/components/primitives/Stage.tsx +62 -0
- package/template/src/components/primitives/Terminal.tsx +24 -7
- package/template/src/components/scenes/BrowserFrame.tsx +13 -6
- package/template/src/components/scenes/CTA.tsx +47 -82
- package/template/src/components/scenes/Clip.tsx +31 -12
- package/template/src/components/scenes/CodeReveal.tsx +12 -13
- package/template/src/components/scenes/DataFlow.tsx +47 -19
- package/template/src/components/scenes/FeatureList.tsx +74 -77
- package/template/src/components/scenes/FileTree.tsx +23 -7
- package/template/src/components/scenes/Hook.tsx +4 -3
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +225 -118
- package/template/src/components/scenes/OSWindowScene.tsx +5 -5
- package/template/src/components/scenes/Problem.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +41 -14
- package/template/src/components/scenes/StatCallout.tsx +123 -68
- package/template/src/components/scenes/TerminalScene.tsx +12 -11
- package/template/src/custom-scenes.ts +19 -0
- package/template/src/duration.ts +54 -5
- package/template/src/index.ts +16 -10
- package/template/src/theme.ts +10 -1
- package/template/src/timing.ts +49 -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 +0 -213
- 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
|
@@ -5,14 +5,31 @@
|
|
|
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
|
|
|
14
16
|
export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The backdrop SYSTEM a look is staged on. This is the look's silhouette: two
|
|
20
|
+
* looks with different backdrops read as different films even before grading.
|
|
21
|
+
* The old single recipe (dark field + drifting radial orbs) was the template's
|
|
22
|
+
* fingerprint — every reel shared it, and an orb in empty space reads as a
|
|
23
|
+
* smudge, not a light. Each system covers the frame with intent instead:
|
|
24
|
+
*
|
|
25
|
+
* - mesh: full-bleed multi-hue gradient field (accent + accent2), slow drift
|
|
26
|
+
* - spotlight: one elliptical light pool aimed where the content sits
|
|
27
|
+
* - sweep: saturated full-frame diagonal gradient between the brand hues
|
|
28
|
+
* - grid: structural engineering grid — the graphic IS the backdrop
|
|
29
|
+
* - field: rich near-solid editorial color field with a soft top wash
|
|
30
|
+
*/
|
|
31
|
+
export type BackdropSystem = "mesh" | "spotlight" | "sweep" | "grid" | "field";
|
|
32
|
+
|
|
16
33
|
export interface Grade {
|
|
17
34
|
/** Tint color layered over the whole frame. */
|
|
18
35
|
color: string;
|
|
@@ -22,7 +39,9 @@ export interface Grade {
|
|
|
22
39
|
|
|
23
40
|
export interface CinematicLook {
|
|
24
41
|
id: LookId;
|
|
25
|
-
/**
|
|
42
|
+
/** Which backdrop system stages the whole reel. */
|
|
43
|
+
backdrop: BackdropSystem;
|
|
44
|
+
/** Backdrop light/color intensity (0..1). */
|
|
26
45
|
glow: number;
|
|
27
46
|
/** Whether a second, cooler light source is added for depth. */
|
|
28
47
|
twoTone: boolean;
|
|
@@ -31,47 +50,69 @@ export interface CinematicLook {
|
|
|
31
50
|
/** Film-grain opacity on the final frame (0..1); skipped for gif output. */
|
|
32
51
|
grain: number;
|
|
33
52
|
overlay: AtmosphereOverlay;
|
|
34
|
-
/**
|
|
53
|
+
/** Arrival move for every scene's content: a zoom that eases to a rest. */
|
|
35
54
|
camera: CameraMove;
|
|
36
55
|
/** Camera amplitude multiplier (1 = default). */
|
|
37
56
|
cameraIntensity: number;
|
|
38
57
|
grade: Grade;
|
|
39
|
-
/** Default cut style; the rhythm picker varies around it. */
|
|
40
|
-
transition: TransitionStyle;
|
|
41
58
|
/** Editing energy: higher tightens enter timings and favors hard cuts. */
|
|
42
59
|
energy: number;
|
|
60
|
+
/**
|
|
61
|
+
* Spring physics for in-scene element animation. Motion is part of the
|
|
62
|
+
* production design: a look should *move* differently, not just look
|
|
63
|
+
* different. The Reel feeds this into the theme so every scene's springs
|
|
64
|
+
* inherit the look's personality (smooth, snappy, weighty, ...).
|
|
65
|
+
*/
|
|
66
|
+
motion: MotionProfile;
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
|
|
46
70
|
// Clean keynote stage — soft key light, gentle push, long dissolves.
|
|
71
|
+
// Motion: settled, no bounce — elements arrive and hold.
|
|
47
72
|
keynote: {
|
|
73
|
+
backdrop: "mesh",
|
|
48
74
|
glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
|
|
49
75
|
camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
|
|
50
|
-
|
|
76
|
+
energy: 0.4,
|
|
77
|
+
motion: { damping: 22, stiffness: 100, mass: 1.0 },
|
|
51
78
|
},
|
|
52
79
|
// Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
|
|
80
|
+
// Motion: heavy and deliberate — weighty mass, slow to settle.
|
|
53
81
|
noir: {
|
|
82
|
+
backdrop: "spotlight",
|
|
54
83
|
glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
|
|
55
84
|
camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
|
|
56
|
-
|
|
85
|
+
energy: 0.35,
|
|
86
|
+
motion: { damping: 28, stiffness: 78, mass: 1.4 },
|
|
57
87
|
},
|
|
58
88
|
// Saturated arcade — dual lights, scanlines, snappy whips and zooms.
|
|
89
|
+
// Motion: snappy but settled — light mass, high stiffness, near-critical
|
|
90
|
+
// damping. Energy comes from speed, not from overshoot: a bouncing element
|
|
91
|
+
// reads as a toy, not a cut (gallery feedback).
|
|
59
92
|
arcade: {
|
|
93
|
+
backdrop: "sweep",
|
|
60
94
|
glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
|
|
61
95
|
camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
|
|
62
|
-
|
|
96
|
+
energy: 0.85,
|
|
97
|
+
motion: { damping: 20, stiffness: 155, mass: 0.7 },
|
|
63
98
|
},
|
|
64
99
|
// Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
|
|
100
|
+
// Motion: stiff and precise — quick, minimal bounce, machined.
|
|
65
101
|
blueprint: {
|
|
102
|
+
backdrop: "grid",
|
|
66
103
|
glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
|
|
67
104
|
camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
|
|
68
|
-
|
|
105
|
+
energy: 0.6,
|
|
106
|
+
motion: { damping: 30, stiffness: 150, mass: 1.1 },
|
|
69
107
|
},
|
|
70
108
|
// Premium brand film — warm grade, big soft vignette, very slow elegant push.
|
|
109
|
+
// Motion: the smoothest — elegant glide, no perceptible bounce.
|
|
71
110
|
editorial: {
|
|
111
|
+
backdrop: "field",
|
|
72
112
|
glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
|
|
73
113
|
camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
|
|
74
|
-
|
|
114
|
+
energy: 0.3,
|
|
115
|
+
motion: { damping: 26, stiffness: 88, mass: 1.15 },
|
|
75
116
|
},
|
|
76
117
|
};
|
|
77
118
|
|
|
@@ -8,9 +8,11 @@ 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// No flip: a hinged swing reads as a gimmick next to the rest of the edit
|
|
12
|
+
// (gallery feedback) — arcade's energy now comes from fast zooms and whips.
|
|
13
|
+
arcade: ["zoom", "whip", "zoom", "fade"],
|
|
14
|
+
blueprint: ["cut", "wipe", "cut", "whip"],
|
|
15
|
+
editorial: ["fade", "wipe", "rise", "fade"],
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
export function transitionFor(look: CinematicLook, index: number, total: number): TransitionStyle {
|
|
@@ -43,8 +45,11 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
43
45
|
const p = interpolate(frame, [0, win], [0, 1], { ...ease, easing: Easing.out(Easing.cubic) });
|
|
44
46
|
|
|
45
47
|
let opacity = 1;
|
|
46
|
-
let
|
|
48
|
+
let scale: string | undefined;
|
|
49
|
+
let translate: string | undefined;
|
|
47
50
|
let filter: string | undefined;
|
|
51
|
+
let transform: string | undefined;
|
|
52
|
+
let clipPath: string | undefined;
|
|
48
53
|
|
|
49
54
|
switch (style) {
|
|
50
55
|
case "cut":
|
|
@@ -54,25 +59,38 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
54
59
|
break;
|
|
55
60
|
case "rise":
|
|
56
61
|
opacity = p;
|
|
57
|
-
|
|
62
|
+
translate = `0 ${interpolate(p, [0, 1], [44, 0])}px`;
|
|
58
63
|
break;
|
|
59
64
|
case "whip":
|
|
60
65
|
opacity = interpolate(frame, [0, win * 0.7], [0, 1], ease);
|
|
61
|
-
|
|
66
|
+
translate = `${interpolate(p, [0, 1], [90 * dir, 0])}px 0`;
|
|
62
67
|
filter = `blur(${interpolate(p, [0, 1], [14, 0])}px)`;
|
|
63
68
|
break;
|
|
64
69
|
case "punch":
|
|
65
70
|
opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
|
|
66
|
-
|
|
71
|
+
scale = String(interpolate(p, [0, 1], [1.07, 1]));
|
|
67
72
|
break;
|
|
68
73
|
case "zoom":
|
|
69
74
|
opacity = p;
|
|
70
|
-
|
|
75
|
+
scale = String(interpolate(p, [0, 1], [0.9, 1]));
|
|
71
76
|
break;
|
|
72
77
|
case "dip":
|
|
73
78
|
// Content snaps in; a black veil over the top lifts away → dip-to-black.
|
|
74
79
|
opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
|
|
75
80
|
break;
|
|
81
|
+
case "wipe": {
|
|
82
|
+
// A hard edge sweeps across, revealing the shot. Direction alternates by
|
|
83
|
+
// seed so consecutive wipes don't all travel the same way.
|
|
84
|
+
opacity = interpolate(frame, [0, win * 0.3], [0, 1], ease);
|
|
85
|
+
const remain = interpolate(p, [0, 1], [100, 0]);
|
|
86
|
+
clipPath = dir > 0 ? `inset(0 ${remain}% 0 0)` : `inset(0 0 0 ${remain}%)`;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "flip":
|
|
90
|
+
// Shot swings in on a vertical hinge — a single playful rotation.
|
|
91
|
+
opacity = interpolate(frame, [0, win * 0.5], [0, 1], ease);
|
|
92
|
+
transform = `perspective(1400px) rotateY(${interpolate(p, [0, 1], [62 * dir, 0])}deg)`;
|
|
93
|
+
break;
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
const dipVeil = style === "dip" && (
|
|
@@ -83,7 +101,9 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
83
101
|
|
|
84
102
|
return (
|
|
85
103
|
<>
|
|
86
|
-
<AbsoluteFill
|
|
104
|
+
<AbsoluteFill
|
|
105
|
+
style={{ opacity, scale, translate, filter, transform, clipPath, willChange: "scale, translate, opacity, transform, clip-path" }}
|
|
106
|
+
>
|
|
87
107
|
{children}
|
|
88
108
|
</AbsoluteFill>
|
|
89
109
|
{dipVeil}
|
|
@@ -10,11 +10,14 @@ interface ArrowProps {
|
|
|
10
10
|
label?: string;
|
|
11
11
|
theme: Theme;
|
|
12
12
|
startFrame?: number;
|
|
13
|
+
/** Line weight; supporting graphics need real presence at video size. */
|
|
14
|
+
strokeWidth?: number;
|
|
15
|
+
labelSize?: number;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
const ARROW_HEAD_SIZE =
|
|
18
|
+
const ARROW_HEAD_SIZE = 14;
|
|
16
19
|
|
|
17
|
-
export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0 }) => {
|
|
20
|
+
export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0, strokeWidth = 3.5, labelSize = 22 }) => {
|
|
18
21
|
const frame = useCurrentFrame();
|
|
19
22
|
const { fps } = useVideoConfig();
|
|
20
23
|
const elapsed = frame - startFrame;
|
|
@@ -50,7 +53,7 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
|
|
|
50
53
|
x2={x2short}
|
|
51
54
|
y2={y2short}
|
|
52
55
|
stroke={theme.accent}
|
|
53
|
-
strokeWidth={
|
|
56
|
+
strokeWidth={strokeWidth}
|
|
54
57
|
strokeDasharray={len}
|
|
55
58
|
strokeDashoffset={dashOffset}
|
|
56
59
|
strokeLinecap="round"
|
|
@@ -68,9 +71,9 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
|
|
|
68
71
|
{label && progress > 0.5 && (
|
|
69
72
|
<text
|
|
70
73
|
x={midX}
|
|
71
|
-
y={midY -
|
|
74
|
+
y={midY - 14}
|
|
72
75
|
fill={theme.textMuted}
|
|
73
|
-
fontSize={
|
|
76
|
+
fontSize={labelSize}
|
|
74
77
|
textAnchor="middle"
|
|
75
78
|
fontFamily={theme.fontSans}
|
|
76
79
|
opacity={interpolate(progress, [0.5, 1], [0, 1])}
|
|
@@ -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",
|
|
@@ -61,19 +61,30 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
61
61
|
framesPerLine = 9,
|
|
62
62
|
}) => {
|
|
63
63
|
const frame = useCurrentFrame();
|
|
64
|
-
const { fps } = useVideoConfig();
|
|
64
|
+
const { fps, width: frameWidth } = useVideoConfig();
|
|
65
65
|
const elapsed = frame - startFrame;
|
|
66
66
|
const lines = code.split("\n");
|
|
67
|
+
// Scale type with the frame so the editor window commands wide canvases
|
|
68
|
+
// (ch-based width sizing inherits this automatically).
|
|
69
|
+
const fontSize = Math.max(22, Math.round(frameWidth * 0.0135));
|
|
67
70
|
const visibleCount = Math.max(0, Math.min(lines.length, Math.floor(elapsed / framesPerLine) + 1));
|
|
68
71
|
|
|
72
|
+
// Size the window to the longest line of the FULL snippet (not the partial
|
|
73
|
+
// reveal) so it arrives at its final width and never stretches to fill the
|
|
74
|
+
// frame — an editor window several times wider than its code reads as empty
|
|
75
|
+
// chrome (gallery feedback). 83px covers gutter, line numbers, and padding.
|
|
76
|
+
const cols = Math.min(Math.max(lines.reduce((m, l) => Math.max(m, l.length), 0), 24), 88);
|
|
77
|
+
|
|
69
78
|
return (
|
|
70
|
-
<div style={{ fontFamily: theme.fontMono, fontSize
|
|
79
|
+
<div style={{ fontFamily: theme.fontMono, fontSize, lineHeight: 1.7, width: `calc(${cols}ch + 83px)`, maxWidth: "100%" }}>
|
|
71
80
|
<div
|
|
72
81
|
style={{
|
|
73
82
|
background: "#0d1117",
|
|
74
|
-
borderRadius:
|
|
83
|
+
borderRadius: 12,
|
|
75
84
|
overflow: "hidden",
|
|
76
|
-
|
|
85
|
+
// Contact shadow + brand-light halo so the window sits in the stage.
|
|
86
|
+
boxShadow: `0 30px 90px -18px ${theme.accent}59, 0 24px 60px rgba(0,0,0,0.55)`,
|
|
87
|
+
outline: "1px solid rgba(255,255,255,0.09)",
|
|
77
88
|
}}
|
|
78
89
|
>
|
|
79
90
|
{/* Title bar */}
|
|
@@ -95,9 +106,11 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
95
106
|
</span>
|
|
96
107
|
</div>
|
|
97
108
|
|
|
98
|
-
{/* Code lines
|
|
109
|
+
{/* Code lines. All rows are laid out from frame 0 (unrevealed ones
|
|
110
|
+
invisible) so the window opens at its final height — the reveal
|
|
111
|
+
happens inside stable chrome instead of inflating it. */}
|
|
99
112
|
<div style={{ padding: "20px 0" }}>
|
|
100
|
-
{lines.
|
|
113
|
+
{lines.map((line, i) => {
|
|
101
114
|
const lineNum = i + 1;
|
|
102
115
|
const isHighlight = highlightLine !== undefined && lineNum === highlightLine;
|
|
103
116
|
const highlightOpacity =
|
|
@@ -115,6 +128,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
115
128
|
style={{
|
|
116
129
|
display: "flex",
|
|
117
130
|
padding: "0 24px",
|
|
131
|
+
visibility: i < visibleCount ? "visible" : "hidden",
|
|
118
132
|
background: isHighlight
|
|
119
133
|
? `rgba(${hexToRgb(theme.accent)}, ${0.15 * highlightOpacity})`
|
|
120
134
|
: "transparent",
|
|
@@ -128,7 +142,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
128
142
|
color: "#484f58",
|
|
129
143
|
minWidth: 32,
|
|
130
144
|
userSelect: "none",
|
|
131
|
-
fontSize:
|
|
145
|
+
fontSize: Math.round(fontSize * 0.8),
|
|
132
146
|
paddingTop: 1,
|
|
133
147
|
}}
|
|
134
148
|
>
|
|
@@ -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
|
|
@@ -57,7 +79,6 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
57
79
|
lineHeight,
|
|
58
80
|
letterSpacing,
|
|
59
81
|
maxWidth,
|
|
60
|
-
textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
|
|
61
82
|
}}
|
|
62
83
|
>
|
|
63
84
|
{words.map((word, i) => {
|
|
@@ -65,17 +86,23 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
65
86
|
const p = spring({ frame: elapsed, fps, config: theme.motion });
|
|
66
87
|
const y = interpolate(p, [0, 1], [112, 0], { extrapolateRight: "clamp" });
|
|
67
88
|
const opacity = interpolate(elapsed, [0, 6], [0, 1], {
|
|
89
|
+
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
|
68
90
|
extrapolateLeft: "clamp",
|
|
69
91
|
extrapolateRight: "clamp",
|
|
70
92
|
});
|
|
71
|
-
const
|
|
72
|
-
|
|
93
|
+
const isEmph = emphIndices.has(i);
|
|
94
|
+
// The clip mask exists only for the rise. Once the word is at rest the
|
|
95
|
+
// mask is released and the glow fades in — a soft 40px shadow inside an
|
|
96
|
+
// overflow-hidden span gets sheared into visible rectangles, so the two
|
|
97
|
+
// must never coexist (gallery feedback: "shadow looks cut off").
|
|
98
|
+
const settled = y < 1;
|
|
99
|
+
const shadowAlpha = glow && settled ? interpolate(p, [0.991, 1], [0, 0.45], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) : 0;
|
|
73
100
|
return (
|
|
74
101
|
<React.Fragment key={i}>
|
|
75
102
|
<span
|
|
76
103
|
style={{
|
|
77
104
|
display: "inline-block",
|
|
78
|
-
overflow: "hidden",
|
|
105
|
+
overflow: settled ? "visible" : "hidden",
|
|
79
106
|
paddingBottom: "0.14em",
|
|
80
107
|
marginBottom: "-0.14em",
|
|
81
108
|
verticalAlign: "bottom",
|
|
@@ -84,9 +111,10 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
84
111
|
<span
|
|
85
112
|
style={{
|
|
86
113
|
display: "inline-block",
|
|
87
|
-
|
|
114
|
+
translate: `0 ${y}%`,
|
|
88
115
|
opacity,
|
|
89
116
|
color: isEmph ? theme.accent : undefined,
|
|
117
|
+
textShadow: shadowAlpha > 0 ? `0 6px 40px rgba(0,0,0,${shadowAlpha.toFixed(3)})` : undefined,
|
|
90
118
|
}}
|
|
91
119
|
>
|
|
92
120
|
{word}
|
|
@@ -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
|
+
};
|