@openvidstudio/core 0.1.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/package.json +51 -0
- package/public/sfx/bell.wav +0 -0
- package/public/sfx/blip.wav +0 -0
- package/public/sfx/click.wav +0 -0
- package/public/sfx/key_a.wav +0 -0
- package/public/sfx/key_b.wav +0 -0
- package/public/sfx/key_c.wav +0 -0
- package/public/sfx/key_enter.wav +0 -0
- package/public/sfx/music-bed.mp3 +0 -0
- package/public/sfx/success.wav +0 -0
- package/public/sfx/whoosh.wav +0 -0
- package/scripts/gen-sfx.sh +74 -0
- package/src/ChecklistPanel.tsx +97 -0
- package/src/CinematicScene.tsx +53 -0
- package/src/CodePanel.tsx +122 -0
- package/src/CursorActor.tsx +90 -0
- package/src/DeviceFrame.tsx +74 -0
- package/src/KineticText.tsx +142 -0
- package/src/RepoCta.tsx +128 -0
- package/src/TerminalReplay.tsx +263 -0
- package/src/camera.tsx +141 -0
- package/src/index.ts +60 -0
- package/src/look.tsx +90 -0
- package/src/motion.ts +50 -0
- package/src/sfx.tsx +106 -0
- package/src/tokens.ts +41 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Kinetic typography: word-staggered title slams + caption bars.
|
|
2
|
+
// Mask/clip reveals only, plain opacity fades are banned (STYLE.md).
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
6
|
+
import { pop, SPRING, staggerDelay, tween, E } from "./motion";
|
|
7
|
+
import { Blip } from "./sfx";
|
|
8
|
+
import { color } from "./tokens";
|
|
9
|
+
|
|
10
|
+
// System-safe fallback stack, not the @remotion/google-fonts network loader: that loader fetches
|
|
11
|
+
// from fonts.gstatic.com at render time, which hangs the whole render (30s timeout, then a fatal
|
|
12
|
+
// crash-loop) with no internet. Same visual family, no network dependency.
|
|
13
|
+
const uiFamily = "Inter, -apple-system, Segoe UI, Roboto, sans-serif";
|
|
14
|
+
|
|
15
|
+
/** Big title: each word slams in with a spring + clip wipe. */
|
|
16
|
+
export const TitleSlam: React.FC<{
|
|
17
|
+
text: string;
|
|
18
|
+
at: number;
|
|
19
|
+
fontSize?: number;
|
|
20
|
+
color?: string;
|
|
21
|
+
glowColor?: string;
|
|
22
|
+
align?: "center" | "left";
|
|
23
|
+
sfx?: boolean;
|
|
24
|
+
}> = ({
|
|
25
|
+
text,
|
|
26
|
+
at,
|
|
27
|
+
fontSize = 120,
|
|
28
|
+
color: textColor = color.textPrimary,
|
|
29
|
+
glowColor,
|
|
30
|
+
align = "center",
|
|
31
|
+
sfx = true,
|
|
32
|
+
}) => {
|
|
33
|
+
const frame = useCurrentFrame();
|
|
34
|
+
const { fps } = useVideoConfig();
|
|
35
|
+
const words = text.split(" ");
|
|
36
|
+
if (frame < at) return null;
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
style={{
|
|
40
|
+
display: "flex",
|
|
41
|
+
gap: fontSize * 0.28,
|
|
42
|
+
justifyContent: align === "center" ? "center" : "flex-start",
|
|
43
|
+
flexWrap: "wrap",
|
|
44
|
+
fontFamily: uiFamily,
|
|
45
|
+
fontWeight: 900,
|
|
46
|
+
fontSize,
|
|
47
|
+
letterSpacing: "-0.03em",
|
|
48
|
+
color: textColor,
|
|
49
|
+
textShadow: glowColor
|
|
50
|
+
? `0 0 24px ${glowColor}66, 0 0 80px ${glowColor}33`
|
|
51
|
+
: undefined,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{words.map((w, i) => {
|
|
55
|
+
const d = at + staggerDelay(i, 4);
|
|
56
|
+
const s = pop(frame, fps, d, SPRING.pop);
|
|
57
|
+
const wipe = tween(frame, [d, d + 6], [100, 0], E.snap);
|
|
58
|
+
return (
|
|
59
|
+
<span
|
|
60
|
+
key={i}
|
|
61
|
+
style={{
|
|
62
|
+
display: "inline-block",
|
|
63
|
+
transform: `scale(${0.7 + s * 0.3}) translateY(${(1 - s) * 40}px)`,
|
|
64
|
+
clipPath: `inset(0 ${wipe}% 0 0)`,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{w}
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
{sfx ? words.map((_, i) => <Blip key={i} at={at + staggerDelay(i, 4)} volume={0.18} />) : null}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Bottom caption bar: single line, word-stagger clip reveal, auto-out. */
|
|
77
|
+
export const Caption: React.FC<{
|
|
78
|
+
text: string;
|
|
79
|
+
at: number;
|
|
80
|
+
out?: number;
|
|
81
|
+
fontSize?: number;
|
|
82
|
+
}> = ({ text, at, out, fontSize = 34 }) => {
|
|
83
|
+
const frame = useCurrentFrame();
|
|
84
|
+
const words = text.split(" ");
|
|
85
|
+
if (frame < at || (out !== undefined && frame > out + 10)) return null;
|
|
86
|
+
const exit =
|
|
87
|
+
out !== undefined ? tween(frame, [out, out + 10], [0, 1], E.snap) : 0;
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
style={{
|
|
91
|
+
position: "absolute",
|
|
92
|
+
bottom: 84,
|
|
93
|
+
left: 0,
|
|
94
|
+
right: 0,
|
|
95
|
+
display: "flex",
|
|
96
|
+
justifyContent: "center",
|
|
97
|
+
transform: `translateY(${exit * 24}px)`,
|
|
98
|
+
opacity: 1 - exit,
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div
|
|
102
|
+
style={{
|
|
103
|
+
fontFamily: uiFamily,
|
|
104
|
+
fontWeight: 500,
|
|
105
|
+
fontSize,
|
|
106
|
+
color: color.textPrimary,
|
|
107
|
+
// glassmorphism caption bar, opaque enough to read over white browser-chrome
|
|
108
|
+
// recordings (was 0.45, washed out against BrowserFrame's white body)
|
|
109
|
+
background: "rgba(12,15,22,0.82)",
|
|
110
|
+
backdropFilter: "blur(18px) saturate(1.3)",
|
|
111
|
+
WebkitBackdropFilter: "blur(18px) saturate(1.3)",
|
|
112
|
+
border: "1px solid rgba(255,255,255,0.12)",
|
|
113
|
+
boxShadow: "0 8px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08)",
|
|
114
|
+
borderRadius: 14,
|
|
115
|
+
padding: `${fontSize * 0.45}px ${fontSize * 0.9}px`,
|
|
116
|
+
display: "flex",
|
|
117
|
+
gap: fontSize * 0.26,
|
|
118
|
+
flexWrap: "wrap",
|
|
119
|
+
justifyContent: "center",
|
|
120
|
+
maxWidth: 1400,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{words.map((w, i) => {
|
|
124
|
+
const d = at + staggerDelay(i, 2);
|
|
125
|
+
const wipe = tween(frame, [d, d + 5], [100, 0], E.snap);
|
|
126
|
+
return (
|
|
127
|
+
<span
|
|
128
|
+
key={i}
|
|
129
|
+
style={{
|
|
130
|
+
clipPath: `inset(0 ${wipe}% 0 0)`,
|
|
131
|
+
display: "inline-block",
|
|
132
|
+
textShadow: "0 1px 3px rgba(0,0,0,0.6)",
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{w}
|
|
136
|
+
</span>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
package/src/RepoCta.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Shared CTA scene: glass repo card (informational, no star action, reads as
|
|
2
|
+
// self-promo otherwise) → dip to a modest title card.
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
6
|
+
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
|
|
7
|
+
import { CinematicScene } from "./CinematicScene";
|
|
8
|
+
import { TitleSlam, Caption } from "./KineticText";
|
|
9
|
+
import { Layer } from "./camera";
|
|
10
|
+
import { E, pop, tween } from "./motion";
|
|
11
|
+
import { Whoosh } from "./sfx";
|
|
12
|
+
import { color } from "./tokens";
|
|
13
|
+
|
|
14
|
+
const { fontFamily: uiFamily } = loadInter("normal", {
|
|
15
|
+
weights: ["500", "700", "900"],
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const DIP_AT = 150;
|
|
20
|
+
|
|
21
|
+
export const RepoCta: React.FC<{
|
|
22
|
+
owner: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
chips: string[];
|
|
26
|
+
titleText: string;
|
|
27
|
+
durationInFrames?: number;
|
|
28
|
+
}> = ({ owner, name, description, chips, titleText, durationInFrames = 270 }) => {
|
|
29
|
+
const frame = useCurrentFrame();
|
|
30
|
+
const { fps } = useVideoConfig();
|
|
31
|
+
const cardIn = pop(frame, fps, 8);
|
|
32
|
+
const dip = tween(frame, [DIP_AT, DIP_AT + 16], [0, 1], E.cinematic);
|
|
33
|
+
const endFade = tween(frame, [durationInFrames - 18, durationInFrames], [0, 1], E.cinematic);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<CinematicScene
|
|
37
|
+
camera={[
|
|
38
|
+
{ frame: 0, x: 960, y: 540, scale: 1.0 },
|
|
39
|
+
{ frame: durationInFrames, x: 960, y: 530, scale: 1.12, easing: E.drift },
|
|
40
|
+
]}
|
|
41
|
+
overlay={
|
|
42
|
+
<>
|
|
43
|
+
<div style={{ position: "absolute", inset: 0, background: "#07090E", opacity: dip, pointerEvents: "none" }} />
|
|
44
|
+
{frame >= DIP_AT + 14 ? (
|
|
45
|
+
<div
|
|
46
|
+
style={{
|
|
47
|
+
position: "absolute",
|
|
48
|
+
inset: 0,
|
|
49
|
+
display: "flex",
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
justifyContent: "center",
|
|
53
|
+
gap: 26,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<TitleSlam text={titleText} at={DIP_AT + 16} fontSize={76} color={color.textPrimary} glowColor={color.accent} />
|
|
57
|
+
<Caption text={`github.com/${owner}/${name}`} at={DIP_AT + 38} fontSize={28} />
|
|
58
|
+
</div>
|
|
59
|
+
) : null}
|
|
60
|
+
<div style={{ position: "absolute", inset: 0, background: "#000", opacity: endFade, pointerEvents: "none" }} />
|
|
61
|
+
</>
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<Layer depth={0.65} maxBlur={16}>
|
|
65
|
+
<div
|
|
66
|
+
style={{
|
|
67
|
+
position: "absolute",
|
|
68
|
+
left: 560,
|
|
69
|
+
top: 180,
|
|
70
|
+
width: 800,
|
|
71
|
+
height: 800,
|
|
72
|
+
borderRadius: "50%",
|
|
73
|
+
background: `radial-gradient(circle, ${color.accent}2e 0%, transparent 65%)`,
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</Layer>
|
|
77
|
+
|
|
78
|
+
<Layer depth={0}>
|
|
79
|
+
<div
|
|
80
|
+
style={{
|
|
81
|
+
position: "absolute",
|
|
82
|
+
left: 560,
|
|
83
|
+
top: 350,
|
|
84
|
+
width: 800,
|
|
85
|
+
transform: `scale(${0.85 + cardIn * 0.15}) translateY(${(1 - cardIn) * 60}px)`,
|
|
86
|
+
opacity: Math.min(1, cardIn * 1.3),
|
|
87
|
+
borderRadius: 18,
|
|
88
|
+
background: "rgba(21,26,36,0.5)",
|
|
89
|
+
backdropFilter: "blur(20px) saturate(1.3)",
|
|
90
|
+
WebkitBackdropFilter: "blur(20px) saturate(1.3)",
|
|
91
|
+
border: "1px solid rgba(255,255,255,0.12)",
|
|
92
|
+
boxShadow: "0 30px 90px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08)",
|
|
93
|
+
padding: 36,
|
|
94
|
+
fontFamily: uiFamily,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<div style={{ display: "flex", alignItems: "center", gap: 18 }}>
|
|
98
|
+
<div
|
|
99
|
+
style={{
|
|
100
|
+
width: 52,
|
|
101
|
+
height: 52,
|
|
102
|
+
borderRadius: 26,
|
|
103
|
+
background: `linear-gradient(135deg, ${color.accent} 0%, ${color.accentAlt} 100%)`,
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
<div style={{ fontSize: 30, fontWeight: 700, color: color.textPrimary }}>
|
|
107
|
+
{owner} <span style={{ color: color.textSecondary }}>/</span> {name}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div style={{ marginTop: 20, fontSize: 23, color: color.textSecondary, lineHeight: 1.5 }}>
|
|
111
|
+
{description}
|
|
112
|
+
</div>
|
|
113
|
+
<div style={{ display: "flex", gap: 24, marginTop: 22, fontSize: 19, color: color.textSecondary }}>
|
|
114
|
+
{chips.map((c, i) => (
|
|
115
|
+
<span key={i} style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
116
|
+
{i === 0 ? <span style={{ width: 12, height: 12, borderRadius: 6, background: "#3178C6" }} /> : null}
|
|
117
|
+
{c}
|
|
118
|
+
</span>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</Layer>
|
|
123
|
+
|
|
124
|
+
<Whoosh at={0} volume={0.22} />
|
|
125
|
+
<Whoosh at={DIP_AT} volume={0.3} />
|
|
126
|
+
</CinematicScene>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// TerminalReplay: DOM-rendered terminal with typed commands, staggered output reveals,
|
|
2
|
+
// blinking cursor, and frame-exact typing SFX. Never screen-recorded HTML (STYLE.md).
|
|
3
|
+
// Content must fit the window height (auto-scroll: v2).
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
7
|
+
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
|
|
8
|
+
import { E, jitter, tween } from "./motion";
|
|
9
|
+
import { Blip, TypingSfx } from "./sfx";
|
|
10
|
+
import { color, font, glow, panelShadow, radius } from "./tokens";
|
|
11
|
+
|
|
12
|
+
const { fontFamily: monoFamily } = loadMono("normal", {
|
|
13
|
+
weights: ["400", "700"],
|
|
14
|
+
subsets: ["latin"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type TermLine = {
|
|
18
|
+
text: string;
|
|
19
|
+
color?: string;
|
|
20
|
+
glow?: boolean;
|
|
21
|
+
bold?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TermStep =
|
|
25
|
+
| { type: "cmd"; text: string }
|
|
26
|
+
| { type: "out"; lines: TermLine[]; stagger?: number; preDelay?: number }
|
|
27
|
+
| { type: "pause"; frames: number };
|
|
28
|
+
|
|
29
|
+
type ResolvedCmd = {
|
|
30
|
+
type: "cmd";
|
|
31
|
+
text: string;
|
|
32
|
+
start: number;
|
|
33
|
+
charFrames: number[];
|
|
34
|
+
end: number;
|
|
35
|
+
};
|
|
36
|
+
type ResolvedOut = {
|
|
37
|
+
type: "out";
|
|
38
|
+
lines: TermLine[];
|
|
39
|
+
lineFrames: number[];
|
|
40
|
+
start: number;
|
|
41
|
+
end: number;
|
|
42
|
+
};
|
|
43
|
+
type Resolved = ResolvedCmd | ResolvedOut;
|
|
44
|
+
|
|
45
|
+
export const computeTerminalTimeline = (
|
|
46
|
+
steps: TermStep[],
|
|
47
|
+
fps: number,
|
|
48
|
+
cps = 16,
|
|
49
|
+
) => {
|
|
50
|
+
const fpc = fps / cps;
|
|
51
|
+
const items: Resolved[] = [];
|
|
52
|
+
let t = 0;
|
|
53
|
+
for (const s of steps) {
|
|
54
|
+
if (s.type === "pause") {
|
|
55
|
+
t += s.frames;
|
|
56
|
+
} else if (s.type === "cmd") {
|
|
57
|
+
const charFrames = Array.from({ length: s.text.length }, (_, i) =>
|
|
58
|
+
Math.round(t + i * fpc + jitter(i, 9) * fpc * 0.6),
|
|
59
|
+
);
|
|
60
|
+
const end = (charFrames[charFrames.length - 1] ?? t) + Math.round(fps * 0.25);
|
|
61
|
+
items.push({ type: "cmd", text: s.text, start: t, charFrames, end });
|
|
62
|
+
t = end;
|
|
63
|
+
} else {
|
|
64
|
+
const preDelay = s.preDelay ?? Math.round(fps * 0.3);
|
|
65
|
+
const stagger = s.stagger ?? 3;
|
|
66
|
+
const lineFrames = s.lines.map((_, j) => t + preDelay + j * stagger);
|
|
67
|
+
const end = (lineFrames[lineFrames.length - 1] ?? t) + Math.round(fps * 0.2);
|
|
68
|
+
items.push({ type: "out", lines: s.lines, lineFrames, start: t, end });
|
|
69
|
+
t = end;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { items, totalFrames: t };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const LineReveal: React.FC<{
|
|
76
|
+
at: number;
|
|
77
|
+
children: React.ReactNode;
|
|
78
|
+
}> = ({ at, children }) => {
|
|
79
|
+
const frame = useCurrentFrame();
|
|
80
|
+
if (frame < at) return null;
|
|
81
|
+
const p = tween(frame, [at, at + 7], [0, 1], E.snap);
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
style={{
|
|
85
|
+
clipPath: `inset(0 ${(1 - p) * 100}% 0 0)`,
|
|
86
|
+
transform: `translateY(${(1 - p) * 6}px)`,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const TerminalReplay: React.FC<{
|
|
95
|
+
steps: TermStep[];
|
|
96
|
+
title?: string;
|
|
97
|
+
width?: number;
|
|
98
|
+
fontSize?: number;
|
|
99
|
+
cps?: number;
|
|
100
|
+
/** render SFX for typing/reveals (default true) */
|
|
101
|
+
sfx?: boolean;
|
|
102
|
+
/** show a fresh blinking prompt after all output is done (default true) */
|
|
103
|
+
trailingPrompt?: boolean;
|
|
104
|
+
}> = ({
|
|
105
|
+
steps,
|
|
106
|
+
title = "Project",
|
|
107
|
+
width = 1280,
|
|
108
|
+
fontSize = 26,
|
|
109
|
+
cps = 16,
|
|
110
|
+
sfx = true,
|
|
111
|
+
trailingPrompt = true,
|
|
112
|
+
}) => {
|
|
113
|
+
const frame = useCurrentFrame();
|
|
114
|
+
const { fps } = useVideoConfig();
|
|
115
|
+
const { items, totalFrames } = computeTerminalTimeline(steps, fps, cps);
|
|
116
|
+
|
|
117
|
+
const lastCmd = [...items].reverse().find((i) => i.type === "cmd") as
|
|
118
|
+
| ResolvedCmd
|
|
119
|
+
| undefined;
|
|
120
|
+
const typingActive =
|
|
121
|
+
lastCmd && frame >= lastCmd.start && frame <= lastCmd.end;
|
|
122
|
+
const cursorOn = Math.floor(frame / 8) % 2 === 0 || typingActive;
|
|
123
|
+
// cursor sits on the command line only while typing (+ a short beat after);
|
|
124
|
+
// once output streams, it disappears; a fresh prompt appears at the end.
|
|
125
|
+
const showCmdCursor = lastCmd && frame <= lastCmd.end + 12;
|
|
126
|
+
const trailingAt = totalFrames + 14;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
style={{
|
|
131
|
+
width,
|
|
132
|
+
borderRadius: radius.window,
|
|
133
|
+
background: "#10141D",
|
|
134
|
+
border: `1px solid ${color.panelBorder}`,
|
|
135
|
+
boxShadow: panelShadow(true),
|
|
136
|
+
overflow: "hidden",
|
|
137
|
+
fontFamily: monoFamily,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{/* chrome */}
|
|
141
|
+
<div
|
|
142
|
+
style={{
|
|
143
|
+
height: 52,
|
|
144
|
+
display: "flex",
|
|
145
|
+
alignItems: "center",
|
|
146
|
+
padding: "0 20px",
|
|
147
|
+
background: "#151A24",
|
|
148
|
+
borderBottom: `1px solid ${color.panelBorder}`,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<div style={{ display: "flex", gap: 10 }}>
|
|
152
|
+
{["#FF5F57", "#FEBC2E", "#28C840"].map((c) => (
|
|
153
|
+
<div
|
|
154
|
+
key={c}
|
|
155
|
+
style={{ width: 14, height: 14, borderRadius: 7, background: c }}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
<div
|
|
160
|
+
style={{
|
|
161
|
+
flex: 1,
|
|
162
|
+
textAlign: "center",
|
|
163
|
+
color: color.textSecondary,
|
|
164
|
+
fontSize: fontSize * 0.62,
|
|
165
|
+
fontFamily: font.ui,
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{title}
|
|
169
|
+
</div>
|
|
170
|
+
<div style={{ width: 62 }} />
|
|
171
|
+
</div>
|
|
172
|
+
{/* body */}
|
|
173
|
+
<div
|
|
174
|
+
style={{
|
|
175
|
+
padding: `${fontSize * 1.1}px ${fontSize * 1.3}px`,
|
|
176
|
+
fontSize,
|
|
177
|
+
lineHeight: 1.75,
|
|
178
|
+
color: color.textPrimary,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{items.map((item, idx) => {
|
|
182
|
+
if (frame < item.start) return null;
|
|
183
|
+
if (item.type === "cmd") {
|
|
184
|
+
const visible = item.charFrames.filter((f) => f <= frame).length;
|
|
185
|
+
const isCurrent = item === lastCmd;
|
|
186
|
+
// slash-command prefix (e.g. "/components") renders accent+bold
|
|
187
|
+
const prefixMatch = item.text.match(/^\/\S+/);
|
|
188
|
+
const prefixLen = prefixMatch ? prefixMatch[0].length : 0;
|
|
189
|
+
return (
|
|
190
|
+
<div key={idx} style={{ whiteSpace: "pre-wrap" }}>
|
|
191
|
+
<span style={{ color: color.accent, fontWeight: 700 }}>
|
|
192
|
+
{"❯ "}
|
|
193
|
+
</span>
|
|
194
|
+
{prefixLen > 0 ? (
|
|
195
|
+
<span style={{ color: color.accentAlt, fontWeight: 700 }}>
|
|
196
|
+
{item.text.slice(0, Math.min(visible, prefixLen))}
|
|
197
|
+
</span>
|
|
198
|
+
) : null}
|
|
199
|
+
<span>{item.text.slice(Math.min(visible, prefixLen), visible)}</span>
|
|
200
|
+
{isCurrent && showCmdCursor && cursorOn ? (
|
|
201
|
+
<span
|
|
202
|
+
style={{
|
|
203
|
+
display: "inline-block",
|
|
204
|
+
width: fontSize * 0.55,
|
|
205
|
+
height: fontSize * 1.05,
|
|
206
|
+
background: color.textPrimary,
|
|
207
|
+
verticalAlign: "text-bottom",
|
|
208
|
+
marginLeft: 2,
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
) : null}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return (
|
|
216
|
+
<div key={idx}>
|
|
217
|
+
{item.lines.map((l, j) => (
|
|
218
|
+
<LineReveal key={j} at={item.lineFrames[j]}>
|
|
219
|
+
<span
|
|
220
|
+
style={{
|
|
221
|
+
whiteSpace: "pre-wrap",
|
|
222
|
+
color: l.color ?? color.textSecondary,
|
|
223
|
+
fontWeight: l.bold ? 700 : 400,
|
|
224
|
+
...(l.glow ? glow(l.color ?? color.accent) : {}),
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{l.text}
|
|
228
|
+
</span>
|
|
229
|
+
</LineReveal>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
})}
|
|
234
|
+
{trailingPrompt && frame >= trailingAt ? (
|
|
235
|
+
<div>
|
|
236
|
+
<span style={{ color: color.accent, fontWeight: 700 }}>{"❯ "}</span>
|
|
237
|
+
{cursorOn ? (
|
|
238
|
+
<span
|
|
239
|
+
style={{
|
|
240
|
+
display: "inline-block",
|
|
241
|
+
width: fontSize * 0.55,
|
|
242
|
+
height: fontSize * 1.05,
|
|
243
|
+
background: color.textPrimary,
|
|
244
|
+
verticalAlign: "text-bottom",
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
) : null}
|
|
248
|
+
</div>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
{/* SFX */}
|
|
252
|
+
{sfx
|
|
253
|
+
? items.map((item, idx) =>
|
|
254
|
+
item.type === "cmd" ? (
|
|
255
|
+
<TypingSfx key={`sfx-${idx}`} charFrames={item.charFrames} />
|
|
256
|
+
) : (
|
|
257
|
+
<Blip key={`sfx-${idx}`} at={item.lineFrames[0]} />
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
: null}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
};
|
package/src/camera.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Camera rig: renders children on an oversized "stage" and moves a virtual camera over it.
|
|
2
|
+
// The camera is defined by keyframes (focal point on stage + zoom + tilt). Between keyframes,
|
|
3
|
+
// values interpolate with a cinematic easing per segment. Layers can opt into parallax + DoF blur.
|
|
4
|
+
|
|
5
|
+
import React, { createContext, useContext } from "react";
|
|
6
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
7
|
+
import { E, tween } from "./motion";
|
|
8
|
+
|
|
9
|
+
export type CamKeyframe = {
|
|
10
|
+
frame: number;
|
|
11
|
+
/** focal point on the stage, in stage px, this point lands at frame center */
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
/** zoom: 1 = stage px == output px; 1.6 = 160% close-up */
|
|
15
|
+
scale: number;
|
|
16
|
+
rotX?: number;
|
|
17
|
+
rotY?: number;
|
|
18
|
+
rotZ?: number;
|
|
19
|
+
/** easing INTO this keyframe (from the previous one) */
|
|
20
|
+
easing?: (t: number) => number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type CamState = Required<Omit<CamKeyframe, "easing" | "frame">>;
|
|
24
|
+
|
|
25
|
+
const CameraContext = createContext<CamState | null>(null);
|
|
26
|
+
|
|
27
|
+
const resolve = (kfs: CamKeyframe[], frame: number): CamState => {
|
|
28
|
+
const sorted = [...kfs].sort((a, b) => a.frame - b.frame);
|
|
29
|
+
const first = sorted[0];
|
|
30
|
+
const last = sorted[sorted.length - 1];
|
|
31
|
+
const base = (k: CamKeyframe): CamState => ({
|
|
32
|
+
x: k.x,
|
|
33
|
+
y: k.y,
|
|
34
|
+
scale: k.scale,
|
|
35
|
+
rotX: k.rotX ?? 0,
|
|
36
|
+
rotY: k.rotY ?? 0,
|
|
37
|
+
rotZ: k.rotZ ?? 0,
|
|
38
|
+
});
|
|
39
|
+
if (frame <= first.frame) return base(first);
|
|
40
|
+
if (frame >= last.frame) return base(last);
|
|
41
|
+
let a = first;
|
|
42
|
+
let b = sorted[1];
|
|
43
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
44
|
+
if (sorted[i].frame >= frame) {
|
|
45
|
+
a = sorted[i - 1];
|
|
46
|
+
b = sorted[i];
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const ease = b.easing ?? E.cinematic;
|
|
51
|
+
const A = base(a);
|
|
52
|
+
const B = base(b);
|
|
53
|
+
const lerp = (ka: number, kb: number) =>
|
|
54
|
+
tween(frame, [a.frame, b.frame], [ka, kb], ease);
|
|
55
|
+
return {
|
|
56
|
+
x: lerp(A.x, B.x),
|
|
57
|
+
y: lerp(A.y, B.y),
|
|
58
|
+
scale: lerp(A.scale, B.scale),
|
|
59
|
+
rotX: lerp(A.rotX, B.rotX),
|
|
60
|
+
rotY: lerp(A.rotY, B.rotY),
|
|
61
|
+
rotZ: lerp(A.rotZ, B.rotZ),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const CameraRig: React.FC<{
|
|
66
|
+
keyframes: CamKeyframe[];
|
|
67
|
+
/** stage size in px; content is laid out at this size, camera crops into it */
|
|
68
|
+
stageWidth?: number;
|
|
69
|
+
stageHeight?: number;
|
|
70
|
+
children: React.ReactNode;
|
|
71
|
+
}> = ({ keyframes, stageWidth = 1920, stageHeight = 1080, children }) => {
|
|
72
|
+
const frame = useCurrentFrame();
|
|
73
|
+
const { width, height } = useVideoConfig();
|
|
74
|
+
const cam = resolve(keyframes, frame);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<CameraContext.Provider value={cam}>
|
|
78
|
+
<div
|
|
79
|
+
style={{
|
|
80
|
+
width,
|
|
81
|
+
height,
|
|
82
|
+
overflow: "hidden",
|
|
83
|
+
perspective: 1400,
|
|
84
|
+
perspectiveOrigin: "50% 50%",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
style={{
|
|
89
|
+
width: stageWidth,
|
|
90
|
+
height: stageHeight,
|
|
91
|
+
transformOrigin: "0 0",
|
|
92
|
+
transformStyle: "preserve-3d",
|
|
93
|
+
transform: [
|
|
94
|
+
`translate(${width / 2}px, ${height / 2}px)`,
|
|
95
|
+
`rotateX(${cam.rotX}deg)`,
|
|
96
|
+
`rotateY(${cam.rotY}deg)`,
|
|
97
|
+
`rotateZ(${cam.rotZ}deg)`,
|
|
98
|
+
`scale(${cam.scale})`,
|
|
99
|
+
`translate(${-cam.x}px, ${-cam.y}px)`,
|
|
100
|
+
].join(" "),
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</CameraContext.Provider>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const useCamera = () => useContext(CameraContext);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parallax + depth-of-field layer. depth 0 = focal plane (sharp, no parallax).
|
|
114
|
+
* Positive depth = "behind" (moves less, blurred). Negative = foreground (moves more, blurred).
|
|
115
|
+
*/
|
|
116
|
+
export const Layer: React.FC<{
|
|
117
|
+
depth?: number;
|
|
118
|
+
/** max blur in px applied at |depth| = 1 */
|
|
119
|
+
maxBlur?: number;
|
|
120
|
+
style?: React.CSSProperties;
|
|
121
|
+
children: React.ReactNode;
|
|
122
|
+
}> = ({ depth = 0, maxBlur = 10, style, children }) => {
|
|
123
|
+
const cam = useCamera();
|
|
124
|
+
const parallax = 0.06; // fraction of camera offset transferred per unit depth
|
|
125
|
+
const dx = cam ? (cam.x - 960) * depth * parallax : 0;
|
|
126
|
+
const dy = cam ? (cam.y - 540) * depth * parallax : 0;
|
|
127
|
+
const blur = Math.abs(depth) * maxBlur;
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
style={{
|
|
131
|
+
position: "absolute",
|
|
132
|
+
inset: 0,
|
|
133
|
+
transform: `translate(${dx}px, ${dy}px)`,
|
|
134
|
+
filter: blur > 0.2 ? `blur(${blur.toFixed(1)}px)` : undefined,
|
|
135
|
+
...style,
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
};
|