feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s

Restructures the landing page above-the-fold into two distinct sections:

1. **Hero — left copy + cycling tile, no static stack of three blocks**
   New `<HeroStepRotator>` (Framer Motion client component) shows ONE
   tile centred in the column, cycling prompt.txt → build.log →
   claude_desktop_config.json every 3.5s. Auto-advance pauses on hover
   and exposes a 3-dot tablist so users can jump to any step. The active
   dot grows wide with an accent glow.

   Mouse interaction: spring-smoothed 3D tilt on rotateX/rotateY plus a
   radial glow that translates toward the cursor — both driven by motion
   values, so the transforms stay on the GPU compositor instead of
   re-rendering on every mousemove. `useReducedMotion()` strips the
   tilt + glow translation and collapses the page transition to an
   instant cross-fade (the rotation itself still advances — it's content,
   not decoration).

   Hero padding tightened (py-12/14/16 vs py-14/20/28) so the video
   section below is teased above the fold. New scroll cue ("see it run"
   + animated chevron) sits at the bottom of the hero, anchored to
   #flow.

2. **Flow video — full-width edge-to-edge under the hero (new section)**
   The hero.mp4 / hero.webm pair moves out of the "How it works"
   section into its own #flow section. No max-w wrapper — it spans the
   viewport with `w-full aspect-video`, so on a 1080p monitor the video
   gets the full 1920px width. Adds a subtle radial vignette so the
   black edges blend into the page chrome.

