feat(web): Remotion hero video — Section 2 (prompt → server → connect)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m13s

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>
This commit is contained in:
Marco Sadjadi 2026-05-27 10:57:08 +02:00
parent 591a1cb575
commit fd147f9998
18 changed files with 2483 additions and 1 deletions

View File

@ -145,12 +145,40 @@ export default function Landing() {
{/* How it works */}
<section id="how" className="border-b border-[--color-border] py-14 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-12 max-w-2xl">
<div className="mb-10 max-w-2xl">
<h2 className="text-[28px] font-semibold tracking-tight">How it works</h2>
<p className="mt-2 text-[14px] text-[--color-fg-muted]">
Three steps. No JSON to write, no Docker to manage.
</p>
</div>
{/* Hero video visualises the three beats: prompt transform
live server connected to client. autoplay-muted-loop satisfies
every mobile browser autoplay policy. WebM is offered first
(smaller file on Chrome/Firefox), MP4 is the Safari fallback.
motion-reduce:hidden swaps in a static poster for users with
prefers-reduced-motion Tailwind ships that variant by default. */}
<div className="relative mb-12 aspect-video overflow-hidden rounded-lg border border-[--color-border-strong] bg-black">
<video
autoPlay
muted
loop
playsInline
poster="/videos/hero-poster.jpg"
className="size-full object-cover motion-reduce:hidden"
aria-label="Animation: a natural-language prompt becomes a live MCP server and connects to Claude Desktop"
>
<source src="/videos/hero.webm" type="video/webm" />
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
{/* prefers-reduced-motion fallback */}
<img
src="/videos/hero-poster.jpg"
alt="Prompt becoming a live MCP server"
className="hidden size-full object-cover motion-reduce:block"
/>
</div>
<div className="grid gap-6 md:grid-cols-3">
{[
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Binary file not shown.

1559
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
packages:
- "apps/*"
- "packages/*"
- "remotion"

24
remotion/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "@bmm/video",
"version": "0.1.0",
"private": true,
"scripts": {
"studio": "remotion studio src/index.ts",
"render:mp4": "remotion render src/index.ts HeroVideo out/hero.mp4 --codec h264 --crf 28 --pixel-format yuv420p",
"render:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32",
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 60 --image-format jpeg --jpeg-quality 85",
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
"to-web": "node scripts/publish-to-web.mjs",
"build": "pnpm render:all && pnpm to-web"
},
"dependencies": {
"@remotion/cli": "4.0.220",
"react": "19.0.0",
"react-dom": "19.0.0",
"remotion": "4.0.220"
},
"devDependencies": {
"@types/react": "19.0.2",
"typescript": "5.7.2"
}
}

View File

@ -0,0 +1,10 @@
import { Config } from '@remotion/cli/config';
// H.264 main profile + yuv420p so the MP4 plays on Safari, iOS and every
// stock <video> tag. Concurrency = 1 keeps the renderer deterministic and
// is fine for an 8s clip — we trade wall-clock for predictability.
Config.setVideoImageFormat('jpeg');
Config.setCodec('h264');
Config.setPixelFormat('yuv420p');
Config.setConcurrency(1);
Config.setOverwriteOutput(true);

View File

@ -0,0 +1,39 @@
// Copies rendered artifacts from remotion/out/ into apps/web/public/videos/
// where the marketing page expects them. Run after `pnpm render:all`.
//
// Kept as a plain mjs script (no deps) so it works without installing
// anything in the Web app workspace.
import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, '..', 'out');
const DEST = resolve(__dirname, '..', '..', 'apps', 'web', 'public', 'videos');
const FILES = [
{ name: 'hero.mp4', maxBytes: 3 * 1024 * 1024 },
{ name: 'hero.webm', maxBytes: 3 * 1024 * 1024 },
{ name: 'hero-poster.jpg', maxBytes: 250 * 1024 },
];
mkdirSync(DEST, { recursive: true });
let missing = [];
for (const f of FILES) {
const from = resolve(SRC, f.name);
if (!existsSync(from)) {
missing.push(f.name);
continue;
}
const size = statSync(from).size;
copyFileSync(from, resolve(DEST, f.name));
const status = size > f.maxBytes ? '⚠ OVER LIMIT' : '✓';
console.log(`${status} ${f.name} ${(size / 1024).toFixed(1)} KiB (limit ${(f.maxBytes / 1024).toFixed(0)} KiB)`);
}
if (missing.length) {
console.error(`\nMissing: ${missing.join(', ')} — run \`pnpm render:all\` first.`);
process.exit(1);
}

116
remotion/src/HeroVideo.tsx Normal file
View File

@ -0,0 +1,116 @@
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
import { C } from './lib/colors';
import { PromptScene } from './scenes/PromptScene';
import { TransformScene } from './scenes/TransformScene';
import { ServerScene } from './scenes/ServerScene';
export const HERO_FPS = 30;
export const HERO_DURATION_FRAMES = 240; // 8s
// Scene timing — frame ranges, inclusive on start, exclusive on end.
// Beats overlap intentionally at the edges so transitions cross-fade
// rather than hard-cut. The last 12 frames fade the whole canvas to
// black so the loop seam disappears (frame 0 starts equally dark).
export const BEAT = {
prompt: { in: 0, out: 70 },
transform: { in: 55, out: 165 },
server: { in: 150, out: 240 },
} as const;
export function HeroVideo() {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Loop-clean: ramp opacity to 0 over the last 12 frames so frame 239 ≈
// frame 0 (both essentially-black). Browser <video loop> will jump back
// and the seam is invisible.
const loopFade = interpolate(frame, [HERO_DURATION_FRAMES - 12, HERO_DURATION_FRAMES - 1], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}>
{/* Subtle dotted grid set design vocabulary, never moves. Drawn
once at the bottom and every scene paints on top. */}
<DottedGrid />
<AbsoluteFill style={{ opacity: loopFade }}>
{frame < BEAT.prompt.out && <PromptScene />}
{frame >= BEAT.transform.in && frame < BEAT.transform.out && <TransformScene />}
{frame >= BEAT.server.in && <ServerScene fps={fps} />}
</AbsoluteFill>
{/* Cursor visible at start and end, the only motif that bridges the
loop. Hidden during Transform/Server because the camera focus is
elsewhere. */}
<CursorMotif frame={frame} />
</AbsoluteFill>
);
}
function DottedGrid() {
// 32px dot grid, fading toward the edges. Drawn with a single radial
// mask + repeating dot pattern. Static; renders into PNG quickly.
const dotSize = 1.5;
const gap = 48;
const dots = [];
for (let y = 0; y < 1080 + gap; y += gap) {
for (let x = 0; x < 1920 + gap; x += gap) {
dots.push(
<circle key={`${x}-${y}`} cx={x} cy={y} r={dotSize} fill={C.borderStrong} opacity={0.5} />,
);
}
}
return (
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0 }}
// Radial gradient mask: dots strongest center, fading at edges
>
<defs>
<radialGradient id="vignette" cx="50%" cy="50%" r="60%">
<stop offset="0%" stopColor="white" stopOpacity="1" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</radialGradient>
<mask id="vignetteMask">
<rect width="1920" height="1080" fill="url(#vignette)" />
</mask>
</defs>
<g mask="url(#vignetteMask)">{dots}</g>
</svg>
);
}
function CursorMotif({ frame }: { frame: number }) {
// Beat 1: cursor blinks at the right edge of the typed prompt. We
// place a generic blinking caret in the bottom-third area so the loop
// ends with an invitation to type again.
const showStart = frame < BEAT.transform.in;
const showEnd = frame >= HERO_DURATION_FRAMES - 24;
if (!showStart && !showEnd) return null;
// 0.5Hz blink
const blink = Math.floor(frame / 15) % 2 === 0;
// Start-position cursor lives at the end of the typed prompt; end-position
// cursor lives in the same place to set up the loop.
const x = 960 + 220; // anchored after the prompt baseline
const y = 540;
return (
<div
style={{
position: 'absolute',
left: x,
top: y - 24,
width: 4,
height: 48,
backgroundColor: C.accent,
opacity: blink ? 0.9 : 0,
borderRadius: 1,
boxShadow: `0 0 12px ${C.accentGlow}`,
transition: 'opacity 0.05s',
}}
/>
);
}

