@openvidstudio/mcp-server 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.
Files changed (94) hide show
  1. package/dist/beats.d.ts +47 -0
  2. package/dist/beats.js +9 -0
  3. package/dist/capture.d.ts +116 -0
  4. package/dist/capture.js +148 -0
  5. package/dist/config.d.ts +58 -0
  6. package/dist/config.js +130 -0
  7. package/dist/paths.d.ts +20 -0
  8. package/dist/paths.js +57 -0
  9. package/dist/scenes/pngSize.d.ts +11 -0
  10. package/dist/scenes/pngSize.js +75 -0
  11. package/dist/scenes/templates.d.ts +42 -0
  12. package/dist/scenes/templates.js +468 -0
  13. package/dist/server.d.ts +16 -0
  14. package/dist/server.js +37 -0
  15. package/dist/stdio.d.ts +2 -0
  16. package/dist/stdio.js +33 -0
  17. package/dist/tools/captureScreenRecording.d.ts +28 -0
  18. package/dist/tools/captureScreenRecording.js +181 -0
  19. package/dist/tools/captureScreenshot.d.ts +34 -0
  20. package/dist/tools/captureScreenshot.js +148 -0
  21. package/dist/tools/contactSheet.d.ts +36 -0
  22. package/dist/tools/contactSheet.js +116 -0
  23. package/dist/tools/generateNarration.d.ts +54 -0
  24. package/dist/tools/generateNarration.js +185 -0
  25. package/dist/tools/importHiggsfieldClip.d.ts +41 -0
  26. package/dist/tools/importHiggsfieldClip.js +108 -0
  27. package/dist/tools/index.d.ts +13 -0
  28. package/dist/tools/index.js +47 -0
  29. package/dist/tools/initProject.d.ts +37 -0
  30. package/dist/tools/initProject.js +166 -0
  31. package/dist/tools/mcp.d.ts +33 -0
  32. package/dist/tools/mcp.js +36 -0
  33. package/dist/tools/planBeats.d.ts +40 -0
  34. package/dist/tools/planBeats.js +143 -0
  35. package/dist/tools/preflight.d.ts +30 -0
  36. package/dist/tools/preflight.js +170 -0
  37. package/dist/tools/qcExtractFrames.d.ts +25 -0
  38. package/dist/tools/qcExtractFrames.js +129 -0
  39. package/dist/tools/renderVideo.d.ts +43 -0
  40. package/dist/tools/renderVideo.js +120 -0
  41. package/dist/tools/scaffoldScene.d.ts +23 -0
  42. package/dist/tools/scaffoldScene.js +147 -0
  43. package/dist/tools/stitchComposition.d.ts +24 -0
  44. package/dist/tools/stitchComposition.js +230 -0
  45. package/dist/tools/validateBeats.d.ts +14 -0
  46. package/dist/tools/validateBeats.js +164 -0
  47. package/dist/tools/validateScenes.d.ts +31 -0
  48. package/dist/tools/validateScenes.js +191 -0
  49. package/dist/tools/writeBeatsFile.d.ts +20 -0
  50. package/dist/tools/writeBeatsFile.js +81 -0
  51. package/dist/util.d.ts +43 -0
  52. package/dist/util.js +163 -0
  53. package/docs/API.md +344 -0
  54. package/docs/CAPTURE.md +106 -0
  55. package/docs/HIGGSFIELD.md +118 -0
  56. package/docs/NARRATION.md +111 -0
  57. package/docs/OVERVIEW.md +112 -0
  58. package/docs/PIPELINE.md +173 -0
  59. package/docs/PLANNING.md +206 -0
  60. package/docs/SCRIPT.md +50 -0
  61. package/docs/STYLE.md +88 -0
  62. package/package.json +54 -0
  63. package/templates/default/CLAUDE.md +63 -0
  64. package/templates/default/openvidstudio-core/package.json +51 -0
  65. package/templates/default/openvidstudio-core/scripts/gen-sfx.sh +74 -0
  66. package/templates/default/openvidstudio-core/src/ChecklistPanel.tsx +97 -0
  67. package/templates/default/openvidstudio-core/src/CinematicScene.tsx +53 -0
  68. package/templates/default/openvidstudio-core/src/CodePanel.tsx +122 -0
  69. package/templates/default/openvidstudio-core/src/CursorActor.tsx +90 -0
  70. package/templates/default/openvidstudio-core/src/DeviceFrame.tsx +74 -0
  71. package/templates/default/openvidstudio-core/src/KineticText.tsx +142 -0
  72. package/templates/default/openvidstudio-core/src/RepoCta.tsx +128 -0
  73. package/templates/default/openvidstudio-core/src/TerminalReplay.tsx +263 -0
  74. package/templates/default/openvidstudio-core/src/camera.tsx +141 -0
  75. package/templates/default/openvidstudio-core/src/index.ts +60 -0
  76. package/templates/default/openvidstudio-core/src/look.tsx +90 -0
  77. package/templates/default/openvidstudio-core/src/motion.ts +50 -0
  78. package/templates/default/openvidstudio-core/src/sfx.tsx +106 -0
  79. package/templates/default/openvidstudio-core/src/tokens.ts +41 -0
  80. package/templates/default/package.json +31 -0
  81. package/templates/default/public/sfx/bell.wav +0 -0
  82. package/templates/default/public/sfx/blip.wav +0 -0
  83. package/templates/default/public/sfx/click.wav +0 -0
  84. package/templates/default/public/sfx/key_a.wav +0 -0
  85. package/templates/default/public/sfx/key_b.wav +0 -0
  86. package/templates/default/public/sfx/key_c.wav +0 -0
  87. package/templates/default/public/sfx/key_enter.wav +0 -0
  88. package/templates/default/public/sfx/music-bed.mp3 +0 -0
  89. package/templates/default/public/sfx/success.wav +0 -0
  90. package/templates/default/public/sfx/whoosh.wav +0 -0
  91. package/templates/default/remotion.config.ts +13 -0
  92. package/templates/default/src/Root.tsx +9 -0
  93. package/templates/default/src/index.ts +4 -0
  94. package/templates/default/tsconfig.json +16 -0
