@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.
@@ -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
+ };
@@ -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
+ };