15
remotion/src/Root.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Composition } from 'remotion';
import { HeroVideo, HERO_DURATION_FRAMES, HERO_FPS } from './HeroVideo';
export function RemotionRoot() {
return (
<Composition
id="HeroVideo"
component={HeroVideo}
durationInFrames={HERO_DURATION_FRAMES}
fps={HERO_FPS}
width={1920}
height={1080}
/>
);
}

4
remotion/src/index.ts Normal file
View File

@ -0,0 +1,4 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);

View File

@ -0,0 +1,18 @@
// Mirrors apps/web/app/globals.css. Hard-coded rather than imported because
// Remotion compiles in isolation — keeping the palette in one TS const lets
// the renderer be self-contained.
export const C = {
bg: '#0a0a0b',
bgElevated: '#111114',
bgSubtle: '#16161a',
fg: '#fafafa',
fgMuted: '#a1a1aa',
fgSubtle: '#71717a',
border: '#1f1f22',
borderStrong: '#2a2a2e',
accent: '#6366f1',
accentDim: '#4f46e5',
accentGlow: 'rgba(99, 102, 241, 0.35)',
success: '#22c55e',
successDim: '#16a34a',
} as const;

View File

@ -0,0 +1,41 @@
import { spring, interpolate } from 'remotion';
// Snappy spring — overshoots slightly, settles fast. For entrance "pops".
export function springIn(frame: number, fps: number, delay = 0) {
return spring({
frame: frame - delay,
fps,
config: { damping: 12, mass: 0.45, stiffness: 110 },
durationInFrames: 24,
});
}
// Soft spring — no overshoot, gentle. For settling animations.
export function softSpring(frame: number, fps: number, delay = 0, durationInFrames = 30) {
return spring({
frame: frame - delay,
fps,
config: { damping: 22, mass: 1, stiffness: 80 },
durationInFrames,
});
}
// Linear interpolation clamped to [0, 1] over a frame window.
export function clampLerp(frame: number, start: number, end: number) {
return interpolate(frame, [start, end], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
}
// Eased-in-out for sweeps. Cubic.
export function easeInOut(t: number) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// Deterministic pseudo-random — particles need stable positions across the
// renderer's stateless per-frame calls. Linear congruential, seeded by index.
export function rand(seed: number) {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}

View File

@ -0,0 +1,103 @@
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, clampLerp } from '../lib/easings';
// "Search our Notion workspace" — short, recognisable, fits one line at the
// monospace size we're using. Each word springs in from below with a brief
// indigo under-glow that fades as it settles.
const PROMPT_WORDS = ['Search', 'our', 'Notion', 'workspace'];
const WORD_STAGGER = 9; // frames between each word's entrance
export function PromptScene() {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Whole scene fades out after frame 55 so it dissolves into Transform.
const sceneOut = interpolate(frame, [55, 70], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: sceneOut,
}}
>
{/* prompt.txt label — sits above the prompt as a file tag */}
<div
style={{
position: 'absolute',
left: '50%',
top: 460,
transform: 'translateX(-50%)',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: C.fgSubtle,
opacity: clampLerp(frame, 0, 8),
}}
>
prompt.txt
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 56,
fontWeight: 600,
color: C.fg,
letterSpacing: '-0.01em',
display: 'flex',
gap: '18px',
}}
>
{PROMPT_WORDS.map((word, i) => {
const delay = 6 + i * WORD_STAGGER;
const t = springIn(frame, fps, delay);
// Translate from +28px below, settling to 0. Slight rotate adds
// mechanical-feel, undone as it settles.
const y = interpolate(t, [0, 1], [28, 0]);
const rotate = interpolate(t, [0, 1], [4, 0]);
// Brief under-glow: rises with entrance, fades 12 frames later.
const glowEnter = clampLerp(frame, delay, delay + 6);
const glowFade = 1 - clampLerp(frame, delay + 8, delay + 22);
const glowOpacity = Math.min(glowEnter, glowFade);
return (
<span
key={word}
style={{
display: 'inline-block',
transform: `translateY(${y}px) rotate(${rotate}deg)`,
opacity: t,
position: 'relative',
}}
>
{word}
<span
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: -6,
height: 3,
background: `linear-gradient(90deg, transparent, ${C.accent}, transparent)`,
opacity: glowOpacity,
borderRadius: 2,
filter: `blur(3px)`,
}}
/>
</span>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,255 @@
import { useCurrentFrame, interpolate } from 'remotion';
import { C } from '../lib/colors';
import { clampLerp, easeInOut, softSpring } from '../lib/easings';
import { BEAT } from '../HeroVideo';
// Beat 3 — Living server connected to a client.
//
// The server schematic from Beat 2 stays anchored. A "Claude" client
// panel slides in from the right. A Bézier wire animates between them.
// Three data-pulse dots travel back and forth along the wire. A "200"
// status tag flashes briefly. Live-dot in the server's top-right corner
// pulses through the whole scene.
const CX = 960;
const CY = 540;
const SERVER_W = 460;
const SERVER_H = 300;
// Claude client panel — anchored right of the server
const CLIENT_W = 280;
const CLIENT_H = 200;
const CLIENT_CX = 1500;
const CLIENT_CY = 540;
export function ServerScene({ fps }: { fps: number }) {
const frame = useCurrentFrame();
const local = frame - BEAT.server.in; // 0..90
// Scene fade-in (over previous scene)
const sceneIn = clampLerp(frame, BEAT.server.in, BEAT.server.in + 6);
// Client panel slides in from x=+1920 to its anchor
const clientSlide = softSpring(frame, fps, BEAT.server.in + 4, 30);
const clientX = interpolate(clientSlide, [0, 1], [1920 + CLIENT_W, CLIENT_CX]);
// Wire stroke-on (after client lands)
const wireT = easeInOut(clampLerp(local, 22, 50));
// Data-pulse positions along the wire path — three packets, staggered
const pulse1 = ((local - 40) * 0.025) % 1;
const pulse2 = ((local - 50) * 0.025) % 1;
const pulse3 = ((local - 60) * 0.025) % 1;
// "200 OK" tag — pops once at local frame 52, fades over 18 frames
const okT = clampLerp(local, 52, 60);
const okFade = 1 - clampLerp(local, 70, 84);
const okAlpha = Math.min(okT, okFade);
// Live-dot pulse — pulses at 1Hz
const liveDot = 0.6 + 0.4 * Math.sin(local * 0.21);
// Wire path — Bézier from server right-edge to client left-edge
const wireStart = { x: CX + SERVER_W / 2, y: CY };
const wireEnd = { x: CLIENT_CX - CLIENT_W / 2 + clientX - CLIENT_CX, y: CLIENT_CY };
const ctrl1 = { x: wireStart.x + 80, y: wireStart.y };
const ctrl2 = { x: wireEnd.x - 80, y: wireEnd.y };
const wirePath = `M ${wireStart.x} ${wireStart.y} C ${ctrl1.x} ${ctrl1.y}, ${ctrl2.x} ${ctrl2.y}, ${wireEnd.x} ${wireEnd.y}`;
// Compute pulse position on Bézier — sample the cubic
const pulsePos = (t: number) => {
const tt = Math.max(0, Math.min(1, t));
const u = 1 - tt;
const x =
u * u * u * wireStart.x +
3 * u * u * tt * ctrl1.x +
3 * u * tt * tt * ctrl2.x +
tt * tt * tt * wireEnd.x;
const y =
u * u * u * wireStart.y +
3 * u * u * tt * ctrl1.y +
3 * u * tt * tt * ctrl2.y +
tt * tt * tt * wireEnd.y;
return { x, y };
};
return (
<div style={{ position: 'absolute', inset: 0, opacity: sceneIn }}>
{/* Server schematic — same as Transform end state, holds its position */}
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
<rect
x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2}
width={SERVER_W}
height={SERVER_H}
rx={6}
fill={C.bgElevated}
stroke={C.accent}
strokeWidth={2}
opacity={0.95}
/>
{/* Inner panel — slightly subtler */}
<rect
x={CX - SERVER_W / 2 + 6}
y={CY - SERVER_H / 2 + 6}
width={SERVER_W - 12}
height={SERVER_H - 12}
rx={4}
fill={C.bgSubtle}
opacity={0.6}
/>
{/* Tool rows inside server */}
{[0, 1, 2].map((r) => {
const y = CY - 60 + r * 60;
return (
<g key={r}>
<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>
);
})}
{/* Live dot */}
<circle
cx={CX + SERVER_W / 2 - 14}
cy={CY - SERVER_H / 2 + 14}
r={5}
fill={C.success}
opacity={liveDot}
style={{ filter: `drop-shadow(0 0 6px ${C.success}88)` }}
/>
{/* Wire — drawn with dash-offset progression */}
<path
d={wirePath}
fill="none"
stroke={C.accent}
strokeWidth={2}
strokeOpacity={0.7}
strokeDasharray={400}
strokeDashoffset={(1 - wireT) * 400}
/>
{/* Data pulses traveling on wire — only after wire is fully drawn */}
{wireT > 0.95 &&
[pulse1, pulse2, pulse3].map((p, i) => {
if (p < 0 || p > 1) return null;
const pos = pulsePos(p);
return (
<circle
key={i}
cx={pos.x}
cy={pos.y}
r={4}
fill={C.accent}
opacity={0.95}
style={{ filter: `drop-shadow(0 0 8px ${C.accent})` }}
/>
);
})}
</svg>
{/* Client panel — Claude Desktop style */}
<div
style={{
position: 'absolute',
left: clientX - CLIENT_W / 2,
top: CLIENT_CY - CLIENT_H / 2,
width: CLIENT_W,
height: CLIENT_H,
borderRadius: 8,
backgroundColor: C.bgElevated,
border: `1px solid ${C.borderStrong}`,
padding: '18px 20px',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
letterSpacing: '0.16em',
textTransform: 'uppercase',
color: C.fgSubtle,
marginBottom: 14,
}}
>
claude desktop
</div>
<div style={{ height: 1, background: C.border, marginBottom: 16 }} />
<div
style={{
fontSize: 14,
color: C.fgMuted,
lineHeight: 1.5,
}}
>
search "Q3 strategy"
</div>
<div
style={{
marginTop: 12,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
color: C.fg,
}}
>
3 pages found
</div>
<div
style={{
position: 'absolute',
bottom: 14,
left: 20,
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
color: C.success,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: C.success,
boxShadow: `0 0 6px ${C.success}`,
}}
/>
connected
</div>
</div>
{/* 200 OK tag — flashes above the wire midway through */}
{okAlpha > 0 && (
<div
style={{
position: 'absolute',
left: (wireStart.x + wireEnd.x) / 2 - 30,
top: wireStart.y - 40 - okT * 8,
padding: '3px 10px',
borderRadius: 999,
backgroundColor: 'rgba(34, 197, 94, 0.18)',
border: `1px solid ${C.success}`,
color: C.success,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
opacity: okAlpha,
}}
>
200 OK
</div>
)}
</div>
);
}

View File

@ -0,0 +1,254 @@
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>
);
}

15
remotion/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}