@@ -0,0 +1,97 @@
1
+ // GitHub-style Community Standards checklist panel. Items flip ✗ → ✓ at flipAt frames.
2
+
3
+ import React from "react";
4
+ import { useCurrentFrame, useVideoConfig } from "remotion";
5
+ import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
6
+ import { pop, SPRING } from "./motion";
7
+ import { Blip } from "./sfx";
8
+ import { color, panelShadow, radius } from "./tokens";
9
+
10
+ const { fontFamily: uiFamily } = loadInter("normal", {
11
+ weights: ["500", "700"],
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export type ChecklistItem = {
16
+ label: string;
17
+ /** absolute scene frame at which this item flips to ✓ (omit = stays ✗) */
18
+ flipAt?: number;
19
+ };
20
+
21
+ export const ChecklistPanel: React.FC<{
22
+ title?: string;
23
+ items: ChecklistItem[];
24
+ width?: number;
25
+ fontSize?: number;
26
+ sfx?: boolean;
27
+ }> = ({
28
+ title = "Community Standards",
29
+ items,
30
+ width = 760,
31
+ fontSize = 27,
32
+ sfx = true,
33
+ }) => {
34
+ const frame = useCurrentFrame();
35
+ const { fps } = useVideoConfig();
36
+ return (
37
+ <div
38
+ style={{
39
+ width,
40
+ borderRadius: radius.window,
41
+ background: "#10141D",
42
+ border: `1px solid ${color.panelBorder}`,
43
+ boxShadow: panelShadow(true),
44
+ fontFamily: uiFamily,
45
+ padding: `${fontSize}px ${fontSize * 1.2}px`,
46
+ }}
47
+ >
48
+ <div style={{ fontSize: fontSize * 1.15, fontWeight: 700, color: color.textPrimary, marginBottom: fontSize * 0.8 }}>
49
+ {title}
50
+ </div>
51
+ {items.map((item, i) => {
52
+ const flipped = item.flipAt !== undefined && frame >= item.flipAt;
53
+ const s = flipped ? pop(frame, fps, item.flipAt, SPRING.pop) : 1;
54
+ return (
55
+ <div
56
+ key={i}
57
+ style={{
58
+ display: "flex",
59
+ alignItems: "center",
60
+ gap: fontSize * 0.6,
61
+ padding: `${fontSize * 0.38}px 0`,
62
+ fontSize,
63
+ color: color.textSecondary,
64
+ borderBottom: i < items.length - 1 ? `1px solid ${color.panelBorder}55` : undefined,
65
+ }}
66
+ >
67
+ <span
68
+ style={{
69
+ display: "inline-flex",
70
+ alignItems: "center",
71
+ justifyContent: "center",
72
+ width: fontSize * 1.15,
73
+ height: fontSize * 1.15,
74
+ borderRadius: "50%",
75
+ fontWeight: 700,
76
+ fontSize: fontSize * 0.8,
77
+ transform: `scale(${0.7 + s * 0.3})`,
78
+ background: flipped ? `${color.success}22` : `${color.danger}22`,
79
+ color: flipped ? color.success : color.danger,
80
+ border: `1.5px solid ${flipped ? color.success : color.danger}`,
81
+ boxShadow: flipped ? `0 0 18px ${color.success}44` : undefined,
82
+ }}
83
+ >
84
+ {flipped ? "✓" : "✗"}
85
+ </span>
86
+ <span style={{ color: flipped ? color.textPrimary : color.textSecondary }}>{item.label}</span>
87
+ </div>
88
+ );
89
+ })}
90
+ {sfx
91
+ ? items
92
+ .filter((i) => i.flipAt !== undefined)
93
+ .map((i, k) => <Blip key={k} at={i.flipAt as number} volume={0.22} />)
94
+ : null}
95
+ </div>
96
+ );
97
+ };
@@ -0,0 +1,53 @@
1
+ // Mandatory wrapper for every scene (STYLE.md): dark radial stage + camera rig + look pass.
2
+ // Content renders on an oversized stage; camera keyframes drive the shot.
3
+
4
+ import React from "react";
5
+ import { AbsoluteFill } from "remotion";
6
+ import { CameraRig, CamKeyframe } from "./camera";
7
+ import { LookPass } from "./look";
8
+ import { color } from "./tokens";
9
+
10
+ export const CinematicScene: React.FC<{
11
+ camera: CamKeyframe[];
12
+ stageWidth?: number;
13
+ stageHeight?: number;
14
+ /** dark (default) or light stage */
15
+ variant?: "dark" | "light";
16
+ grain?: number;
17
+ vignette?: number;
18
+ /** screen-space layer (captions, title cards, dips/fades), NOT camera-transformed */
19
+ overlay?: React.ReactNode;
20
+ children: React.ReactNode;
21
+ }> = ({
22
+ camera,
23
+ stageWidth = 1920,
24
+ stageHeight = 1080,
25
+ variant = "dark",
26
+ grain = 0.05,
27
+ vignette = 0.32,
28
+ overlay,
29
+ children,
30
+ }) => {
31
+ const bg =
32
+ variant === "dark"
33
+ ? `radial-gradient(ellipse 90% 80% at 50% 40%, ${color.bg1} 0%, ${color.bg0} 100%)`
34
+ : color.lightBg;
35
+ return (
36
+ <AbsoluteFill style={{ background: bg }}>
37
+ <CameraRig
38
+ keyframes={camera}
39
+ stageWidth={stageWidth}
40
+ stageHeight={stageHeight}
41
+ >
42
+ {children}
43
+ </CameraRig>
44
+ {overlay ? (
45
+ <div style={{ position: "absolute", inset: 0 }}>{overlay}</div>
46
+ ) : null}
47
+ <LookPass
48
+ grain={grain}
49
+ vignette={variant === "light" ? vignette * 0.5 : vignette}
50
+ />
51
+ </AbsoluteFill>
52
+ );
53
+ };
@@ -0,0 +1,122 @@
1
+ // Code panel: manually-tokenized lines (no highlighter dep), optional scroll + row highlight.
2
+
3
+ import React from "react";
4
+ import { color, glow, panelShadow, radius } from "./tokens";
5
+
6
+ // System-safe fallbacks, avoid @remotion/google-fonts' network fetch (hangs render offline).
7
+ const monoFamily = "JetBrains Mono, Consolas, Menlo, monospace";
8
+ const uiFamily = "Inter, -apple-system, Segoe UI, Roboto, sans-serif";
9
+
10
+ export type Token = { t: string; c?: "kw" | "str" | "fn" | "cm" | "num" | "plain" };
11
+ export type CodeLine = Token[];
12
+
13
+ const TOKEN_COLORS: Record<NonNullable<Token["c"]>, string> = {
14
+ kw: "#C792EA",
15
+ str: "#8BD49C",
16
+ fn: "#82AAFF",
17
+ cm: "#5B6572",
18
+ num: "#F78C6C",
19
+ plain: "#D6DEEB",
20
+ };
21
+
22
+ export const CodePanel: React.FC<{
23
+ title: string;
24
+ lines: CodeLine[];
25
+ width?: number;
26
+ height?: number;
27
+ fontSize?: number;
28
+ /** px translateY applied to code body (drive with a tween for scrolling) */
29
+ scroll?: number;
30
+ /** 0-based line indexes to highlight with an accent row + glow */
31
+ highlight?: number[];
32
+ }> = ({
33
+ title,
34
+ lines,
35
+ width = 860,
36
+ height = 640,
37
+ fontSize = 22,
38
+ scroll = 0,
39
+ highlight = [],
40
+ }) => {
41
+ return (
42
+ <div
43
+ style={{
44
+ width,
45
+ height,
46
+ borderRadius: radius.window,
47
+ background: "#10141D",
48
+ border: `1px solid ${color.panelBorder}`,
49
+ boxShadow: panelShadow(true),
50
+ overflow: "hidden",
51
+ }}
52
+ >
53
+ <div
54
+ style={{
55
+ height: 48,
56
+ display: "flex",
57
+ alignItems: "center",
58
+ padding: "0 18px",
59
+ gap: 10,
60
+ background: "#151A24",
61
+ borderBottom: `1px solid ${color.panelBorder}`,
62
+ fontFamily: uiFamily,
63
+ fontSize: 16,
64
+ color: color.textSecondary,
65
+ }}
66
+ >
67
+ <div style={{ display: "flex", gap: 8 }}>
68
+ {["#FF5F57", "#FEBC2E", "#28C840"].map((c) => (
69
+ <div key={c} style={{ width: 12, height: 12, borderRadius: 6, background: c }} />
70
+ ))}
71
+ </div>
72
+ <span style={{ marginLeft: 8 }}>{title}</span>
73
+ </div>
74
+ <div style={{ height: height - 48, overflow: "hidden" }}>
75
+ <div style={{ padding: `${fontSize * 0.7}px 0`, transform: `translateY(${-scroll}px)` }}>
76
+ {lines.map((line, i) => {
77
+ const hl = highlight.includes(i);
78
+ return (
79
+ <div
80
+ key={i}
81
+ style={{
82
+ display: "flex",
83
+ fontFamily: monoFamily,
84
+ fontSize,
85
+ lineHeight: 1.65,
86
+ background: hl ? `${color.accent}1A` : undefined,
87
+ borderLeft: hl ? `3px solid ${color.accent}` : "3px solid transparent",
88
+ }}
89
+ >
90
+ <span
91
+ style={{
92
+ width: fontSize * 2.4,
93
+ textAlign: "right",
94
+ paddingRight: fontSize * 0.8,
95
+ color: "#3A4252",
96
+ userSelect: "none",
97
+ flexShrink: 0,
98
+ }}
99
+ >
100
+ {i + 1}
101
+ </span>
102
+ <span style={{ whiteSpace: "pre" }}>
103
+ {line.map((tok, j) => (
104
+ <span
105
+ key={j}
106
+ style={{
107
+ color: TOKEN_COLORS[tok.c ?? "plain"],
108
+ ...(hl && tok.c === "str" ? glow(color.accent, 0.6) : {}),
109
+ }}
110
+ >
111
+ {tok.t}
112
+ </span>
113
+ ))}
114
+ </span>
115
+ </div>
116
+ );
117
+ })}
118
+ </div>
119
+ </div>
120
+ </div>
121
+ );
122
+ };
@@ -0,0 +1,90 @@
1
+ // Cursor as an actor (STYLE.md rule 4): oversized macOS cursor gliding between keyframes
2
+ // with cinematic easing, click = press-scale + expanding ring + click SFX.
3
+
4
+ import React from "react";
5
+ import { useCurrentFrame } from "remotion";
6
+ import { E, tween } from "./motion";
7
+ import { Click } from "./sfx";
8
+ import { color } from "./tokens";
9
+
10
+ export type CursorKeyframe = { frame: number; x: number; y: number };
11
+
12
+ export const CursorActor: React.FC<{
13
+ path: CursorKeyframe[];
14
+ /** frames at which a click happens (ring + press + sfx) */
15
+ clicks?: number[];
16
+ scale?: number;
17
+ sfx?: boolean;
18
+ }> = ({ path, clicks = [], scale = 1.6, sfx = true }) => {
19
+ const frame = useCurrentFrame();
20
+ const sorted = [...path].sort((a, b) => a.frame - b.frame);
21
+ if (frame < sorted[0].frame) return null;
22
+
23
+ let x = sorted[sorted.length - 1].x;
24
+ let y = sorted[sorted.length - 1].y;
25
+ for (let i = 1; i < sorted.length; i++) {
26
+ if (sorted[i].frame >= frame) {
27
+ const a = sorted[i - 1];
28
+ const b = sorted[i];
29
+ x = tween(frame, [a.frame, b.frame], [a.x, b.x], E.cinematic);
30
+ y = tween(frame, [a.frame, b.frame], [a.y, b.y], E.cinematic);
31
+ break;
32
+ }
33
+ }
34
+
35
+ // press scale near a click
36
+ const press = clicks.reduce((acc, c) => {
37
+ if (frame >= c && frame <= c + 8) {
38
+ return Math.min(acc, 1 - 0.18 * Math.sin(((frame - c) / 8) * Math.PI));
39
+ }
40
+ return acc;
41
+ }, 1);
42
+
43
+ return (
44
+ <>
45
+ {/* click rings */}
46
+ {clicks.map((c, i) => {
47
+ if (frame < c || frame > c + 22) return null;
48
+ const p = tween(frame, [c, c + 22], [0, 1], E.snap);
49
+ return (
50
+ <div
51
+ key={i}
52
+ style={{
53
+ position: "absolute",
54
+ left: x - 60 * p,
55
+ top: y - 60 * p,
56
+ width: 120 * p,
57
+ height: 120 * p,
58
+ borderRadius: "50%",
59
+ border: `3px solid ${color.accent}`,
60
+ opacity: 1 - p,
61
+ boxShadow: `0 0 24px ${color.accent}66`,
62
+ }}
63
+ />
64
+ );
65
+ })}
66
+ {/* macOS arrow cursor */}
67
+ <svg
68
+ width={28 * scale}
69
+ height={40 * scale}
70
+ viewBox="0 0 28 40"
71
+ style={{
72
+ position: "absolute",
73
+ left: x,
74
+ top: y,
75
+ transform: `scale(${press})`,
76
+ transformOrigin: "top left",
77
+ filter: "drop-shadow(0 4px 10px rgba(0,0,0,0.5))",
78
+ }}
79
+ >
80
+ <path
81
+ d="M2 2 L2 32 L10 25 L15 37 L20 35 L15 23 L26 23 Z"
82
+ fill="#FFFFFF"
83
+ stroke="#000000"
84
+ strokeWidth={2}
85
+ />
86
+ </svg>
87
+ {sfx ? clicks.map((c, i) => <Click key={i} at={c} />) : null}
88
+ </>
89
+ );
90
+ };
@@ -0,0 +1,74 @@
1
+ // Browser window frame (macOS chrome + URL pill) hosting arbitrary DOM content.
2
+
3
+ import React from "react";
4
+ import { color, panelShadow, radius } from "./tokens";
5
+
6
+ // System-safe fallback, avoids @remotion/google-fonts' network fetch (hangs render offline).
7
+ const uiFamily = "Inter, -apple-system, Segoe UI, Roboto, sans-serif";
8
+
9
+ export const BrowserFrame: React.FC<{
10
+ url: string;
11
+ width?: number;
12
+ height?: number;
13
+ dark?: boolean;
14
+ children: React.ReactNode;
15
+ }> = ({ url, width = 1360, height = 850, dark = true, children }) => {
16
+ const chrome = dark ? "#151A24" : "#EDF0F4";
17
+ const body = dark ? "#0E1219" : "#FFFFFF";
18
+ const border = dark ? color.panelBorder : "#D8DEE7";
19
+ const text = dark ? color.textSecondary : "#5B6572";
20
+ return (
21
+ <div
22
+ style={{
23
+ width,
24
+ height,
25
+ borderRadius: radius.window,
26
+ background: body,
27
+ border: `1px solid ${border}`,
28
+ boxShadow: panelShadow(dark),
29
+ overflow: "hidden",
30
+ fontFamily: uiFamily,
31
+ }}
32
+ >
33
+ <div
34
+ style={{
35
+ height: 56,
36
+ display: "flex",
37
+ alignItems: "center",
38
+ gap: 16,
39
+ padding: "0 20px",
40
+ background: chrome,
41
+ borderBottom: `1px solid ${border}`,
42
+ }}
43
+ >
44
+ <div style={{ display: "flex", gap: 10 }}>
45
+ {["#FF5F57", "#FEBC2E", "#28C840"].map((c) => (
46
+ <div key={c} style={{ width: 14, height: 14, borderRadius: 7, background: c }} />
47
+ ))}
48
+ </div>
49
+ <div
50
+ style={{
51
+ flex: 1,
52
+ maxWidth: 640,
53
+ margin: "0 auto",
54
+ height: 34,
55
+ borderRadius: 17,
56
+ background: dark ? "#0E1219" : "#FFFFFF",
57
+ border: `1px solid ${border}`,
58
+ display: "flex",
59
+ alignItems: "center",
60
+ justifyContent: "center",
61
+ fontSize: 17,
62
+ color: text,
63
+ }}
64
+ >
65
+ {url}
66
+ </div>
67
+ <div style={{ width: 62 }} />
68
+ </div>
69
+ <div style={{ position: "relative", width: "100%", height: height - 56, overflow: "hidden" }}>
70
+ {children}
71
+ </div>
72
+ </div>
73
+ );
74
+ };
@@ -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
+ };