3. **"How it works" — now lean**
   Video removed (it's the flow section now). Just the three textual
   cards as supporting copy.

Adds `framer-motion@11.18.2` to apps/web/package.json. Build passes
typecheck + Next.js production build with no new warnings; LCP path is
untouched since the rotator is client-hydrated after first paint and
Framer Motion is tree-shaken to the components we import.

Note: visitors with `prefers-reduced-motion: reduce` will still see the
video's poster instead of autoplay — Chrome blocks the network fetch
entirely for autoplay media when reduced-motion is set. The flow video
remains visible for the rest, and the step rotator continues to cycle
its content (with instant cross-fade instead of slide+scale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-27 12:05:28 +02:00
parent 22ba23f353
commit e4e437c44c
14 changed files with 1840 additions and 81 deletions

View File

@ -1,6 +1,9 @@
import { HeroStepRotator } from '@/components/hero-step-rotator';
import { JsonLd } from '@/components/json-ld';
import { ParticleHero } from '@/components/particle-hero';
import { StaticCodeBlock } from '@/components/static-code-block';
import { FAQ, faqJsonLd } from '@/lib/seo';
import { ChevronDown } from 'lucide-react';
import Link from 'next/link';
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
@ -83,14 +86,25 @@ const TIERS = [
export default function Landing() {
return (
<>
{/* Hero */}
<section className="relative border-b border-[--color-border]">
<div className="mx-auto grid max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12 md:py-28">
{/* Hero left: copy + CTAs, right: cycling step-rotator tile.
The old layout stacked three static code blocks vertically; the
new layout shows one centered tile that cycles through the same
three artifacts (prompt build.log claude config) with a
mouse-reactive 3D tilt and a step indicator. Shorter overall
so the video section below is teased above the fold. */}
<section className="relative overflow-hidden border-b border-[--color-border]">
{/* WebGL particle field capability-detected client component.
Sits behind the hero content at z-0 with pointer-events:none
so the CTAs above remain fully interactive. The canvas listens
for pointermove on window itself, so the ring still tracks
the cursor through the content above. */}
<ParticleHero />
<div className="relative z-10 mx-auto grid max-w-6xl gap-10 px-6 py-12 sm:py-14 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12 md:py-16">
<div className="min-w-0">
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
v0.1 updated 2026-05-20
</span>
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[56px]">
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]">
Describe your tool.
<br />
We host the server.
@ -115,7 +129,7 @@ export default function Landing() {
Read the docs
</Link>
</div>
<div className="mt-10 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-subtle]">
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-subtle]">
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> OAuth 2.1 + PKCE
</span>
@ -132,13 +146,52 @@ export default function Landing() {
</div>
<div className="relative min-w-0">
<div className="absolute -inset-px rounded-lg border border-[--color-border-strong]" />
<div className="space-y-3">
<StaticCodeBlock label="prompt.txt" code={PROMPT_EXAMPLE} />
<StaticCodeBlock label="build.log" code={OUTPUT_EXAMPLE} />
<StaticCodeBlock label="claude_desktop_config.json" code={INSTALL_SNIPPET} />
<HeroStepRotator />
</div>
</div>
{/* Scroll cue — hints the video section sits directly below. */}
<a
href="#flow"
aria-label="See the flow in action"
className="absolute inset-x-0 bottom-2 z-10 mx-auto flex w-fit items-center gap-1 text-[11px] uppercase tracking-[0.18em] text-[--color-fg-subtle] transition-colors hover:text-[--color-fg-muted]"
>
<span>see it run</span>
<ChevronDown size={12} className="animate-bounce" />
</a>
</section>
{/* Flow video full-width edge-to-edge under the hero. The clip
shows the real flow (prompt server schematic live connection
to Claude Desktop) in three smooth phases. autoplay-muted-loop +
playsInline satisfies every mobile browser autoplay policy; the
`poster` carries first paint while the video decodes. */}
<section
id="flow"
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
>
<div className="relative aspect-video w-full">
<video
autoPlay
muted
loop
playsInline
preload="auto"
poster="/videos/hero-poster.jpg"
className="size-full object-cover"
aria-label="Animation: a 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>
{/* Subtle vignette to integrate edges into the rest of the page */}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(ellipse at center, transparent 60%, rgba(10,10,11,0.55) 100%)',
}}
/>
</div>
</section>
@ -152,33 +205,11 @@ export default function Landing() {
</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>
{/* The same video used to live here; it now has its own
full-width section directly under the hero so it's teased
above the fold and gets edge-to-edge real estate. This
section keeps the three explanatory cards as supporting
copy under the video. */}
<div className="grid gap-6 md:grid-cols-3">
{[
{

View File

@ -0,0 +1,591 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* HeroAnimation full-bleed Canvas 2D piece for the marketing hero.
*
* Four layers (back to front):
* 1. Drifting indigo particle dots (texture)
* 2. Code-block panel with typewriter cycling 3 snippets (centerpiece)
* 3. Mouse-tracking glow ring (interactive, additive)
* 4. Data packets shooting from the panel toward the canvas edges
*
* Hex colors are hardcoded here on purpose Tailwind v4 beta oklch tokens
* don't round-trip cleanly when read from JS, and we want pixel-stable
* brand colors inside the canvas regardless of the design-token layer.
*/
const COLORS = {
bg: '#0a0a0b',
bgElevated: '#111114',
border: '#1f1f22',
borderStrong: '#2a2a2e',
fg: '#fafafa',
fgMuted: '#a1a1aa',
fgSubtle: '#71717a',
accent: '#6366f1',
success: '#22c55e',
} as const;
const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [
[
'// MCP server — auto-generated',
'import { Server } from "@bmm/mcp";',
'',
'const server = new Server({',
' name: "data-bridge",',
' tools: [getRecord, search, write],',
'});',
'',
'await server.listen();',
],
[
'> Generating spec... OK',
'> Static checks OK',
'> Building image OK 17.2s',
'> Deploying container OK',
'> Live at https://...mcp.bmm.io',
],
[
'// claude_desktop_config.json',
'{',
' "mcpServers": {',
' "data-bridge": {',
' "url": "https://data.mcp.bmm.io",',
' "auth": "oauth2"',
' }',
' }',
'}',
],
];
const KEYWORDS = new Set([
'import',
'from',
'const',
'await',
'new',
'return',
'function',
'let',
'var',
'if',
'else',
'export',
'default',
]);
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
seed: number;
}
interface Packet {
x: number;
y: number;
vx: number;
vy: number;
life: number; // 0..1, decreases
maxLife: number;
}
interface HeroAnimationProps {
className?: string;
}
export function HeroAnimation({ className }: HeroAnimationProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const reducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ----- sizing (DPR-aware) -----
let cssW = 0;
let cssH = 0;
const setSize = () => {
const rect = wrap.getBoundingClientRect();
cssW = Math.max(1, Math.floor(rect.width));
cssH = Math.max(1, Math.floor(rect.height));
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
seedParticles();
};
// ----- particles -----
const particles: Particle[] = [];
const PARTICLE_COUNT = 60;
const seedParticles = () => {
particles.length = 0;
for (let i = 0; i < PARTICLE_COUNT; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (0.15 + Math.random() * 0.25) * (reducedMotion ? 0.4 : 1);
particles.push({
x: Math.random() * cssW,
y: Math.random() * cssH,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 0.7 + Math.random() * 1.3,
seed: Math.random() * 1000,
});
}
};
// ----- pointer state -----
// Track in CSS pixels (post setTransform), -1,-1 means "not over".
const pointer = {
raw: { x: -1, y: -1 },
smooth: { x: cssW / 2, y: cssH / 2 },
inside: false,
ringAlpha: 0, // eased
};
const onPointerMove = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pointer.raw.x = e.clientX - rect.left;
pointer.raw.y = e.clientY - rect.top;
pointer.inside = true;
};
const onPointerLeave = () => {
pointer.inside = false;
};
canvas.addEventListener('pointermove', onPointerMove, { passive: true });
canvas.addEventListener('pointerleave', onPointerLeave, { passive: true });
canvas.addEventListener('pointercancel', onPointerLeave, { passive: true });
// ----- packets -----
const packets: Packet[] = [];
let lastPacketSpawn = 0;
// ----- typewriter state -----
// Snippet cycle:
// typing -> holding -> erasing -> pausing -> next snippet
// Timings calibrated so each snippet runs ~4-5s.
const FORWARD_MS = reducedMotion ? 45 : 25;
const ERASE_MS = reducedMotion ? 28 : 15;
const HOLD_MS = 2500;
const PAUSE_MS = 500;
let snippetIndex = 0;
type Phase = 'typing' | 'holding' | 'erasing' | 'pausing';
let phase: Phase = 'typing';
let phaseStart = 0;
// Characters revealed across the FULL snippet text including newlines.
const fullText = (i: number): string =>
(SNIPPETS[i] ?? []).join('\n');
// ----- animation loop -----
let rafId = 0;
let startTime = performance.now();
let lastFrame = startTime;
let visible = !document.hidden;
const onVisibility = () => {
visible = !document.hidden;
if (visible) {
// Reset frame delta so paused time doesn't lurch animation.
lastFrame = performance.now();
rafId = requestAnimationFrame(frame);
}
};
document.addEventListener('visibilitychange', onVisibility);
// ----- drawing helpers -----
const drawParticles = (elapsed: number, dt: number) => {
ctx.save();
ctx.fillStyle = COLORS.accent;
ctx.globalAlpha = 0.2;
const driftScale = reducedMotion ? 0.4 : 1;
for (const p of particles) {
// Cheap pseudo-simplex: layered sines indexed by seed + time.
const t = elapsed * 0.0004;
const nx = Math.sin(t + p.seed) * Math.cos(t * 0.7 + p.seed * 1.3);
const ny = Math.cos(t * 0.9 + p.seed * 0.6) * Math.sin(t * 0.5 + p.seed);
p.x += (p.vx + nx * 0.3) * dt * 0.06 * driftScale;
p.y += (p.vy + ny * 0.3) * dt * 0.06 * driftScale;
// Wrap around edges so density stays uniform.
if (p.x < -4) p.x = cssW + 4;
else if (p.x > cssW + 4) p.x = -4;
if (p.y < -4) p.y = cssH + 4;
else if (p.y > cssH + 4) p.y = -4;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
};
// Returns { x, y, w, h } of the code panel in CSS pixels.
const panelRect = () => {
const w = cssW * 0.8;
// Sized to comfortably fit the longest snippet (~9 lines @ ~18px line-height).
const h = Math.min(cssH * 0.55, 240);
return {
x: (cssW - w) / 2,
y: (cssH - h) / 2,
w,
h,
};
};
const drawPanel = (rect: { x: number; y: number; w: number; h: number }) => {
ctx.save();
ctx.fillStyle = COLORS.bg;
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
const r = 8;
const { x, y, w, h } = rect;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
};
// Tokenize a line for syntax color. Cheap heuristic, not a real parser.
type Token = { text: string; color: string };
const tokenizeLine = (line: string, elapsed: number): Token[] => {
// Comment lines.
if (line.trim().startsWith('//')) {
return [{ text: line, color: COLORS.fgSubtle }];
}
// Log lines: ">" prefix style, flash "OK" green briefly when it appears.
if (line.startsWith('>')) {
const tokens: Token[] = [];
// Find OK occurrences and color them.
const parts = line.split(/(\bOK\b)/g);
for (const part of parts) {
if (part === 'OK') {
// Subtle flash effect: brighter for first ~500ms after fully typed.
const flash = 0.65 + 0.35 * (0.5 + 0.5 * Math.sin(elapsed * 0.005));
tokens.push({
text: part,
color: withAlpha(COLORS.success, flash),
});
} else {
tokens.push({ text: part, color: COLORS.fgMuted });
}
}
return tokens;
}
// Generic JS/JSON: split on word boundaries, color keywords + strings.
const tokens: Token[] = [];
// Match: string literals, words, or anything else.
const re = /("[^"]*"|\b[A-Za-z_$][\w$]*\b|[^"\w]+)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(line)) !== null) {
const t = match[0];
if (t.startsWith('"')) {
tokens.push({ text: t, color: COLORS.fgMuted });
} else if (KEYWORDS.has(t)) {
tokens.push({ text: t, color: COLORS.accent });
} else {
tokens.push({ text: t, color: COLORS.fg });
}
}
return tokens;
};
const drawCode = (
rect: { x: number; y: number; w: number; h: number },
visibleText: string,
elapsed: number,
) => {
ctx.save();
// Clip to panel so text never bleeds over the rounded border.
ctx.beginPath();
ctx.rect(rect.x, rect.y, rect.w, rect.h);
ctx.clip();
const fontSize = 12.5;
const lineHeight = 18;
const padX = 16;
const padY = 14;
ctx.font = `${fontSize}px var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace`;
ctx.textBaseline = 'top';
const lines = visibleText.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const y = rect.y + padY + i * lineHeight;
if (y > rect.y + rect.h - padY) break;
let x = rect.x + padX;
const tokens = tokenizeLine(line, elapsed);
for (const tok of tokens) {
ctx.fillStyle = tok.color;
ctx.fillText(tok.text, x, y);
x += ctx.measureText(tok.text).width;
}
}
// Blinking caret at the end of the visible text.
const lastLine = lines[lines.length - 1] ?? '';
const caretX =
rect.x + padX + ctx.measureText(lastLine).width;
const caretY = rect.y + padY + (lines.length - 1) * lineHeight;
const blink = (elapsed % 1000) < 500;
if (blink && caretY + lineHeight <= rect.y + rect.h - padY + lineHeight) {
ctx.fillStyle = COLORS.accent;
ctx.fillRect(caretX + 1, caretY + 2, 6, fontSize);
}
ctx.restore();
};
const drawRing = (elapsed: number) => {
if (pointer.ringAlpha < 0.005) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const cx = pointer.smooth.x;
const cy = pointer.smooth.y;
const baseRadii: ReadonlyArray<{ r: number; w: number; a: number }> = [
{ r: 60, w: 2.4, a: 0.4 },
{ r: 90, w: 1.6, a: 0.28 },
{ r: 130, w: 1.0, a: 0.18 },
];
for (const band of baseRadii) {
ctx.beginPath();
const segments = 96;
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
// Breathing distortion around the circumference.
const wobble = Math.sin(theta * 3 + elapsed * 0.002) * 4;
const r = band.r + wobble;
const x = cx + Math.cos(theta) * r;
const y = cy + Math.sin(theta) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = withAlpha(
COLORS.accent,
band.a * pointer.ringAlpha,
);
ctx.lineWidth = band.w;
ctx.stroke();
}
ctx.restore();
};
const spawnPacket = (rect: { x: number; y: number; w: number; h: number }) => {
// Pick an edge of the panel.
const edge = Math.floor(Math.random() * 4);
let x = 0;
let y = 0;
let vx = 0;
let vy = 0;
const speed = 0.06 + Math.random() * 0.04; // CSS px / ms
if (edge === 0) {
// top
x = rect.x + Math.random() * rect.w;
y = rect.y;
vx = (Math.random() - 0.5) * 0.02;
vy = -speed;
} else if (edge === 1) {
// right
x = rect.x + rect.w;
y = rect.y + Math.random() * rect.h;
vx = speed;
vy = (Math.random() - 0.5) * 0.02;
} else if (edge === 2) {
// bottom
x = rect.x + Math.random() * rect.w;
y = rect.y + rect.h;
vx = (Math.random() - 0.5) * 0.02;
vy = speed;
} else {
// left
x = rect.x;
y = rect.y + Math.random() * rect.h;
vx = -speed;
vy = (Math.random() - 0.5) * 0.02;
}
// Distance to nearest canvas edge along direction → travel time.
const distX = vx > 0 ? cssW - x : vx < 0 ? x : Infinity;
const distY = vy > 0 ? cssH - y : vy < 0 ? y : Infinity;
const tx = vx !== 0 ? distX / Math.abs(vx) : Infinity;
const ty = vy !== 0 ? distY / Math.abs(vy) : Infinity;
const maxLife = Math.min(tx, ty);
packets.push({ x, y, vx, vy, life: 1, maxLife });
};
const drawPackets = (dt: number) => {
ctx.save();
for (let i = packets.length - 1; i >= 0; i--) {
const p = packets[i];
if (!p) continue;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt / p.maxLife;
if (
p.life <= 0 ||
p.x < -4 ||
p.x > cssW + 4 ||
p.y < -4 ||
p.y > cssH + 4
) {
packets.splice(i, 1);
continue;
}
const alpha = Math.max(0, p.life) * 0.85;
ctx.fillStyle = withAlpha(COLORS.accent, alpha);
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
};
// ----- main frame -----
const frame = (now: number) => {
if (!visible) return; // visibilitychange will restart us
const dt = Math.min(48, now - lastFrame); // cap to avoid lurches
lastFrame = now;
const elapsed = now - startTime;
// Pointer smoothing (EMA). When outside, ease ring alpha to 0 (~400ms).
if (pointer.inside && pointer.raw.x >= 0) {
pointer.smooth.x = 0.82 * pointer.smooth.x + 0.18 * pointer.raw.x;
pointer.smooth.y = 0.82 * pointer.smooth.y + 0.18 * pointer.raw.y;
// Ease alpha to 1 over ~250ms.
pointer.ringAlpha = Math.min(1, pointer.ringAlpha + dt / 250);
} else {
pointer.ringAlpha = Math.max(0, pointer.ringAlpha - dt / 400);
}
// Clear (transparent — wrapper supplies bg color).
ctx.clearRect(0, 0, cssW, cssH);
// Layer 1: particles
drawParticles(elapsed, dt);
// Layer 2: panel + typewriter
const rect = panelRect();
drawPanel(rect);
// Compute visible characters for the current snippet.
const text = fullText(snippetIndex);
const totalChars = text.length;
let visibleChars = 0;
const sincePhase = now - phaseStart;
if (phase === 'typing') {
visibleChars = Math.min(totalChars, Math.floor(sincePhase / FORWARD_MS));
if (visibleChars >= totalChars) {
phase = 'holding';
phaseStart = now;
}
} else if (phase === 'holding') {
visibleChars = totalChars;
if (sincePhase >= HOLD_MS) {
phase = 'erasing';
phaseStart = now;
}
} else if (phase === 'erasing') {
visibleChars = Math.max(
0,
totalChars - Math.floor(sincePhase / ERASE_MS),
);
if (visibleChars <= 0) {
phase = 'pausing';
phaseStart = now;
}
} else {
visibleChars = 0;
if (sincePhase >= PAUSE_MS) {
snippetIndex = (snippetIndex + 1) % SNIPPETS.length;
phase = 'typing';
phaseStart = now;
}
}
drawCode(rect, text.slice(0, visibleChars), elapsed);
// Layer 4 (drawn before ring so the glow sits on top): packets.
// Skipped under reduced-motion.
if (!reducedMotion) {
if (now - lastPacketSpawn > 600 && packets.length < 6) {
spawnPacket(rect);
lastPacketSpawn = now;
}
drawPackets(dt);
}
// Layer 3: glow ring (additive, on top)
drawRing(elapsed);
rafId = requestAnimationFrame(frame);
};
// ----- resize observer -----
const ro = new ResizeObserver(() => {
setSize();
});
ro.observe(wrap);
setSize();
// Kick off.
startTime = performance.now();
lastFrame = startTime;
phaseStart = startTime;
rafId = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(rafId);
ro.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('pointercancel', onPointerLeave);
};
}, []);
return (
<div
ref={wrapRef}
className={
className ??
'relative aspect-square w-full overflow-hidden rounded-lg border border-[--color-border-strong] bg-[--color-bg-elevated]'
}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="block h-full w-full"
/>
</div>
);
}
/**
* Append an alpha channel to a 6-digit hex color.
* `withAlpha('#6366f1', 0.4)` -> 'rgba(99, 102, 241, 0.4)'
*/
function withAlpha(hex: string, alpha: number): string {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@ -0,0 +1,177 @@
'use client';
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from 'framer-motion';
import { useEffect, useRef, useState } from 'react';
interface Step {
label: string; // file-name badge top-left
badge: string; // "01 · Describe" badge top-right
code: string;
}
const STEPS: Step[] = [
{
label: 'prompt.txt',
badge: '01 · Describe',
code: `Create an MCP server that searches our Notion workspace.
Tools: search_pages, get_page_content.
Auth: NOTION_API_KEY.`,
},
{
label: 'build.log',
badge: '02 · Generate',
code: `> Generating spec... OK (2 tools)
> Static checks OK
> Building image bmm-mcp-notion OK 17.2s
> Deploying container OK
> Live at https://notion-x9.mcp.buildmymcpserver.com
> First request: 401 token 200 OK`,
},
{
label: 'claude_desktop_config.json',
badge: '03 · Connect',
code: `{
"mcpServers": {
"notion": {
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}`,
},
];
const AUTO_MS = 3500;
/**
* Hero step rotator single centered tile cycling through three states.
*
* Replaces the old static stack of three code blocks. Auto-advances every
* 3.5s, pauses on hover, jumps instantly when the user clicks a dot.
*
* Mouse interaction: the tile reacts with a subtle 3D tilt driven by spring-
* smoothed motion values, plus a radial glow that translates toward the
* cursor. Both are disabled when `prefers-reduced-motion: reduce` is set
* the rotation is content-essential and still advances, but the transition
* collapses to an instant cross-fade and the tilt/glow are stripped out.
*/
export function HeroStepRotator() {
const [step, setStep] = useState(0);
const [paused, setPaused] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
// Mouse motion values for tilt + glow translation. `mx` and `my` are
// raw cursor offsets (-1..1); the `*Spring` versions add the smoothing
// so the tilt doesn't jitter on every mousemove event.
const mx = useMotionValue(0);
const my = useMotionValue(0);
const mxSpring = useSpring(mx, { damping: 22, stiffness: 200 });
const mySpring = useSpring(my, { damping: 22, stiffness: 200 });
const rotateX = useTransform(mySpring, [-1, 1], reduced ? [0, 0] : [6, -6]);
const rotateY = useTransform(mxSpring, [-1, 1], reduced ? [0, 0] : [-8, 8]);
// Glow translates rather than re-rendering background-position — keeps
// it on the GPU compositor instead of pegging the main thread.
const glowDx = useTransform(mxSpring, [-1, 1], reduced ? [0, 0] : [-140, 140]);
const glowDy = useTransform(mySpring, [-1, 1], reduced ? [0, 0] : [-110, 110]);
useEffect(() => {
if (paused) return;
const t = setTimeout(() => setStep((s) => (s + 1) % STEPS.length), AUTO_MS);
return () => clearTimeout(t);
}, [step, paused]);
function onMove(e: React.MouseEvent<HTMLDivElement>) {
const r = containerRef.current?.getBoundingClientRect();
if (!r) return;
const x = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1));
const y = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1));
mx.set(x);
my.set(y);
}
function onEnter() {
setPaused(true);
}
function onLeave() {
setPaused(false);
mx.set(0);
my.set(0);
}
const current = STEPS[step]!;
return (
<div className="flex flex-col items-center gap-5">
<div
ref={containerRef}
className="relative w-full max-w-md"
style={{ perspective: 1200 }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
onMouseMove={onMove}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -8 }}
transition={{ duration: reduced ? 0.15 : 0.5, ease: [0.16, 1, 0.3, 1] }}
style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}
className="relative overflow-hidden rounded-lg border border-[--color-border-strong] bg-[--color-bg-elevated] shadow-2xl shadow-black/50"
>
{/* Cursor-following glow — sits behind the content, additive. */}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(circle 260px at center, rgba(99,102,241,0.32), transparent 70%)',
x: glowDx,
y: glowDy,
}}
/>
<div className="relative flex items-center justify-between border-b border-[--color-border] px-4 py-2.5">
<span className="mono text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
{current.label}
</span>
<span className="mono text-[10.5px] tracking-[0.16em] text-[--color-accent]">
{current.badge}
</span>
</div>
<pre className="mono relative overflow-x-auto px-4 py-4 text-[12.5px] leading-relaxed text-[--color-fg]">
<code>{current.code}</code>
</pre>
</motion.div>
</AnimatePresence>
</div>
{/* Step indicator accent dot is wider + glows so the active step
reads at a glance. Buttons stay clickable so users can jump. */}
<div className="flex items-center gap-2" role="tablist" aria-label="Hero flow steps">
{STEPS.map((s, i) => (
<button
key={s.badge}
type="button"
role="tab"
aria-selected={i === step}
aria-label={`Jump to ${s.badge}`}
onClick={() => setStep(i)}
className={`h-1.5 rounded-full transition-all duration-300 ${
i === step
? 'w-9 bg-[--color-accent] shadow-[0_0_10px_rgba(99,102,241,0.65)]'
: 'w-1.5 bg-[--color-border-strong] hover:bg-[--color-fg-subtle]'
}`}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,322 @@
'use client';
/**
* ParticleField GPGPU particle simulation backing the marketing hero.
*
* Lean Three.js, no @react-three/fiber. Renders a 256×256 (or 128×128
* on lower-end devices) float texture of positions, ping-ponged each
* frame through a sim shader, then drawn as gl_Points with an
* anti-aliased disc SDF and additive blending.
*
* Callers MUST gate this behind the capability checks in `index.tsx`
* this component assumes WebGL2 + float-render-target support exists
* and will throw if they don't.
*/
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import {
initFragment,
renderFragment,
renderVertex,
simFragment,
simVertex,
} from './shaders';
export interface ParticleFieldProps {
/** Sqrt of particle count. 256 → 65,536 particles, 128 → 16,384. */
textureSize: 128 | 256;
/**
* Global multiplier on drift + ring-push velocity. 1.0 default; 0.5
* for reduced-motion users. Pointer position is NOT scaled the ring
* still tracks the cursor at full fidelity, only the ambient motion
* and the gradient-push are damped.
*/
motionScale?: number;
}
export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// ----- Renderer ---------------------------------------------------
// alpha:true so the hero gradient/border behind the canvas shows
// through where particles are sparse. premultipliedAlpha pairs with
// the premultiplied output of the render fragment shader.
const canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
container.appendChild(canvas);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: false,
alpha: true,
premultipliedAlpha: true,
powerPreference: 'high-performance',
});
renderer.setClearColor(0x000000, 0);
// Clamp DPR — going above 2 on a 65k-particle field burns laptop GPUs
// with negligible visual gain.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
const initialRect = container.getBoundingClientRect();
renderer.setSize(Math.max(1, initialRect.width), Math.max(1, initialRect.height), false);
// ----- Float-texture support check --------------------------------
// EXT_color_buffer_float is required to render INTO a float target
// on WebGL2. Without it, ping-pong won't work — bail out and let the
// wrapper fall back to the static gradient.
const gl = renderer.getContext() as WebGL2RenderingContext;
const floatExt = gl.getExtension('EXT_color_buffer_float');
if (!floatExt) {
// Tear down what we built and signal failure via the canvas
// remaining empty. The wrapper checks this synchronously before
// we even mount, so this is a belt-and-braces guard.
canvas.remove();
renderer.dispose();
return;
}
// ----- Scenes & camera --------------------------------------------
// Two scenes: one for the simulation pass (fullscreen quad), one
// for the actual particle render. Both use the same OrthographicCamera
// because everything is already in clip space.
const simScene = new THREE.Scene();
const renderScene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// ----- Ping-pong render targets ------------------------------------
const rtParams: THREE.RenderTargetOptions = {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
format: THREE.RGBAFormat,
type: THREE.FloatType,
depthBuffer: false,
stencilBuffer: false,
generateMipmaps: false,
};
let rtA = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
let rtB = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
// ----- Init pass: seed both targets with the starting field -------
const initMaterial = new THREE.ShaderMaterial({
vertexShader: simVertex,
fragmentShader: initFragment,
});
const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMaterial);
simScene.add(fsQuad);
renderer.setRenderTarget(rtA);
renderer.render(simScene, camera);
renderer.setRenderTarget(rtB);
renderer.render(simScene, camera);
renderer.setRenderTarget(null);
// Swap in the actual sim material on the same quad.
const simUniforms = {
uPrev: { value: rtA.texture },
uTime: { value: 0 },
uDelta: { value: 1 / 60 },
uRingPos: { value: new THREE.Vector2(0, 0) },
uRingRadius: { value: 0.22 },
uRingWidth: { value: 0.05 },
uRingActive: { value: 0 },
uMotionScale: { value: motionScale },
};
const simMaterial = new THREE.ShaderMaterial({
vertexShader: simVertex,
fragmentShader: simFragment,
uniforms: simUniforms,
});
fsQuad.material = simMaterial;
initMaterial.dispose();
// ----- Particle geometry: one vertex per texel --------------------
const count = textureSize * textureSize;
const indexUvs = new Float32Array(count * 2);
const positionsAttr = new Float32Array(count * 3); // unused but required
for (let y = 0; y < textureSize; y++) {
for (let x = 0; x < textureSize; x++) {
const i = y * textureSize + x;
// Sample texels at their centers, not corners.
indexUvs[i * 2 + 0] = (x + 0.5) / textureSize;
indexUvs[i * 2 + 1] = (y + 0.5) / textureSize;
}
}
const particleGeo = new THREE.BufferGeometry();
particleGeo.setAttribute('position', new THREE.BufferAttribute(positionsAttr, 3));
particleGeo.setAttribute('aIndexUv', new THREE.BufferAttribute(indexUvs, 2));
// Tell Three.js never to frustum-cull this — positions live in
// the texture, not the bounding box of the buffer geometry.
particleGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 10);
// Brand colors — read from --color-accent (#6366f1) and
// --color-success (#22c55e). Kept as constants here rather than
// reading from CSS variables: those resolve to oklch in modern
// Tailwind builds, which needs parsing. Hardcode the hex values
// the design system already commits to.
const colorCalm = new THREE.Color('#6366f1');
const colorHot = new THREE.Color('#22c55e');
const renderUniforms = {
uPositions: { value: rtB.texture },
uPointSize: { value: textureSize === 256 ? 1.8 : 2.4 },
uDpr: { value: dpr },
uColorCalm: { value: colorCalm },
uColorHot: { value: colorHot },
uBaseAlpha: { value: 0.42 },
};
const particleMat = new THREE.ShaderMaterial({
vertexShader: renderVertex,
fragmentShader: renderFragment,
uniforms: renderUniforms,
transparent: true,
depthTest: false,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const particles = new THREE.Points(particleGeo, particleMat);
renderScene.add(particles);
// ----- Pointer tracking ------------------------------------------
// Raw target (last pointer event), smoothed via EMA into the
// uniform each frame so the ring tracks fluidly even if events
// are sparse (touch / pen / throttled mouse).
const target = new THREE.Vector2(0, 0);
const smoothed = new THREE.Vector2(0, 0);
let hasPointer = false;
const updatePointerFromClient = (clientX: number, clientY: number) => {
const rect = container.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
// Flip Y so up is positive — matches clip space.
const y = -(((clientY - rect.top) / rect.height) * 2 - 1);
target.set(x, y);
hasPointer = true;
};
const onPointerMove = (e: PointerEvent) => {
updatePointerFromClient(e.clientX, e.clientY);
};
const onPointerLeave = () => {
hasPointer = false;
};
// Listen on window so the ring tracks even when the cursor is over
// the codeblocks/CTAs that sit above the canvas. The container is
// pointer-events:none-friendly because we read clientX/clientY.
window.addEventListener('pointermove', onPointerMove, { passive: true });
container.addEventListener('pointerleave', onPointerLeave, { passive: true });
// ----- Resize handling -------------------------------------------
const onResize = () => {
const rect = container.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
renderer.setSize(rect.width, rect.height, false);
};
const ro = new ResizeObserver(onResize);
ro.observe(container);
// ----- Animation loop --------------------------------------------
const clock = new THREE.Clock();
let raf = 0;
let running = true;
// Defer the first frame to idle to keep LCP clean — the hero text
// is the LCP element and must paint before we start eating GPU.
const startLoop = () => {
const tick = () => {
if (!running) return;
raf = requestAnimationFrame(tick);
const delta = Math.min(clock.getDelta(), 1 / 30); // tab-switch guard
const t = clock.elapsedTime;
// Smooth the pointer position (EMA, alpha=0.15).
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
// Fade ring in/out when the pointer enters/leaves.
const targetActive = hasPointer ? 1 : 0;
simUniforms.uRingActive.value =
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
simUniforms.uTime.value = t;
simUniforms.uDelta.value = delta;
simUniforms.uRingPos.value.copy(smoothed);
// Sim pass: read rtA, write rtB.
simUniforms.uPrev.value = rtA.texture;
renderer.setRenderTarget(rtB);
renderer.render(simScene, camera);
renderer.setRenderTarget(null);
// Render pass: draw particles sampling rtB.
renderUniforms.uPositions.value = rtB.texture;
renderer.render(renderScene, camera);
// Swap.
const tmp = rtA;
rtA = rtB;
rtB = tmp;
};
tick();
};
let idleHandle: number | null = null;
const w = window as Window & {
requestIdleCallback?: (cb: IdleRequestCallback) => number;
cancelIdleCallback?: (h: number) => void;
};
if (typeof w.requestIdleCallback === 'function') {
idleHandle = w.requestIdleCallback(() => startLoop());
} else {
idleHandle = window.setTimeout(startLoop, 80);
}
// ----- Cleanup ----------------------------------------------------
// Three.js leaks GPU memory aggressively if we skip any of this.
return () => {
running = false;
if (raf) cancelAnimationFrame(raf);
if (idleHandle !== null) {
if (typeof w.cancelIdleCallback === 'function') {
w.cancelIdleCallback(idleHandle);
} else {
window.clearTimeout(idleHandle);
}
}
window.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerleave', onPointerLeave);
ro.disconnect();
particleGeo.dispose();
particleMat.dispose();
simMaterial.dispose();
(fsQuad.geometry as THREE.BufferGeometry).dispose();
rtA.dispose();
rtB.dispose();
renderer.dispose();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
}, [textureSize, motionScale]);
// The container is the surface that receives pointer events.
// Visually transparent — the canvas it owns paints the field.
return (
<div
ref={containerRef}
aria-hidden="true"
className="absolute inset-0 size-full"
/>
);
}

