feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
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:
parent
22ba23f353
commit
e4e437c44c
@ -1,6 +1,9 @@
|
|||||||
|
import { HeroStepRotator } from '@/components/hero-step-rotator';
|
||||||
import { JsonLd } from '@/components/json-ld';
|
import { JsonLd } from '@/components/json-ld';
|
||||||
|
import { ParticleHero } from '@/components/particle-hero';
|
||||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||||
import { FAQ, faqJsonLd } from '@/lib/seo';
|
import { FAQ, faqJsonLd } from '@/lib/seo';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
|
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
|
||||||
@ -83,14 +86,25 @@ const TIERS = [
|
|||||||
export default function Landing() {
|
export default function Landing() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero */}
|
{/* Hero — left: copy + CTAs, right: cycling step-rotator tile.
|
||||||
<section className="relative border-b border-[--color-border]">
|
The old layout stacked three static code blocks vertically; the
|
||||||
<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">
|
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">
|
<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]">
|
<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
|
v0.1 — updated 2026-05-20
|
||||||
</span>
|
</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.
|
Describe your tool.
|
||||||
<br />
|
<br />
|
||||||
We host the server.
|
We host the server.
|
||||||
@ -115,7 +129,7 @@ export default function Landing() {
|
|||||||
Read the docs
|
Read the docs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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="inline-flex items-center gap-1.5">
|
||||||
<span className="size-1.5 rounded-full bg-emerald-400" /> OAuth 2.1 + PKCE
|
<span className="size-1.5 rounded-full bg-emerald-400" /> OAuth 2.1 + PKCE
|
||||||
</span>
|
</span>
|
||||||
@ -132,14 +146,53 @@ export default function Landing() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative min-w-0">
|
<div className="relative min-w-0">
|
||||||
<div className="absolute -inset-px rounded-lg border border-[--color-border-strong]" />
|
<HeroStepRotator />
|
||||||
<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} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
@ -152,33 +205,11 @@ export default function Landing() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero video — visualises the three beats: prompt → transform →
|
{/* The same video used to live here; it now has its own
|
||||||
live server connected to client. autoplay-muted-loop satisfies
|
full-width section directly under the hero so it's teased
|
||||||
every mobile browser autoplay policy. WebM is offered first
|
above the fold and gets edge-to-edge real estate. This
|
||||||
(smaller file on Chrome/Firefox), MP4 is the Safari fallback.
|
section keeps the three explanatory cards as supporting
|
||||||
motion-reduce:hidden swaps in a static poster for users with
|
copy under the video. */}
|
||||||
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">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
|
|||||||
591
apps/web/components/hero-animation.tsx
Normal file
591
apps/web/components/hero-animation.tsx
Normal 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})`;
|
||||||
|
}
|
||||||
177
apps/web/components/hero-step-rotator.tsx
Normal file
177
apps/web/components/hero-step-rotator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
apps/web/components/particle-hero/ParticleField.tsx
Normal file
322
apps/web/components/particle-hero/ParticleField.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
apps/web/components/particle-hero/index.tsx
Normal file
163
apps/web/components/particle-hero/index.tsx
Normal 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;
|
||||||
295
apps/web/components/particle-hero/shaders.ts
Normal file
295
apps/web/components/particle-hero/shaders.ts
Normal 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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -12,12 +12,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bmm/types": "workspace:*",
|
"@bmm/types": "workspace:*",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"framer-motion": "11.18.2",
|
||||||
"geist": "1.3.1",
|
"geist": "1.3.1",
|
||||||
"lucide-react": "0.469.0",
|
"lucide-react": "0.469.0",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"tailwind-merge": "2.5.5",
|
"tailwind-merge": "2.5.5",
|
||||||
|
"three": "0.171.0",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
@ -31,6 +33,7 @@
|
|||||||
"@types/node": "22.10.2",
|
"@types/node": "22.10.2",
|
||||||
"@types/react": "19.0.2",
|
"@types/react": "19.0.2",
|
||||||
"@types/react-dom": "19.0.2",
|
"@types/react-dom": "19.0.2",
|
||||||
|
"@types/three": "0.171.0",
|
||||||
"postcss": "8.4.49",
|
"postcss": "8.4.49",
|
||||||
"tailwindcss": "4.0.0-beta.7",
|
"tailwindcss": "4.0.0-beta.7",
|
||||||
"typescript": "5.7.2"
|
"typescript": "5.7.2"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@ -140,6 +140,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: 2.1.1
|
specifier: 2.1.1
|
||||||
version: 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:
|
geist:
|
||||||
specifier: 1.3.1
|
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))
|
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:
|
tailwind-merge:
|
||||||
specifier: 2.5.5
|
specifier: 2.5.5
|
||||||
version: 2.5.5
|
version: 2.5.5
|
||||||
|
three:
|
||||||
|
specifier: 0.171.0
|
||||||
|
version: 0.171.0
|
||||||
zod:
|
zod:
|
||||||
specifier: 3.25.76
|
specifier: 3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@ -174,6 +180,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 19.0.2
|
specifier: 19.0.2
|
||||||
version: 19.0.2(@types/react@19.0.2)
|
version: 19.0.2(@types/react@19.0.2)
|
||||||
|
'@types/three':
|
||||||
|
specifier: 0.171.0
|
||||||
|
version: 0.171.0
|
||||||
postcss:
|
postcss:
|
||||||
specifier: 8.4.49
|
specifier: 8.4.49
|
||||||
version: 8.4.49
|
version: 8.4.49
|
||||||
@ -1317,6 +1326,9 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.0.0-beta.7':
|
'@tailwindcss/postcss@4.0.0-beta.7':
|
||||||
resolution: {integrity: sha512-w7FyhQwymcWMyzyCj8nsNq3ImIurGbwujcEOtQhnX5gf3g8N2bH3EAq5+pk6Y0j4+HK37m1pIJ4lnULRkE66YQ==}
|
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':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@ -1340,6 +1352,15 @@ packages:
|
|||||||
'@types/react@19.0.2':
|
'@types/react@19.0.2':
|
||||||
resolution: {integrity: sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==}
|
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':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||||
|
|
||||||
@ -1388,6 +1409,9 @@ packages:
|
|||||||
'@webassemblyjs/wast-printer@1.14.1':
|
'@webassemblyjs/wast-printer@1.14.1':
|
||||||
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
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':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@ -1898,6 +1922,9 @@ packages:
|
|||||||
fd-slicer@1.1.0:
|
fd-slicer@1.1.0:
|
||||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||||
|
|
||||||
|
fflate@0.8.3:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
@ -1921,6 +1948,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
fresh@2.0.0:
|
||||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -2208,6 +2249,9 @@ packages:
|
|||||||
merge-stream@2.0.0:
|
merge-stream@2.0.0:
|
||||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
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:
|
mime-db@1.52.0:
|
||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -2234,6 +2278,12 @@ packages:
|
|||||||
mnemonist@0.39.8:
|
mnemonist@0.39.8:
|
||||||
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
|
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:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@ -2738,6 +2788,9 @@ packages:
|
|||||||
thread-stream@3.1.0:
|
thread-stream@3.1.0:
|
||||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||||
|
|
||||||
|
three@0.171.0:
|
||||||
|
resolution: {integrity: sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
@ -3718,6 +3771,8 @@ snapshots:
|
|||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
tailwindcss: 4.0.0-beta.7
|
tailwindcss: 4.0.0-beta.7
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@ -3743,6 +3798,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
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':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.10.2
|
'@types/node': 22.10.2
|
||||||
@ -3824,6 +3892,8 @@ snapshots:
|
|||||||
'@webassemblyjs/ast': 1.14.1
|
'@webassemblyjs/ast': 1.14.1
|
||||||
'@xtuc/long': 4.2.2
|
'@xtuc/long': 4.2.2
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.70': {}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0': {}
|
'@xtuc/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@ -4362,6 +4432,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
pend: 1.2.0
|
pend: 1.2.0
|
||||||
|
|
||||||
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@ -4396,6 +4468,15 @@ snapshots:
|
|||||||
|
|
||||||
forwarded@0.2.0: {}
|
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: {}
|
fresh@2.0.0: {}
|
||||||
|
|
||||||
fs-monkey@1.0.3: {}
|
fs-monkey@1.0.3: {}
|
||||||
@ -4633,6 +4714,8 @@ snapshots:
|
|||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|
||||||
|
meshoptimizer@0.18.1: {}
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
mime-db@1.54.0: {}
|
mime-db@1.54.0: {}
|
||||||
@ -4653,6 +4736,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
obliterator: 2.0.5
|
obliterator: 2.0.5
|
||||||
|
|
||||||
|
motion-dom@11.18.1:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 11.18.1
|
||||||
|
|
||||||
|
motion-utils@11.18.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
msgpackr-extract@3.0.3:
|
msgpackr-extract@3.0.3:
|
||||||
@ -5127,6 +5216,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
real-require: 0.2.0
|
real-require: 0.2.0
|
||||||
|
|
||||||
|
three@0.171.0: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
toad-cache@3.7.1: {}
|
toad-cache@3.7.1: {}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"studio": "remotion studio src/index.ts",
|
"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: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: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",
|
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
|
||||||
|
|||||||
49
remotion/scripts/postprocess.mjs
Normal file
49
remotion/scripts/postprocess.mjs
Normal 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`);
|
||||||
@ -13,10 +13,21 @@ export function PromptScene() {
|
|||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
// Whole scene fades out after frame 55 so it dissolves into Transform.
|
// 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',
|
extrapolateLeft: 'clamp',
|
||||||
extrapolateRight: '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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -56,6 +67,15 @@ export function PromptScene() {
|
|||||||
letterSpacing: '-0.01em',
|
letterSpacing: '-0.01em',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '18px',
|
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) => {
|
{PROMPT_WORDS.map((word, i) => {
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
import { useCurrentFrame, useVideoConfig, interpolate, spring } from 'remotion';
|
||||||
import { C } from '../lib/colors';
|
import { C } from '../lib/colors';
|
||||||
import { rand, clampLerp, easeInOut, softSpring } from '../lib/easings';
|
import { rand, clampLerp, easeInOut } from '../lib/easings';
|
||||||
import { BEAT } from '../HeroVideo';
|
import { BEAT } from '../HeroVideo';
|
||||||
|
|
||||||
// Beat 2 — the wow moment.
|
// Beat 2 — the wow moment.
|
||||||
//
|
//
|
||||||
// Prompt words detonate into ~60 chunky glowing particles that drift, then
|
// The collapsed prompt point at (960, 540) detonates RADIALLY into ~60
|
||||||
// magnetically snap into target slots along a SERVER SCHEMATIC. The
|
// glowing particles that scatter spherically, then magnetically snap into
|
||||||
// schematic strokes on IN PARALLEL with the convergence so the eye always
|
// target slots along a SERVER SCHEMATIC. Particles are supporting players:
|
||||||
// has something to anchor to — earlier versions had a dead frame ~3s in
|
// small enough that the schematic — the thing being built — reads as the
|
||||||
// where particles were too small and the box hadn't drawn yet.
|
// primary subject. Schematic strokes on IN PARALLEL with the convergence
|
||||||
|
// so the eye always has something to anchor to.
|
||||||
|
|
||||||
const PARTICLE_COUNT = 60;
|
const PARTICLE_COUNT = 60;
|
||||||
|
|
||||||
@ -71,27 +72,29 @@ export function TransformScene() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
|
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
|
||||||
{/* Central core — radial glow that's always-on during Beat 2. Sells
|
{/* Central core — a hint of radial glow at the explosion origin.
|
||||||
"something is building here" before the schematic is drawn. */}
|
Toned down from earlier versions so the schematic, not the core,
|
||||||
|
carries the visual weight. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: CX - 200,
|
left: CX - 110,
|
||||||
top: CY - 200,
|
top: CY - 110,
|
||||||
width: 400,
|
width: 220,
|
||||||
height: 400,
|
height: 220,
|
||||||
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
|
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
|
||||||
opacity: coreAlpha * 0.7,
|
opacity: coreAlpha * 0.45,
|
||||||
transform: `scale(${corePulse})`,
|
transform: `scale(${corePulse})`,
|
||||||
pointerEvents: 'none',
|
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 }}>
|
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
<feGaussianBlur stdDeviation="1.4" result="blur" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="blur" />
|
<feMergeNode in="blur" />
|
||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
@ -99,32 +102,43 @@ export function TransformScene() {
|
|||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
|
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
|
||||||
const wordIndex = i % 4;
|
// SINGLE-POINT ORIGIN. All 60 particles start at canvas center —
|
||||||
const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30;
|
// the exact point Beat 1's prompt just collapsed into. This is
|
||||||
const wordY = 540 + rand(i * 3.71) * 24 - 12;
|
// what makes the explosion read as radial/spherical instead of
|
||||||
|
// horizontal.
|
||||||
|
const originX = CX;
|
||||||
|
const originY = CY;
|
||||||
|
|
||||||
const slot = targetSlot(i);
|
const slot = targetSlot(i);
|
||||||
// Velocity vectors — particles fly outward in a roughly radial
|
// Velocity vectors — even spherical distribution. Golden-angle
|
||||||
// pattern from the prompt baseline. Magnitude varied per particle.
|
// stratification of `i` plus a small jitter prevents the visible
|
||||||
const angle = rand(i * 1.71) * Math.PI * 2;
|
// banding you'd get from a pure uniform-random angle on 60 dots.
|
||||||
const speed = 240 + rand(i * 4.13) * 380;
|
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 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);
|
const explode = clampLerp(local, 0, 22);
|
||||||
// Pull starts earlier (frame 14 instead of 25) so particles
|
// Pull — slower, more deliberate. Inlined spring so we can set
|
||||||
// are visible converging rather than just drifting.
|
// the exact damping/mass/stiffness/duration the scene needs
|
||||||
const pull = softSpring(frame, fps, BEAT.transform.in + 14, 42);
|
// 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 driftX = originX + vx * explode * (1 - pull);
|
||||||
const driftY = wordY + vy * explode * (1 - pull);
|
const driftY = originY + vy * explode * (1 - pull);
|
||||||
const x = driftX + (slot.x - driftX) * pull;
|
const x = driftX + (slot.x - driftX) * pull;
|
||||||
const y = driftY + (slot.y - driftY) * pull;
|
const y = driftY + (slot.y - driftY) * pull;
|
||||||
|
|
||||||
// Radius: 6→3 as particles lock in. Big enough at 1080p that
|
// Radius: 4→2 as particles lock in. Smaller than v2 so the
|
||||||
// every particle is clearly visible.
|
// schematic carries primary visual weight.
|
||||||
const r = interpolate(pull, [0, 1], [6, 3]);
|
const r = interpolate(pull, [0, 1], [4, 2]);
|
||||||
// Always indigo — earlier two-color split was indecisive
|
|
||||||
const color = C.accent;
|
const color = C.accent;
|
||||||
const alpha = clampLerp(local, 0, 4);
|
const alpha = clampLerp(local, 0, 4);
|
||||||
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
|
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
|
||||||
@ -136,7 +150,7 @@ export function TransformScene() {
|
|||||||
cy={y}
|
cy={y}
|
||||||
r={r}
|
r={r}
|
||||||
fill={color}
|
fill={color}
|
||||||
opacity={alpha * fadeOut * 0.95}
|
opacity={alpha * fadeOut * 0.9}
|
||||||
filter="url(#glow)"
|
filter="url(#glow)"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -157,7 +171,9 @@ export function TransformScene() {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</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
|
<rect
|
||||||
x={CX - SERVER_W / 2}
|
x={CX - SERVER_W / 2}
|
||||||
y={CY - SERVER_H / 2}
|
y={CY - SERVER_H / 2}
|
||||||
@ -165,10 +181,11 @@ export function TransformScene() {
|
|||||||
height={SERVER_H}
|
height={SERVER_H}
|
||||||
rx={8}
|
rx={8}
|
||||||
fill={C.bgElevated}
|
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
|
<rect
|
||||||
x={CX - SERVER_W / 2}
|
x={CX - SERVER_W / 2}
|
||||||
y={CY - SERVER_H / 2}
|
y={CY - SERVER_H / 2}
|
||||||
@ -177,11 +194,11 @@ export function TransformScene() {
|
|||||||
rx={8}
|
rx={8}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={C.accent}
|
stroke={C.accent}
|
||||||
strokeWidth={3}
|
strokeWidth={4}
|
||||||
strokeDasharray={2 * (SERVER_W + SERVER_H)}
|
strokeDasharray={2 * (SERVER_W + SERVER_H)}
|
||||||
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
|
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
|
||||||
opacity={0.95}
|
opacity={0.98}
|
||||||
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
|
style={{ filter: `drop-shadow(0 0 16px ${C.accentGlow}) drop-shadow(0 0 4px ${C.accentGlow})` }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Three internal tool rows */}
|
{/* Three internal tool rows */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user