255 lines
8.9 KiB
TypeScript
255 lines
8.9 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|