View File

@ -0,0 +1,163 @@
'use client';
/**
* ParticleHero public entry to the WebGL particle background.
*
* Responsibilities (kept OUT of ParticleField so that component can
* assume happy-path WebGL2):
*
* 1. WebGL2 missing static gradient.
* 2. Mobile / low-power profile either 16k particles or skip.
* 3. prefers-reduced-motion still WebGL + cursor tracking, but
* capped at 16k particles with halved drift and halved push
* velocity. The ring still follows the cursor at full fidelity
* because that's the interaction the user explicitly wants; we
* only damp the *ambient* motion so the field reads as calm.
* 4. Lazy-load Three.js via next/dynamic so the hero LCP text isn't
* blocked by ~150kb of WebGL plumbing.
*
* The static fallback is a CSS-only radial gradient + faint dot mask.
* It looks intentional, not broken same color story as the live
* particle field, just without motion.
*/
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
type Capability =
| { kind: 'unknown' }
| { kind: 'fallback' }
| { kind: 'webgl'; textureSize: 128 | 256; motionScale: number };
// Dynamic import keeps three out of the initial bundle. ssr:false
// because there's no DOM/Canvas during SSR anyway.
const ParticleField = dynamic(
() => import('./ParticleField').then((m) => ({ default: m.ParticleField })),
{
ssr: false,
// No loading UI — the static gradient already lives at z-0 above
// until this resolves, and the canvas paints into the same slot.
loading: () => null,
},
);
/**
* Detect WebGL2 + float-render-target support without keeping the
* context around. We create a throwaway canvas, ask for `webgl2`, and
* probe `EXT_color_buffer_float`. If anything fails we fall back.
*
* Runs sync-only in the browser; never during SSR.
*/
function detectWebGL2(): boolean {
try {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2');
if (!gl) return false;
const ext = gl.getExtension('EXT_color_buffer_float');
// Losing context to ensure we don't leak the probe.
const loseExt = gl.getExtension('WEBGL_lose_context');
loseExt?.loseContext();
return Boolean(ext);
} catch {
return false;
}
}
export function ParticleHero() {
// Start in 'unknown' so SSR markup matches the first client render —
// the fallback gradient is rendered until we resolve capability, so
// there's no flash either way.
const [cap, setCap] = useState<Capability>({ kind: 'unknown' });
useEffect(() => {
// 1. WebGL2 + float targets — hard gate. Without these the sim
// can't run at all, fall through to the static gradient.
if (!detectWebGL2()) {
setCap({ kind: 'fallback' });
return;
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
/**
* Pick the right particle tier for the device.
*
* Returns a non-fallback WebGL config OR null when the device is
* too constrained to render the field at all (low-core phones).
* Reduced-motion does NOT shrink the tier here it's applied as
* a separate motion scalar on top, because the user still wants
* the cursor-tracking interaction.
*/
const pickTier = (): Capability => {
// Heuristic: small viewport OR an absurd DPR (low-DPI phones lying
// about retina) with no high-end signal. hardwareConcurrency is a
// rough but free proxy; logical cores <= 4 on a small viewport is
// a strong hint we shouldn't push 65k particles.
const isNarrow = window.matchMedia('(max-width: 768px)').matches;
const dpr = window.devicePixelRatio || 1;
const cores = navigator.hardwareConcurrency ?? 4;
const reduced = reduce.matches;
// Motion-reduce caps drift + ring-push velocity at 50% but keeps
// pointer position fidelity at 100%. 1.0 means "default motion".
const motionScale = reduced ? 0.5 : 1.0;
if (isNarrow) {
// Phones: drop to 16k. Going lower than that and the field
// visibly thins out; going higher and we cook batteries.
// 4-core phones get the static fallback — those are the
// budget Androids most likely to thermal-throttle.
if (cores <= 4) {
return { kind: 'fallback' };
}
return { kind: 'webgl', textureSize: 128, motionScale };
}
if (dpr > 2.5 && cores <= 4) {
// High-DPI low-core — likely a low-end tablet.
return { kind: 'webgl', textureSize: 128, motionScale };
}
// Desktop / capable tablet. Reduced-motion users get the same 128
// tier as mobile — fewer particles means less ambient activity in
// peripheral vision, which is what the motion preference is for.
if (reduced) {
return { kind: 'webgl', textureSize: 128, motionScale };
}
return { kind: 'webgl', textureSize: 256, motionScale };
};
setCap(pickTier());
// Respond to motion-preference changes mid-session — re-pick the
// tier so toggling the OS setting takes effect without a reload.
const onReduceChange = () => setCap(pickTier());
reduce.addEventListener('change', onReduceChange);
return () => reduce.removeEventListener('change', onReduceChange);
}, []);
// Static fallback: radial indigo glow + faint dotted mask.
// Used both for 'unknown' (pre-hydration) and 'fallback'.
if (cap.kind !== 'webgl') {
return (
<div
aria-hidden="true"
className="absolute inset-0 size-full overflow-hidden"
style={{
backgroundImage: [
// Soft indigo glow centered on the hero
'radial-gradient(60% 80% at 50% 45%, rgba(99,102,241,0.18), rgba(99,102,241,0) 70%)',
// Very faint dotted texture — reads as "field of particles
// at rest" rather than a flat gradient.
'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 1.5px)',
].join(', '),
backgroundSize: '100% 100%, 24px 24px',
}}
/>
);
}
return <ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />;
}
export default ParticleHero;

View File

@ -0,0 +1,295 @@
/**
* GLSL shaders for the particle-field hero.
*
* Shaders are exported as tagged-template strings with a leading
* `/* glsl *\/` comment marker so future syntax highlighters or
* static analysers can pick them up without us adding a webpack loader.
*
* Conventions:
* - All positions live in clip-space-like coordinates: x, y [-1, +1].
* - Position texture is RGBA32F:
* r = x
* g = y
* b = scale (per-particle render size jitter)
* a = velocity magnitude (used for color tint)
* - Simulation runs in a fullscreen quad pass each fragment = one particle.
*/
const simplexNoise = /* glsl */ `
// 2D simplex noise by Ian McEwan / Ashima Arts — public domain.
// Used both for idle drift in the sim and for organic distortion of
// the cursor-tracking ring.
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
float snoise(vec2 v) {
const vec4 C = vec4(
0.211324865405187,
0.366025403784439,
-0.577350269189626,
0.024390243902439
);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m = m * m;
m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
`;
/**
* Sim vertex shader trivial fullscreen pass.
* Writes through clip-space UVs so the fragment shader receives one
* fragment per particle in the position texture.
*/
export const simVertex = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
/**
* Sim fragment shader the actual integrator.
*
* Inputs:
* uPrev previous-frame position texture (ping-pong source)
* uTime elapsed seconds
* uDelta clamped frame delta (seconds), guards against tab-switch spikes
* uRingPos mouse position in clip space, smoothed
* uRingRadius current ring radius (clip-space units)
* uRingWidth base ring thickness (clip-space units)
* uRingActive 0..1 fade so the ring softly vanishes when the mouse leaves
* uMotionScale global multiplier on drift + ring-push velocity. 1.0 is
* default; the prefers-reduced-motion path passes 0.5 so
* the field reads as calm without removing interaction.
* Pointer *position* is not scaled the ring still
* tracks the cursor at full fidelity.
*
* Per-particle dynamics:
* 1. Idle drift: rotational simplex-noise velocity field feels like
* slow oceanic currents rather than random brownian jitter.
* 2. Ring push: three overlapping smoothstep bands at slightly offset
* radii, with the radius input distorted by simplex noise and a
* polar sin/cos wave. The gradient of the resulting field is
* applied as an outward push, so particles get gently shoved as
* the ring sweeps over them.
* 3. Containment: a very soft spring pulls particles back toward the
* origin if they drift past the field edge prevents particles
* from escaping to infinity on long sessions.
* 4. Damping: every frame velocity decays so the field returns to a
* calm steady state when the mouse is idle.
*/
export const simFragment = /* glsl */ `
precision highp float;
uniform sampler2D uPrev;
uniform float uTime;
uniform float uDelta;
uniform vec2 uRingPos;
uniform float uRingRadius;
uniform float uRingWidth;
uniform float uRingActive;
uniform float uMotionScale;
varying vec2 vUv;
${simplexNoise}
// Organic ring field — value peaks ON the ring, falls off either side.
// Three overlapping smoothstep bands with simplex-noise + polar-wave
// distortion to keep the boundary breathing instead of geometric.
float ringField(vec2 p) {
vec2 d = p - uRingPos;
float r = length(d);
float ang = atan(d.y, d.x);
// Breathing distortion of the radius itself.
float noise = snoise(p * 4.0 + uTime * 0.35) * 0.05;
// Polar wave — a slow rippling around the circumference.
float wave = sin(ang * 5.0 + uTime * 1.2) * 0.012
+ cos(ang * 3.0 - uTime * 0.7) * 0.010;
float rr = r + noise + wave;
// Three bands of different thickness at slightly offset radii.
float w = uRingWidth;
float b1 = smoothstep(uRingRadius - w * 0.30, uRingRadius, rr)
* (1.0 - smoothstep(uRingRadius, uRingRadius + w * 0.30, rr));
float b2 = smoothstep(uRingRadius - w * 0.80, uRingRadius - w * 0.15, rr)
* (1.0 - smoothstep(uRingRadius - w * 0.15, uRingRadius + w * 0.65, rr));
float b3 = smoothstep(uRingRadius - w * 1.40, uRingRadius - w * 0.50, rr)
* (1.0 - smoothstep(uRingRadius - w * 0.50, uRingRadius + w * 1.20, rr));
return (b1 * 1.0 + b2 * 0.55 + b3 * 0.30) * uRingActive;
}
void main() {
vec4 prev = texture2D(uPrev, vUv);
vec2 pos = prev.xy;
float scale = prev.z;
float velPrev = prev.w;
// --- Idle drift: rotational simplex-noise current ---
// Time is scaled by uMotionScale so reduced-motion users get a
// calmer field that evolves at half speed.
float driftTime = uTime * uMotionScale;
float n1 = snoise(pos * 1.6 + vec2(driftTime * 0.08, 0.0));
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.08) + 53.7);
vec2 driftVel = vec2(-n2, n1) * 0.045 * uMotionScale; // curl-like rotation
// --- Ring push: gradient of the ring field, pointing outward ---
float h = 0.003;
float fx0 = ringField(pos - vec2(h, 0.0));
float fx1 = ringField(pos + vec2(h, 0.0));
float fy0 = ringField(pos - vec2(0.0, h));
float fy1 = ringField(pos + vec2(0.0, h));
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
float fieldHere = ringField(pos);
// Push along gradient — particles get nudged away from the ring crest.
// Magnitude is scaled by uMotionScale so reduced-motion users get a
// softer shove while the ring position still tracks at full fidelity.
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
// --- Soft containment toward origin if particle escaped ---
float r = length(pos);
vec2 containVel = vec2(0.0);
if (r > 1.05) {
containVel = -normalize(pos) * (r - 1.05) * 0.6;
}
// --- Integrate ---
vec2 vel = driftVel + ringVel + containVel;
vec2 next = pos + vel * uDelta * 60.0; // normalise to 60fps reference
// Velocity magnitude for color tint — EMA so flash decays gracefully.
float velMag = length(vel);
float velOut = mix(velPrev, velMag, 0.20);
gl_FragColor = vec4(next, scale, velOut);
}
`;
/**
* Render vertex shader one vertex per particle, sampled from the
* position texture. The vertex's `position` attribute is unused;
* instead `aIndexUv` carries the (u, v) coordinate of this particle
* inside the position texture, and we read the actual position from
* `uPositions`.
*
* `gl_PointSize` is scaled by per-particle `scale` (z channel) and the
* device pixel ratio so the disc stays the same physical size on
* retina displays.
*/
export const renderVertex = /* glsl */ `
precision highp float;
uniform sampler2D uPositions;
uniform float uPointSize;
uniform float uDpr;
attribute vec2 aIndexUv;
varying float vVel;
varying float vScale;
void main() {
vec4 p = texture2D(uPositions, aIndexUv);
vScale = p.z;
vVel = p.w;
// Position is already clip-space xy in [-1, +1]; pin z = 0.
gl_Position = vec4(p.xy, 0.0, 1.0);
gl_PointSize = uPointSize * p.z * uDpr;
}
`;
/**
* Render fragment shader anti-aliased disc + velocity-based tint.
*
* Color: most of the field stays calm indigo at low opacity; particles
* that just got shoved by the ring (high velocity) flash toward a
* success-green tint. Output is premultiplied so additive blending
* gives the bloom-like glow without needing a post-process pass.
*/
export const renderFragment = /* glsl */ `
precision highp float;
uniform vec3 uColorCalm; // indigo
uniform vec3 uColorHot; // success-green
uniform float uBaseAlpha;
varying float vVel;
varying float vScale;
void main() {
// Disc SDF — anti-aliased round dot.
float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.42, d);
if (a <= 0.001) discard;
// Velocity-driven mix: pin to indigo for typical drift, lerp toward
// green only on real shoves. The 0.04..0.18 band is roughly where
// ring pushes live; idle drift stays below 0.03.
float t = smoothstep(0.04, 0.18, vVel);
vec3 col = mix(uColorCalm, uColorHot, t);
float alpha = uBaseAlpha * a * (0.6 + 0.4 * vScale);
// Premultiplied alpha — pairs with THREE.AdditiveBlending.
gl_FragColor = vec4(col * alpha, alpha);
}
`;
/**
* Init fragment runs once into both ping-pong targets to seed the
* starting field. Uses a tempered random distribution: uniform in the
* disc, with a small radial bias toward the edges so the field doesn't
* look like a bullseye on first frame.
*/
export const initFragment = /* glsl */ `
precision highp float;
varying vec2 vUv;
${simplexNoise}
// Tiny hash for per-particle deterministic randoms.
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
float r1 = hash(vUv);
float r2 = hash(vUv + 17.3);
float r3 = hash(vUv + 91.7);
// Polar-uniform disc with a soft outward bias.
float angle = r1 * 6.28318;
float radius = sqrt(r2) * 1.0;
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
// Slight horizontal stretch so the field reads as a wide hero band,
// not a perfect circle.
pos.x *= 1.25;
float scale = 0.55 + r3 * 0.85;
gl_FragColor = vec4(pos, scale, 0.0);
}
`;

View File

@ -12,12 +12,14 @@
"dependencies": {
"@bmm/types": "workspace:*",
"clsx": "2.1.1",
"framer-motion": "11.18.2",
"geist": "1.3.1",
"lucide-react": "0.469.0",
"next": "15.1.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "2.5.5",
"three": "0.171.0",
"zod": "3.25.76"
},
"browserslist": [
@ -31,6 +33,7 @@
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"@types/three": "0.171.0",
"postcss": "8.4.49",
"tailwindcss": "4.0.0-beta.7",
"typescript": "5.7.2"

Binary file not shown.

Binary file not shown.

91
pnpm-lock.yaml generated
View File

@ -140,6 +140,9 @@ importers:
clsx:
specifier: 2.1.1
version: 2.1.1
framer-motion:
specifier: 11.18.2
version: 11.18.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
geist:
specifier: 1.3.1
version: 1.3.1(next@15.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
@ -158,6 +161,9 @@ importers:
tailwind-merge:
specifier: 2.5.5
version: 2.5.5
three:
specifier: 0.171.0
version: 0.171.0
zod:
specifier: 3.25.76
version: 3.25.76
@ -174,6 +180,9 @@ importers:
'@types/react-dom':
specifier: 19.0.2
version: 19.0.2(@types/react@19.0.2)
'@types/three':
specifier: 0.171.0
version: 0.171.0
postcss:
specifier: 8.4.49
version: 8.4.49
@ -1317,6 +1326,9 @@ packages:
'@tailwindcss/postcss@4.0.0-beta.7':
resolution: {integrity: sha512-w7FyhQwymcWMyzyCj8nsNq3ImIurGbwujcEOtQhnX5gf3g8N2bH3EAq5+pk6Y0j4+HK37m1pIJ4lnULRkE66YQ==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@ -1340,6 +1352,15 @@ packages:
'@types/react@19.0.2':
resolution: {integrity: sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.171.0':
resolution: {integrity: sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@ -1388,6 +1409,9 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
'@webgpu/types@0.1.70':
resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==}
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@ -1898,6 +1922,9 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@ -1921,6 +1948,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
framer-motion@11.18.2:
resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@ -2208,6 +2249,9 @@ packages:
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
meshoptimizer@0.18.1:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@ -2234,6 +2278,12 @@ packages:
mnemonist@0.39.8:
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
motion-dom@11.18.1:
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
motion-utils@11.18.1:
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -2738,6 +2788,9 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
three@0.171.0:
resolution: {integrity: sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -3718,6 +3771,8 @@ snapshots:
postcss: 8.4.49
tailwindcss: 4.0.0-beta.7
'@tweenjs/tween.js@23.1.3': {}
'@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {}
@ -3743,6 +3798,19 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/stats.js@0.17.4': {}
'@types/three@0.171.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
'@webgpu/types': 0.1.70
fflate: 0.8.3
meshoptimizer: 0.18.1
'@types/webxr@0.5.24': {}
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 22.10.2
@ -3824,6 +3892,8 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
'@webgpu/types@0.1.70': {}
'@xtuc/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {}
@ -4362,6 +4432,8 @@ snapshots:
dependencies:
pend: 1.2.0
fflate@0.8.3: {}
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@ -4396,6 +4468,15 @@ snapshots:
forwarded@0.2.0: {}
framer-motion@11.18.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
motion-dom: 11.18.1
motion-utils: 11.18.1
tslib: 2.8.1
optionalDependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
fresh@2.0.0: {}
fs-monkey@1.0.3: {}
@ -4633,6 +4714,8 @@ snapshots:
merge-stream@2.0.0: {}
meshoptimizer@0.18.1: {}
mime-db@1.52.0: {}
mime-db@1.54.0: {}
@ -4653,6 +4736,12 @@ snapshots:
dependencies:
obliterator: 2.0.5
motion-dom@11.18.1:
dependencies:
motion-utils: 11.18.1
motion-utils@11.18.1: {}
ms@2.1.3: {}
msgpackr-extract@3.0.3:
@ -5127,6 +5216,8 @@ snapshots:
dependencies:
real-require: 0.2.0
three@0.171.0: {}
tiny-invariant@1.3.3: {}
toad-cache@3.7.1: {}

View File

@ -4,7 +4,7 @@
"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:mp4": "remotion render src/index.ts HeroVideo out/hero-raw.mp4 --codec h264 --crf 28 --pixel-format yuv420p && node scripts/postprocess.mjs",
"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 180 --image-format jpeg --jpeg-quality 85",
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",

View File

@ -0,0 +1,49 @@
// Re-encode the raw Remotion output to a browser-safe MP4 then delete
// the raw file. The previous pipeline used `-c:v copy` which preserved
// `pix_fmt=yuvj420p` (JPEG full-range) — Chrome refuses to decode that
// correctly and the video ships as a black square on the marketing page.
//
// Flags below were verified to produce a tag the browsers accept:
// pix_fmt=yuv420p, color_range=tv, profile=Main, level=4.0, BT.709.
import { spawnSync } from 'node:child_process';
import { existsSync, unlinkSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const rawPath = resolve(root, 'out', 'hero-raw.mp4');
const outPath = resolve(root, 'out', 'hero.mp4');
if (!existsSync(rawPath)) {
console.error(`postprocess: input not found at ${rawPath}`);
process.exit(1);
}
const ffmpegArgs = [
'-y',
'-i', rawPath,
'-c:v', 'libx264',
'-profile:v', 'main',
'-level', '4.0',
'-vf', 'format=yuv420p,colorspace=bt709:iall=bt709:fast=1',
'-color_range', 'tv',
'-color_primaries', 'bt709',
'-color_trc', 'bt709',
'-colorspace', 'bt709',
'-preset', 'slow',
'-crf', '23',
'-an',
'-movflags', '+faststart',
outPath,
];
const result = spawnSync('ffmpeg', ffmpegArgs, { stdio: 'inherit' });
if (result.status !== 0) {
console.error(`postprocess: ffmpeg exited with code ${result.status}`);
process.exit(result.status ?? 1);
}
unlinkSync(rawPath);
console.log(`postprocess: wrote ${outPath} and removed raw input`);

View File

@ -13,10 +13,21 @@ export function PromptScene() {
const { fps } = useVideoConfig();
// Whole scene fades out after frame 55 so it dissolves into Transform.
const sceneOut = interpolate(frame, [55, 70], [1, 0], {
// The collapse: between frame 50-70 all four words scale down toward
// their shared geometric center (960, 540) and fade. Visually the
// prompt "drops" into a single bright point that Beat 2 will explode
// from — Beat 2's particle origin matches this same point.
const sceneOut = interpolate(frame, [60, 70], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const collapseT = interpolate(frame, [50, 68], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// Ease the collapse so it accelerates inward at the very end.
const collapseEase = collapseT * collapseT;
const collapseScale = interpolate(collapseEase, [0, 1], [1, 0.2]);
return (
<div
@ -56,6 +67,15 @@ export function PromptScene() {
letterSpacing: '-0.01em',
display: 'flex',
gap: '18px',
// Collapse the entire word row toward its center point (which is
// canvas center 960,540 because the parent flex is fullscreen-
// centered). transform-origin: center makes all four words pull
// into a single bright point right before Beat 2's explosion.
transform: `scale(${collapseScale})`,
transformOrigin: 'center center',
filter: collapseT > 0
? `drop-shadow(0 0 ${12 + collapseEase * 28}px ${C.accentGlow})`
: undefined,
}}
>
{PROMPT_WORDS.map((word, i) => {

View File

@ -1,15 +1,16 @@
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
import { useCurrentFrame, useVideoConfig, interpolate, spring } from 'remotion';
import { C } from '../lib/colors';
import { rand, clampLerp, easeInOut, softSpring } from '../lib/easings';
import { rand, clampLerp, easeInOut } from '../lib/easings';
import { BEAT } from '../HeroVideo';
// Beat 2 — the wow moment.
//
// Prompt words detonate into ~60 chunky glowing particles that drift, then
// magnetically snap into target slots along a SERVER SCHEMATIC. The
// schematic strokes on IN PARALLEL with the convergence so the eye always
// has something to anchor to — earlier versions had a dead frame ~3s in
// where particles were too small and the box hadn't drawn yet.
// The collapsed prompt point at (960, 540) detonates RADIALLY into ~60
// glowing particles that scatter spherically, then magnetically snap into
// target slots along a SERVER SCHEMATIC. Particles are supporting players:
// small enough that the schematic — the thing being built — reads as the
// primary subject. Schematic strokes on IN PARALLEL with the convergence
// so the eye always has something to anchor to.
const PARTICLE_COUNT = 60;
@ -71,27 +72,29 @@ export function TransformScene() {
return (
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
{/* Central core radial glow that's always-on during Beat 2. Sells
"something is building here" before the schematic is drawn. */}
{/* Central core a hint of radial glow at the explosion origin.
Toned down from earlier versions so the schematic, not the core,
carries the visual weight. */}
<div
style={{
position: 'absolute',
left: CX - 200,
top: CY - 200,
width: 400,
height: 400,
left: CX - 110,
top: CY - 110,
width: 220,
height: 220,
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
opacity: coreAlpha * 0.7,
opacity: coreAlpha * 0.45,
transform: `scale(${corePulse})`,
pointerEvents: 'none',
}}
/>
{/* Particles — 60 chunky glowing dots */}
{/* Particles 60 small glowing dots radiating from a single origin.
They support the schematic; they do not dominate it. */}
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feGaussianBlur stdDeviation="1.4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
@ -99,32 +102,43 @@ export function TransformScene() {
</filter>
</defs>
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
const wordIndex = i % 4;
const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30;
const wordY = 540 + rand(i * 3.71) * 24 - 12;
// SINGLE-POINT ORIGIN. All 60 particles start at canvas center —
// the exact point Beat 1's prompt just collapsed into. This is
// what makes the explosion read as radial/spherical instead of
// horizontal.
const originX = CX;
const originY = CY;
const slot = targetSlot(i);
// Velocity vectors — particles fly outward in a roughly radial
// pattern from the prompt baseline. Magnitude varied per particle.
const angle = rand(i * 1.71) * Math.PI * 2;
const speed = 240 + rand(i * 4.13) * 380;
// Velocity vectors — even spherical distribution. Golden-angle
// stratification of `i` plus a small jitter prevents the visible
// banding you'd get from a pure uniform-random angle on 60 dots.
const goldenAngle = (i * 2.39996323) % (Math.PI * 2);
const jitter = (rand(i * 1.71) - 0.5) * 0.35;
const angle = goldenAngle + jitter;
const speed = 220 + rand(i * 4.13) * 320;
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed - 60; // bias slightly upward
const vy = Math.sin(angle) * speed;
const explode = clampLerp(local, 0, 18);
// Pull starts earlier (frame 14 instead of 25) so particles
// are visible converging rather than just drifting.
const pull = softSpring(frame, fps, BEAT.transform.in + 14, 42);
const explode = clampLerp(local, 0, 22);
// Pull — slower, more deliberate. Inlined spring so we can set
// the exact damping/mass/stiffness/duration the scene needs
// without bloating the easings module.
const pull = spring({
frame: frame - (BEAT.transform.in + 22),
fps,
config: { damping: 25, mass: 1.3, stiffness: 55 },
durationInFrames: 60,
});
const driftX = wordX + vx * explode * (1 - pull);
const driftY = wordY + vy * explode * (1 - pull);
const driftX = originX + vx * explode * (1 - pull);
const driftY = originY + vy * explode * (1 - pull);
const x = driftX + (slot.x - driftX) * pull;
const y = driftY + (slot.y - driftY) * pull;
// Radius: 6→3 as particles lock in. Big enough at 1080p that
// every particle is clearly visible.
const r = interpolate(pull, [0, 1], [6, 3]);
// Always indigo — earlier two-color split was indecisive
// Radius: 4→2 as particles lock in. Smaller than v2 so the
// schematic carries primary visual weight.
const r = interpolate(pull, [0, 1], [4, 2]);
const color = C.accent;
const alpha = clampLerp(local, 0, 4);
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
@ -136,7 +150,7 @@ export function TransformScene() {
cy={y}
r={r}
fill={color}
opacity={alpha * fadeOut * 0.95}
opacity={alpha * fadeOut * 0.9}
filter="url(#glow)"
/>
);
@ -157,7 +171,9 @@ export function TransformScene() {
</linearGradient>
</defs>
{/* Faint inner panel as the box draws — gives volume immediately */}
{/* Inner panel fills earlier and darker so the schematic reads
as a solid object the particles are building, not a wireframe
sketch. Reaches 0.9 opacity by the time the strokes complete. */}
<rect
x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2}
@ -165,10 +181,11 @@ export function TransformScene() {
height={SERVER_H}
rx={8}
fill={C.bgElevated}
opacity={strokeT * 0.5}
opacity={Math.min(0.9, strokeT * 1.5)}
/>
{/* Outer rectangle stroke */}
{/* Outer rectangle stroke wider and with a heavier drop-shadow
so the chassis outline is the dominant element on screen. */}
<rect
x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2}
@ -177,11 +194,11 @@ export function TransformScene() {
rx={8}
fill="none"
stroke={C.accent}
strokeWidth={3}
strokeWidth={4}
strokeDasharray={2 * (SERVER_W + SERVER_H)}
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
opacity={0.95}
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
opacity={0.98}
style={{ filter: `drop-shadow(0 0 16px ${C.accentGlow}) drop-shadow(0 0 4px ${C.accentGlow})` }}
/>
{/* Three internal tool rows */}