feat(web): Remotion hero video — Section 2 (prompt → server → connect)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m13s
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:
parent
591a1cb575
commit
fd147f9998
@ -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">
|
||||
{[
|
||||
{
|
||||
|
||||
BIN
apps/web/public/videos/hero-poster.jpg
Normal file
BIN
apps/web/public/videos/hero-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
apps/web/public/videos/hero.mp4
Normal file
BIN
apps/web/public/videos/hero.mp4
Normal file
Binary file not shown.
BIN
apps/web/public/videos/hero.webm
Normal file
BIN
apps/web/public/videos/hero.webm
Normal file
Binary file not shown.
1559
pnpm-lock.yaml
generated
1559
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "remotion"
|
||||
|
||||
24
remotion/package.json
Normal file
24
remotion/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
remotion/remotion.config.ts
Normal file
10
remotion/remotion.config.ts
Normal 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);
|
||||
39
remotion/scripts/publish-to-web.mjs
Normal file
39
remotion/scripts/publish-to-web.mjs
Normal 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
116
remotion/src/HeroVideo.tsx
Normal 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
15
remotion/src/Root.tsx
Normal 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
4
remotion/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
18
remotion/src/lib/colors.ts
Normal file
18
remotion/src/lib/colors.ts
Normal 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;
|
||||
41
remotion/src/lib/easings.ts
Normal file
41
remotion/src/lib/easings.ts
Normal 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);
|
||||
}
|
||||
103
remotion/src/scenes/PromptScene.tsx
Normal file
103
remotion/src/scenes/PromptScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
remotion/src/scenes/ServerScene.tsx
Normal file
255
remotion/src/scenes/ServerScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
remotion/src/scenes/TransformScene.tsx
Normal file
254
remotion/src/scenes/TransformScene.tsx
Normal 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
15
remotion/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user