buildmymcpserver/remotion/src/scenes/TransformScene.tsx

255 lines
8.9 KiB
TypeScript
Raw Normal View History

feat(web): Remotion hero video — Section 2 (prompt → server → connect) New @bmm/video workspace at remotion/. Renders an 8s 1920×1080 H.264 + WebM + JPG poster sequence that visualises the three-step "How it works" pitch literally: - Beat 1 (0-2s): "Search our Notion workspace" word-by-word entrance with spring-in from below + brief indigo under-glow + monospace prompt.txt label. Blinking cursor bridges the loop seam. - Beat 2 (2-5s): each prompt word detonates into ~9 particles per word; particles drift, then magnetically converge onto target slots along a server schematic that strokes itself on. Scan-line sweep + corner labels (mcp-notion, OAuth 2.1, search_pages, get_page_content) sell that this is a real artefact, not a placeholder. - Beat 3 (5-8s): Claude Desktop client panel slides in from the right; a Bézier wire animates between server and client; three data-packet dots travel along the wire; 200-OK tag pops; green live-dot pulses on the server. Last 12 frames fade to black so frame 239 ≈ frame 0 and browser <video loop> has no visible seam. Brand palette is hard-coded in lib/colors.ts to match globals.css — keeps the Remotion bundle self-contained (no Tailwind import needed). springIn / softSpring / clampLerp / rand helpers in lib/easings.ts power the motion vocabulary. Concurrency=1 + yuv420p in the config gives a deterministic render that plays on every <video> tag. File sizes: hero.mp4 449 KB, hero.webm 258 KB, hero-poster.jpg 33 KB — all well under the 3 MB / 250 KB ceilings. Section 2 ("How it works") now opens with the video in a border-bordered aspect-video panel between the heading and the three existing cards. autoPlay+muted+loop+playsInline satisfies every mobile autoplay policy; motion-reduce:hidden swaps in the static poster for prefers-reduced-motion users. Scripts: - pnpm --filter @bmm/video render:all (mp4 + webm + poster) - pnpm --filter @bmm/video to-web (copy to apps/web/public/videos/) - pnpm --filter @bmm/video build (both, end-to-end) `to-web` is the script name because `publish` collides with pnpm's built-in npm-publish command which refused to run with an unclean tree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:57:08 +02:00
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
import { C } from '../lib/colors';
import { rand, clampLerp, easeInOut, softSpring } from '../lib/easings';
import { BEAT } from '../HeroVideo';
// Beat 2 — the wow moment.
//
// The prompt words detonate into ~30 particle dots. Each particle has
// (a) a random scatter velocity that decays, (b) a magnetic pull toward
// a designated target slot on the SERVER SCHEMATIC that materialises in
// the centre. As particles arrive, the schematic strokes itself on
// line-by-line. A scan-line sweeps over the formed server right before
// it settles.
const PARTICLE_COUNT = 36;
// Target slots on the server schematic — points on the rectangle's
// perimeter plus internal "row" markers. Particles slot into these.
const SERVER_W = 460;
const SERVER_H = 300;
const CX = 960;
const CY = 540;
function targetSlot(i: number) {
const N = PARTICLE_COUNT;
// Distribute slots: half on the perimeter, half on internal "tool rows".
if (i < N / 2) {
// perimeter — walk around the rectangle
const t = i / (N / 2);
const perim = 2 * (SERVER_W + SERVER_H);
const d = t * perim;
const left = CX - SERVER_W / 2;
const top = CY - SERVER_H / 2;
let px = left;
let py = top;
if (d < SERVER_W) {
px = left + d;
py = top;
} else if (d < SERVER_W + SERVER_H) {
px = left + SERVER_W;
py = top + (d - SERVER_W);
} else if (d < 2 * SERVER_W + SERVER_H) {
px = left + SERVER_W - (d - SERVER_W - SERVER_H);
py = top + SERVER_H;
} else {
px = left;
py = top + SERVER_H - (d - 2 * SERVER_W - SERVER_H);
}
return { x: px, y: py };
}
// Internal tool rows — three horizontal lines inside the server
const j = i - N / 2;
const row = j % 3;
const col = Math.floor(j / 3) / (N / 2 / 3 - 1);
const rowY = CY - 60 + row * 60;
const rowX = CX - SERVER_W / 2 + 30 + col * (SERVER_W - 60);
return { x: rowX, y: rowY };
}
export function TransformScene() {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Local frame within the beat — frame 0 is the start of Transform.
const local = frame - BEAT.transform.in; // 0..110
const total = BEAT.transform.out - BEAT.transform.in;
// Entrance + exit envelopes for the scene as a whole.
const sceneIn = clampLerp(frame, BEAT.transform.in, BEAT.transform.in + 6);
const sceneOut = 1 - clampLerp(frame, BEAT.transform.out - 8, BEAT.transform.out);
const sceneAlpha = Math.min(sceneIn, sceneOut);
// Schematic stroke-on: 0→1 between frames 35..65 of the beat.
const strokeT = easeInOut(clampLerp(local, 30, 70));
// Port pulse readiness — only after schematic strokes complete.
const portPulse = clampLerp(local, 70, 95);
// Scan-line sweep — diagonal gradient passes once across the formed server
const scanT = clampLerp(local, 70, 100);
return (
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
{/* Particles */}
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
// Source: prompt word approximate position spread across the line
const wordIndex = i % 4;
const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30;
const wordY = 540 + rand(i * 3.71) * 20 - 10;
// Target slot on server
const slot = targetSlot(i);
// Scatter velocity — frames 0..25 the particle drifts outward;
// then frames 25..60 it pulls toward the target with spring.
const vx = (rand(i * 1.31) - 0.5) * 600;
const vy = (rand(i * 2.71) - 0.5) * 400;
// Phase 1: explosion 0..25
const explode = clampLerp(local, 0, 25);
// Phase 2: magnetic pull 25..60
const pull = softSpring(frame, fps, BEAT.transform.in + 25, 36);
// Position is: (wordPos) + (vx,vy * explode) lerped toward target by pull
const driftX = wordX + vx * explode * (1 - pull);
const driftY = wordY + vy * explode * (1 - pull);
const x = driftX + (slot.x - driftX) * pull;
const y = driftY + (slot.y - driftY) * pull;
// Size grows as particles "lock in"
const r = interpolate(pull, [0, 1], [2.5, 1.8]);
// Color shifts from white → indigo as they lock to schematic
const color = pull > 0.6 ? C.accent : C.fg;
// Fade in fast at the start so the explosion is visible
const alpha = clampLerp(local, 0, 4);
// Slight fade-out near end as the schematic takes visual primacy
const fadeOut = 1 - clampLerp(local, 85, 105) * 0.3;
return (
<circle
key={i}
cx={x}
cy={y}
r={r}
fill={color}
opacity={alpha * fadeOut * 0.95}
style={{
filter: pull > 0.5 ? `drop-shadow(0 0 4px ${C.accentGlow})` : undefined,
}}
/>
);
})}
</svg>
{/* Server schematic — strokes on as particles arrive */}
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<defs>
<linearGradient id="scanline" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset={`${Math.max(0, scanT * 100 - 8)}%`} stopColor={C.accent} stopOpacity="0" />
<stop offset={`${scanT * 100}%`} stopColor={C.accent} stopOpacity="0.85" />
<stop offset={`${Math.min(100, scanT * 100 + 8)}%`} stopColor={C.accent} stopOpacity="0" />
</linearGradient>
</defs>
{/* Outer rectangle — stroke draws perimeter from top-left clockwise */}
<rect
x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2}
width={SERVER_W}
height={SERVER_H}
rx={6}
fill="none"
stroke={C.accent}
strokeWidth={2}
strokeDasharray={2 * (SERVER_W + SERVER_H)}
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
opacity={0.9}
/>
{/* Three internal "tool" rows — appear after perimeter completes */}
{[0, 1, 2].map((r) => {
const rowAlpha = clampLerp(local, 60 + r * 4, 72 + r * 4);
const y = CY - 60 + r * 60;
return (
<g key={r} opacity={rowAlpha}>
<line
x1={CX - SERVER_W / 2 + 24}
y1={y}
x2={CX + SERVER_W / 2 - 24}
y2={y}
stroke={C.borderStrong}
strokeWidth={1}
/>
<circle cx={CX - SERVER_W / 2 + 30} cy={y} r={3} fill={C.accent} opacity={0.9} />
</g>
);
})}
{/* Port dots — 4 each side, pulse once schematic locks in */}
{[-1, 1].map((side) =>
[-1, 0, 1].map((off) => (
<circle
key={`${side}-${off}`}
cx={CX + (SERVER_W / 2) * side}
cy={CY + off * 60}
r={4}
fill={C.accent}
opacity={portPulse * (0.6 + 0.4 * Math.sin(local * 0.3 + off))}
style={{ filter: `drop-shadow(0 0 6px ${C.accentGlow})` }}
/>
)),
)}
{/* Scan-line sweep — diagonal pass after stroke completes */}
{scanT > 0 && scanT < 1 && (
<rect
x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2}
width={SERVER_W}
height={SERVER_H}
rx={6}
fill="url(#scanline)"
opacity={0.55}
/>
)}
</svg>
{/* Corner labels — typographic detail that sells "this is a real server" */}
<CornerLabel x={CX - SERVER_W / 2 - 8} y={CY - SERVER_H / 2 - 28} text="mcp-notion" appearAt={local} delay={62} />
<CornerLabel x={CX + SERVER_W / 2 + 8} y={CY - SERVER_H / 2 - 28} text="OAuth 2.1" appearAt={local} delay={68} align="right" />
<CornerLabel x={CX - SERVER_W / 2 - 8} y={CY + SERVER_H / 2 + 18} text="search_pages" appearAt={local} delay={72} />
<CornerLabel x={CX + SERVER_W / 2 + 8} y={CY + SERVER_H / 2 + 18} text="get_page_content" appearAt={local} delay={76} align="right" />
</div>
);
}
function CornerLabel({
x,
y,
text,
appearAt,
delay,
align = 'left',
}: {
x: number;
y: number;
text: string;
appearAt: number;
delay: number;
align?: 'left' | 'right';
}) {
const t = clampLerp(appearAt, delay, delay + 8);
return (
<div
style={{
position: 'absolute',
left: align === 'left' ? x : undefined,
right: align === 'right' ? 1920 - x : undefined,
top: y,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
letterSpacing: '0.06em',
color: C.fgMuted,
opacity: t,
transform: `translateY(${(1 - t) * 4}px)`,
}}
>
{text}
</div>
);
}