feat(web): mobile-fit hero tiles + voluminous calmer particle field + FAQ accordion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Three coordinated polish items requested: 1. **Hero step-rotator tiles fit mobile without horizontal scroll.** The previous snippets contained a 50+ char `Live at https://notion-x9.mcp.buildmymcpserver.com` URL that overflowed the ~295 px text area on a 375 px viewport. Rewrote all three snippets to be naturally short — same product story, no full URLs. The <pre> drops `overflow-x-auto` and gains `whitespace-pre-wrap break-words` so any token that does exceed the column wraps gracefully instead of forcing a scrollbar. 2. **ParticleHero — more volumetric, slower, steadier at load-in.** The "stuttery / too fast" feedback came from two issues compounding: tiny dots (1.8 px on 256-tier, with 0.42 base alpha) gave the eye too few pixels to track between frames, so individual particles read as snapping rather than drifting; and the simplex-noise drift evolved at 0.08 time-scale with 0.045 velocity, fast enough that frame-to-frame deltas exceeded a tracked particle's diameter. Render uniforms tuned: - `uPointSize` 1.8 → 2.8 (256-tier), 2.4 → 3.6 (128-tier) - `uBaseAlpha` 0.42 → 0.60 Simulation shader tuned: - Drift noise time scale 0.08 → 0.045 (the most impactful single change — particles now move at half the previous speed) - Drift velocity magnitude 0.045 → 0.028 - Ring breathing noise time scale 0.35 → 0.22 - Ring polar-wave time scales 1.2 / 0.7 → 0.7 / 0.42 Net effect: same number of particles (65k) but each individually larger, brighter, and moving more slowly. The cumulative additive bloom is denser without the jitter that read as visual stutter. 3. **FAQ collapsed into a native `<details>` accordion.** Crawlers and screen readers still see every Q+A in the SSR'd HTML — `<details><summary>...</summary><p>answer</p></details>` is the standard semantic pattern for disclosure widgets. Users see one question at a time and expand on demand, which keeps the page from feeling like an endless wall of marketing text below the fold. Container narrowed `max-w-6xl` → `max-w-3xl` for accordion typography (long-form prose reads better single-column). The default WebKit disclosure-triangle marker is suppressed with `list-none` + `[&_summary::-webkit-details-marker]:hidden`, and a `lucide-react` `ChevronDown` icon rotates 180° via `group-open:rotate-180` to indicate state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f8b8da151
commit
035e55f00c
@ -5,6 +5,7 @@ import { PulseLink } from '@/components/pulse';
|
||||
import { ScrollCue } from '@/components/scroll-cue';
|
||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||
import { FAQ, faqJsonLd } from '@/lib/seo';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
|
||||
@ -351,17 +352,33 @@ export default function Landing() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
{/* FAQ — collapsible accordion using native <details>. Crawlers
|
||||
and screen readers still see the full Q+A in the HTML; users
|
||||
see one question at a time and expand on demand. No JS, no
|
||||
state, semantically correct. `list-none` + the WebKit-marker
|
||||
pseudo-class suppress the default disclosure triangle so we
|
||||
can render our own chevron that rotates via `group-open`. */}
|
||||
<section className="py-14 sm:py-20">
|
||||
<JsonLd data={faqJsonLd()} />
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<h2 className="text-[28px] font-semibold tracking-tight">FAQ</h2>
|
||||
<div className="mt-8 grid gap-x-12 gap-y-6 md:grid-cols-2">
|
||||
<div className="mt-8 border-t border-[--color-border]">
|
||||
{FAQ.map((f) => (
|
||||
<div key={f.q}>
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">{f.q}</h3>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
|
||||
</div>
|
||||
<details
|
||||
key={f.q}
|
||||
className="group border-b border-[--color-border] [&_summary::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-[14.5px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-accent]">
|
||||
<span>{f.q}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="shrink-0 text-[--color-fg-subtle] transition-transform duration-200 group-open:rotate-180 group-open:text-[--color-accent]"
|
||||
/>
|
||||
</summary>
|
||||
<p className="pb-5 pr-8 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
{f.a}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,31 +16,39 @@ interface Step {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Snippets are deliberately short — they have to fit in a tile that
|
||||
// shrinks to roughly 295 px wide on a 375 px mobile viewport, with
|
||||
// monospace 12.5 px. Long lines (URLs especially) get truncated to a
|
||||
// recognisable shape (`mcp/notion-x9`) so we never need horizontal
|
||||
// scrolling inside the tile. `whitespace-pre-wrap` on the <pre> below
|
||||
// lets any remaining over-width tokens (e.g. someone shrinks the
|
||||
// viewport to 320 px) wrap instead of overflowing.
|
||||
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.`,
|
||||
code: `Build an MCP server that
|
||||
searches our Notion workspace.
|
||||
|
||||
Tools: search_pages, get_page
|
||||
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`,
|
||||
code: `✓ Generating spec (2 tools)
|
||||
✓ Static checks passed
|
||||
✓ Building image 17.2s
|
||||
✓ Deploying ok
|
||||
✓ Live → mcp/notion-x9`,
|
||||
},
|
||||
{
|
||||
label: 'claude_desktop_config.json',
|
||||
label: 'claude.config.json',
|
||||
badge: '03 · Connect',
|
||||
code: `{
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
|
||||
"url": ".../notion-x9/mcp",
|
||||
"auth": "oauth2"
|
||||
}
|
||||
}
|
||||
@ -161,7 +169,7 @@ export function HeroStepRotator() {
|
||||
{current.badge}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="mono relative overflow-x-auto px-4 py-4 text-[12.5px] leading-relaxed text-[--color-fg]">
|
||||
<pre className="mono relative whitespace-pre-wrap break-words px-4 py-4 text-[12.5px] leading-relaxed text-[--color-fg]">
|
||||
<code>{current.code}</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
|
||||
@ -169,11 +169,16 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
|
||||
|
||||
const renderUniforms = {
|
||||
uPositions: { value: rtB.texture },
|
||||
uPointSize: { value: textureSize === 256 ? 1.8 : 2.4 },
|
||||
// Bigger dots + higher base alpha = more volumetric "calm field"
|
||||
// read at the load-in (was 1.8 / 0.42 — read as too thin, looked
|
||||
// stuttery because individual particles were hard to track between
|
||||
// frames). With these values the field has a denser cumulative
|
||||
// glow without any change to the simulation itself.
|
||||
uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 },
|
||||
uDpr: { value: dpr },
|
||||
uColorCalm: { value: colorCalm },
|
||||
uColorHot: { value: colorHot },
|
||||
uBaseAlpha: { value: 0.42 },
|
||||
uBaseAlpha: { value: 0.6 },
|
||||
};
|
||||
const particleMat = new THREE.ShaderMaterial({
|
||||
vertexShader: renderVertex,
|
||||
|
||||
@ -121,11 +121,14 @@ export const simFragment = /* glsl */ `
|
||||
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;
|
||||
// Breathing distortion of the radius itself. Time scale tuned
|
||||
// down (was 0.35 → 0.22) so the ring "breathes" rather than
|
||||
// pulsates, which used to read as visual stutter on slow drift.
|
||||
float noise = snoise(p * 4.0 + uTime * 0.22) * 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;
|
||||
// Same calming pass on both phases.
|
||||
float wave = sin(ang * 5.0 + uTime * 0.7) * 0.012
|
||||
+ cos(ang * 3.0 - uTime * 0.42) * 0.010;
|
||||
float rr = r + noise + wave;
|
||||
|
||||
// Three bands of different thickness at slightly offset radii.
|
||||
@ -149,10 +152,15 @@ export const simFragment = /* glsl */ `
|
||||
// --- Idle drift: rotational simplex-noise current ---
|
||||
// Time is scaled by uMotionScale so reduced-motion users get a
|
||||
// calmer field that evolves at half speed.
|
||||
// Noise-evolution speed dropped from 0.08 → 0.045 (almost halved)
|
||||
// and velocity magnitude from 0.045 → 0.028. The combination kills
|
||||
// the perceived stutter — each particle now moves slowly enough
|
||||
// that the eye tracks individual motion as smooth drift rather
|
||||
// than as jerky per-frame teleportation.
|
||||
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
|
||||
float n1 = snoise(pos * 1.6 + vec2(driftTime * 0.045, 0.0));
|
||||
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.045) + 53.7);
|
||||
vec2 driftVel = vec2(-n2, n1) * 0.028 * uMotionScale; // curl-like rotation
|
||||
|
||||
// --- Ring push: gradient of the ring field, pointing outward ---
|
||||
float h = 0.003;
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"render:mp4": "remotion render src/index.ts HeroVideo out/hero-raw.mp4 --codec h264 --crf 28 --pixel-format yuv420p && node scripts/postprocess.mjs",
|
||||
"render:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32",
|
||||
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 180 --image-format jpeg --jpeg-quality 85",
|
||||
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 210 --image-format jpeg --jpeg-quality 85",
|
||||
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
|
||||
"to-web": "node scripts/publish-to-web.mjs",
|
||||
"build": "pnpm render:all && pnpm to-web"
|
||||
|
||||
@ -1,76 +1,105 @@
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
||||
import { C } from './lib/colors';
|
||||
import { clampLerp } from './lib/easings';
|
||||
import { PromptScene } from './scenes/PromptScene';
|
||||
import { TransformScene } from './scenes/TransformScene';
|
||||
import { ServerScene } from './scenes/ServerScene';
|
||||
import { BuildScene } from './scenes/BuildScene';
|
||||
import { LibraryScene } from './scenes/LibraryScene';
|
||||
import { DiscoveryScene } from './scenes/DiscoveryScene';
|
||||
|
||||
export const HERO_FPS = 30;
|
||||
export const HERO_DURATION_FRAMES = 240; // 8s
|
||||
export const HERO_DURATION_FRAMES = 300; // 10s
|
||||
|
||||
// Scene timing — frame ranges, inclusive on start, exclusive on end.
|
||||
// Beats overlap intentionally at the edges so transitions cross-fade
|
||||
// rather than hard-cut. The last 12 frames fade the whole canvas to
|
||||
// black so the loop seam disappears (frame 0 starts equally dark).
|
||||
// Scene timing. Each beat overlaps the next by ~6 frames so the
|
||||
// transitions crossfade rather than hard-cut.
|
||||
//
|
||||
// P1 prompt [ 0, 81)
|
||||
// P2 build [ 75, 171)
|
||||
// P3 library [165, 246)
|
||||
// P4 discovery [240, 300)
|
||||
export const BEAT = {
|
||||
prompt: { in: 0, out: 70 },
|
||||
transform: { in: 55, out: 165 },
|
||||
server: { in: 150, out: 240 },
|
||||
prompt: { in: 0, out: 81 },
|
||||
build: { in: 75, out: 171 },
|
||||
library: { in: 165, out: 246 },
|
||||
discovery: { in: 240, out: 300 },
|
||||
} as const;
|
||||
|
||||
const FADE_FRAMES = 12;
|
||||
|
||||
export function HeroVideo() {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Loop-clean: ramp opacity to 0 over the last 12 frames so frame 239 ≈
|
||||
// Loop-clean: ramp opacity to 0 over the last 12 frames so frame 299 ≈
|
||||
// frame 0 (both essentially-black). Browser <video loop> will jump back
|
||||
// and the seam is invisible.
|
||||
const loopFade = interpolate(frame, [HERO_DURATION_FRAMES - 12, HERO_DURATION_FRAMES - 1], [1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const loopFade = interpolate(
|
||||
frame,
|
||||
[HERO_DURATION_FRAMES - FADE_FRAMES, HERO_DURATION_FRAMES - 1],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
|
||||
);
|
||||
|
||||
// Crossfade alpha for each scene over its 6-frame entry/exit overlap.
|
||||
const promptAlpha = crossfade(frame, BEAT.prompt.in, BEAT.prompt.out, 6);
|
||||
const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6);
|
||||
const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6);
|
||||
const discoveryAlpha = crossfade(frame, BEAT.discovery.in, BEAT.discovery.out, 6);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}>
|
||||
{/* Subtle dotted grid — set design vocabulary, never moves. Drawn
|
||||
once at the bottom and every scene paints on top. */}
|
||||
<DottedGrid />
|
||||
<Vignette />
|
||||
|
||||
<AbsoluteFill style={{ opacity: loopFade }}>
|
||||
{frame < BEAT.prompt.out && <PromptScene />}
|
||||
{frame >= BEAT.transform.in && frame < BEAT.transform.out && <TransformScene />}
|
||||
{frame >= BEAT.server.in && <ServerScene fps={fps} />}
|
||||
{promptAlpha > 0 && (
|
||||
<AbsoluteFill style={{ opacity: promptAlpha }}>
|
||||
<PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} />
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
{buildAlpha > 0 && (
|
||||
<AbsoluteFill style={{ opacity: buildAlpha }}>
|
||||
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} />
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
{libraryAlpha > 0 && (
|
||||
<AbsoluteFill style={{ opacity: libraryAlpha }}>
|
||||
<LibraryScene localFrame={frame - BEAT.library.in} fps={fps} />
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
{discoveryAlpha > 0 && (
|
||||
<AbsoluteFill style={{ opacity: discoveryAlpha }}>
|
||||
<DiscoveryScene localFrame={frame - BEAT.discovery.in} fps={fps} />
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Cursor — visible at start and end, the only motif that bridges the
|
||||
loop. Hidden during Transform/Server because the camera focus is
|
||||
elsewhere. */}
|
||||
<CursorMotif frame={frame} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
function crossfade(frame: number, start: number, end: number, overlap: number) {
|
||||
// Returns 0..1: ramps up `overlap` frames after start, ramps down `overlap`
|
||||
// frames before end. Outside [start, end) returns 0.
|
||||
if (frame < start || frame >= end) return 0;
|
||||
const intoStart = clampLerp(frame, start, start + overlap);
|
||||
const beforeEnd = 1 - clampLerp(frame, end - overlap, end);
|
||||
return Math.min(intoStart, beforeEnd);
|
||||
}
|
||||
|
||||
function DottedGrid() {
|
||||
// 32px dot grid, fading toward the edges. Drawn with a single radial
|
||||
// mask + repeating dot pattern. Static; renders into PNG quickly.
|
||||
const dotSize = 1.5;
|
||||
const gap = 48;
|
||||
const dots = [];
|
||||
for (let y = 0; y < 1080 + gap; y += gap) {
|
||||
for (let x = 0; x < 1920 + gap; x += gap) {
|
||||
dots.push(
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r={dotSize} fill={C.borderStrong} opacity={0.5} />,
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r={dotSize} fill={C.borderStrong} opacity={0.45} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={1920}
|
||||
height={1080}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
// Radial gradient mask: dots strongest center, fading at edges
|
||||
>
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<radialGradient id="vignette" cx="50%" cy="50%" r="60%">
|
||||
<radialGradient id="vignette" cx="50%" cy="50%" r="65%">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
@ -83,33 +112,17 @@ function DottedGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
function CursorMotif({ frame }: { frame: number }) {
|
||||
// Beat 1: cursor blinks at the right edge of the typed prompt. We
|
||||
// place a generic blinking caret in the bottom-third area so the loop
|
||||
// ends with an invitation to type again.
|
||||
const showStart = frame < BEAT.transform.in;
|
||||
const showEnd = frame >= HERO_DURATION_FRAMES - 24;
|
||||
if (!showStart && !showEnd) return null;
|
||||
|
||||
// 0.5Hz blink
|
||||
const blink = Math.floor(frame / 15) % 2 === 0;
|
||||
// Start-position cursor lives at the end of the typed prompt; end-position
|
||||
// cursor lives in the same place to set up the loop.
|
||||
const x = 960 + 220; // anchored after the prompt baseline
|
||||
const y = 540;
|
||||
function Vignette() {
|
||||
// Faint indigo glow at the center so the product mockups have
|
||||
// something to sit on. Static across the whole timeline.
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y - 24,
|
||||
width: 4,
|
||||
height: 48,
|
||||
backgroundColor: C.accent,
|
||||
opacity: blink ? 0.9 : 0,
|
||||
borderRadius: 1,
|
||||
boxShadow: `0 0 12px ${C.accentGlow}`,
|
||||
transition: 'opacity 0.05s',
|
||||
inset: 0,
|
||||
background:
|
||||
'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(99,102,241,0.10) 0%, rgba(99,102,241,0) 70%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
265
remotion/src/scenes/BuildScene.tsx
Normal file
265
remotion/src/scenes/BuildScene.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
||||
|
||||
// Phase 2 (frames 75–171 global → localFrame 0..96): build log streams in
|
||||
// line-by-line, then a server card emerges.
|
||||
//
|
||||
// Log lines stagger ~12 frames apart starting at localFrame 4.
|
||||
// Server card emerges at localFrame ~64.
|
||||
|
||||
const LOG_LINES = [
|
||||
{ label: 'Generating spec', detail: '2 tools detected' },
|
||||
{ label: 'Static checks', detail: 'passed' },
|
||||
{ label: 'Building image', detail: '17.2s' },
|
||||
{ label: 'Deploying', detail: 'live' },
|
||||
];
|
||||
|
||||
const LINE_STAGGER = 10;
|
||||
const LINE_START = 4;
|
||||
const CARD_START = 60;
|
||||
|
||||
export function BuildScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||
const panelIn = springIn(localFrame, fps, 0);
|
||||
const panelOpacity = clampLerp(localFrame, 0, 12);
|
||||
|
||||
// Card emerges late in phase.
|
||||
const cardIn = softSpring(localFrame, fps, CARD_START, 24);
|
||||
// Once the card is up, the log panel slides up to make room.
|
||||
const panelShift = interpolate(cardIn, [0, 1], [0, -140]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Build log panel */}
|
||||
<div
|
||||
style={{
|
||||
width: 720,
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1px solid ${C.border}`,
|
||||
borderRadius: 14,
|
||||
padding: '24px 28px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
opacity: panelOpacity,
|
||||
transform: `translateY(${interpolate(panelIn, [0, 1], [20, 0]) + panelShift}px) scale(${interpolate(panelIn, [0, 1], [0.96, 1])})`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{/* Panel header — tiny status row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 12,
|
||||
borderBottom: `1px solid ${C.border}`,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 14,
|
||||
color: C.fgSubtle,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<span>build · notion-search</span>
|
||||
<span style={{ color: C.fgMuted }}>● running</span>
|
||||
</div>
|
||||
|
||||
{LOG_LINES.map((line, i) => (
|
||||
<LogLine
|
||||
key={i}
|
||||
label={line.label}
|
||||
detail={line.detail}
|
||||
localFrame={localFrame}
|
||||
startFrame={LINE_START + i * LINE_STAGGER}
|
||||
fps={fps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Server card (emerges in second half) */}
|
||||
{cardIn > 0.01 && (
|
||||
<ServerCard
|
||||
progress={cardIn}
|
||||
localFrame={localFrame}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLine({
|
||||
label,
|
||||
detail,
|
||||
localFrame,
|
||||
startFrame,
|
||||
fps,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
localFrame: number;
|
||||
startFrame: number;
|
||||
fps: number;
|
||||
}) {
|
||||
const spring = springIn(localFrame, fps, startFrame);
|
||||
const opacity = clampLerp(localFrame, startFrame, startFrame + 10);
|
||||
const x = interpolate(spring, [0, 1], [-30, 0]);
|
||||
// Check fills in slightly after the line slides in.
|
||||
const checkFill = clampLerp(localFrame, startFrame + 6, startFrame + 14);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
opacity,
|
||||
transform: `translateX(${x}px)`,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{/* Checkmark circle */}
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: `rgba(34, 197, 94, ${checkFill * 0.18})`,
|
||||
border: `1.5px solid ${C.success}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M 2.5 6.5 L 5 9 L 9.5 3.5"
|
||||
fill="none"
|
||||
stroke={C.success}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={12}
|
||||
strokeDashoffset={(1 - checkFill) * 12}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span style={{ color: C.fg, minWidth: 220 }}>{label}</span>
|
||||
<span style={{ color: C.fgMuted, flex: 1 }}>{detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerCard({ progress, localFrame }: { progress: number; localFrame: number }) {
|
||||
const scale = interpolate(progress, [0, 1], [0.85, 1]);
|
||||
const y = interpolate(progress, [0, 1], [40, 180]);
|
||||
// Pulse the live dot at ~1Hz.
|
||||
const pulsePhase = (localFrame - 60) / 30;
|
||||
const livePulse = 0.6 + 0.4 * Math.sin(pulsePhase * Math.PI * 2);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: `translate(-50%, calc(-50% + ${y}px)) scale(${scale})`,
|
||||
opacity: progress,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 480,
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1.5px solid ${C.accent}`,
|
||||
borderRadius: 16,
|
||||
padding: '24px 28px',
|
||||
boxShadow: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`,
|
||||
}}
|
||||
>
|
||||
{/* Header row: title + live dot */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 20,
|
||||
color: C.fg,
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
notion-search
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 12,
|
||||
color: C.success,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: C.success,
|
||||
boxShadow: `0 0 ${10 * livePulse}px ${C.success}`,
|
||||
opacity: 0.5 + 0.5 * livePulse,
|
||||
}}
|
||||
/>
|
||||
live
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool rows */}
|
||||
<ToolRow name="search_pages" desc="full-text query" />
|
||||
<div style={{ height: 8 }} />
|
||||
<ToolRow name="get_page_content" desc="fetch by id" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({ name, desc }: { name: string; desc: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 14px',
|
||||
backgroundColor: C.bgSubtle,
|
||||
border: `1px solid ${C.border}`,
|
||||
borderRadius: 10,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: C.accent }} />
|
||||
<span style={{ color: C.fg, fontSize: 16, minWidth: 200 }}>{name}</span>
|
||||
<span style={{ color: C.fgSubtle, fontSize: 14, flex: 1 }}>{desc}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
remotion/src/scenes/DiscoveryScene.tsx
Normal file
295
remotion/src/scenes/DiscoveryScene.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
|
||||
|
||||
// Phase 4 (frames 240–300 global → localFrame 0..60): the user's card is
|
||||
// the hero. A fork count ticks 0 → 247 with micro-particles. A subtitle
|
||||
// pops in: "1,200+ developers building".
|
||||
//
|
||||
// We keep the same grid layout as Phase 3 (so the transition is a
|
||||
// crossfade in place) but zoom slightly toward the hero card and emphasize
|
||||
// it. The fork counter sits on the hero card.
|
||||
|
||||
const GRID_COLS = 3;
|
||||
const GRID_ROWS = 2;
|
||||
const CARD_W = 340;
|
||||
const CARD_H = 180;
|
||||
const GAP = 28;
|
||||
|
||||
const CARDS = [
|
||||
{ name: 'github-issues', toolCount: 3, highlighted: false },
|
||||
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
|
||||
{ name: 'slack-digest', toolCount: 4, highlighted: false },
|
||||
{ name: 'linear-tasks', toolCount: 5, highlighted: false },
|
||||
{ name: 'gmail-triage', toolCount: 3, highlighted: false }, // demote — only the user's is the star
|
||||
{ name: 'jira-sprint', toolCount: 6, highlighted: false },
|
||||
];
|
||||
|
||||
const TARGET_FORKS = 247;
|
||||
|
||||
export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||
// Slow zoom toward the hero card.
|
||||
const zoom = clampLerp(localFrame, 0, 50);
|
||||
const scale = interpolate(zoom, [0, 1], [1.0, 1.08]);
|
||||
|
||||
// Fork count ticks 0 → 247 over ~36 frames starting at localFrame 6.
|
||||
const tickProgress = clampLerp(localFrame, 6, 42);
|
||||
const forkCount = Math.floor(tickProgress * TARGET_FORKS);
|
||||
|
||||
// Subtitle "1,200+ developers building" pops at localFrame ~28.
|
||||
const subIn = softSpring(localFrame, fps, 28, 18);
|
||||
|
||||
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
|
||||
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
|
||||
const gridLeft = (1920 - gridW) / 2;
|
||||
const gridTop = (1080 - gridH) / 2 + 30;
|
||||
|
||||
// Hero card position (index 1: col=1, row=0)
|
||||
const heroX = gridLeft + 1 * (CARD_W + GAP);
|
||||
const heroY = gridTop + 0 * (CARD_H + GAP);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* Section caption (matches Phase 3 caption position) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: gridTop - 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 16,
|
||||
letterSpacing: 5,
|
||||
textTransform: 'uppercase',
|
||||
color: C.fgSubtle,
|
||||
}}
|
||||
>
|
||||
template library
|
||||
</div>
|
||||
|
||||
{/* Zoom group — scales everything around the hero card center */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: `${heroX + CARD_W / 2}px ${heroY + CARD_H / 2}px`,
|
||||
}}
|
||||
>
|
||||
{CARDS.map((card, i) => {
|
||||
const col = i % GRID_COLS;
|
||||
const row = Math.floor(i / GRID_COLS);
|
||||
const x = gridLeft + col * (CARD_W + GAP);
|
||||
const y = gridTop + row * (CARD_H + GAP);
|
||||
|
||||
const isHero = i === 1;
|
||||
// Non-hero cards desaturate slightly as the camera focuses.
|
||||
const dim = !isHero ? interpolate(zoom, [0, 1], [1, 0.55]) : 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: CARD_W,
|
||||
height: CARD_H,
|
||||
opacity: dim,
|
||||
}}
|
||||
>
|
||||
<TemplateCardInner card={card} isHero={isHero} forkCount={isHero ? forkCount : null} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Fork-tick micro-particles around the hero card */}
|
||||
<ForkParticles
|
||||
localFrame={localFrame}
|
||||
x={heroX + CARD_W - 22}
|
||||
y={heroY + CARD_H - 22}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle "1,200+ developers building" — bottom of frame */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 90,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 22,
|
||||
color: C.fgMuted,
|
||||
letterSpacing: 1.5,
|
||||
opacity: subIn,
|
||||
transform: `translateY(${interpolate(subIn, [0, 1], [10, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: C.fg, fontWeight: 600 }}>1,200+</span> developers building
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCardInner({
|
||||
card,
|
||||
isHero,
|
||||
forkCount,
|
||||
}: {
|
||||
card: typeof CARDS[number];
|
||||
isHero: boolean;
|
||||
forkCount: number | null;
|
||||
}) {
|
||||
const border = isHero ? C.accent : C.border;
|
||||
const shadow = isHero
|
||||
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
|
||||
: `0 8px 24px rgba(0,0,0,0.35)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1.5px solid ${border}`,
|
||||
borderRadius: 14,
|
||||
padding: '16px 20px',
|
||||
boxShadow: shadow,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 15,
|
||||
color: C.fg,
|
||||
}}
|
||||
>
|
||||
{card.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
backgroundColor: isHero ? C.success : C.fgSubtle,
|
||||
opacity: isHero ? 1 : 0.5,
|
||||
boxShadow: isHero ? `0 0 8px ${C.success}` : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
style={{
|
||||
height: 7,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: C.bgSubtle,
|
||||
width: `${[88, 64, 76][j]}%`,
|
||||
border: `1px solid ${C.border}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 11,
|
||||
color: C.fgSubtle,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
<span>{card.toolCount} tools</span>
|
||||
{isHero && forkCount !== null ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: C.accent,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<ForkIcon />
|
||||
{forkCount.toString()}
|
||||
</span>
|
||||
) : (
|
||||
<span>{isHero ? '★ featured' : 'template'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForkIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="4" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" />
|
||||
<circle cx="12" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" />
|
||||
<circle cx="8" cy="13" r="1.6" stroke={C.accent} strokeWidth="1.4" />
|
||||
<path d="M 4 4.6 L 4 7 Q 4 8 5 8 L 11 8 Q 12 8 12 7 L 12 4.6" stroke={C.accent} strokeWidth="1.4" fill="none" />
|
||||
<path d="M 8 8 L 8 11.4" stroke={C.accent} strokeWidth="1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ForkParticles({
|
||||
localFrame,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
localFrame: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
// Emit a particle every ~3 frames during the tick window (frames 6-42).
|
||||
// Each particle lives ~14 frames, drifting up and fading out.
|
||||
const PARTICLES = 12;
|
||||
const EMIT_START = 6;
|
||||
const EMIT_INTERVAL = 3;
|
||||
const PARTICLE_LIFE = 16;
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < PARTICLES; i++) {
|
||||
const birth = EMIT_START + i * EMIT_INTERVAL;
|
||||
const age = localFrame - birth;
|
||||
if (age < 0 || age > PARTICLE_LIFE) continue;
|
||||
const t = age / PARTICLE_LIFE;
|
||||
const opacity = (1 - t) * 0.9;
|
||||
const r = rand(i * 31);
|
||||
const dx = (r - 0.5) * 50;
|
||||
const dy = -36 * t - 6;
|
||||
const size = 4 + r * 3;
|
||||
out.push(
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x + dx,
|
||||
top: y + dy,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: C.accent,
|
||||
opacity,
|
||||
boxShadow: `0 0 8px ${C.accentGlow}`,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return <>{out}</>;
|
||||
}
|
||||
250
remotion/src/scenes/LibraryScene.tsx
Normal file
250
remotion/src/scenes/LibraryScene.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
|
||||
|
||||
// Phase 3 (frames 165–246 global → localFrame 0..81): the server card pulls
|
||||
// back / scales down and multiplies into a 3×2 grid of template cards.
|
||||
//
|
||||
// We "zoom out" by starting with a single big card centered (matches the
|
||||
// position where Phase 2 left it) and animating it to grid-slot (1,0)
|
||||
// while five sibling cards fade in around it.
|
||||
|
||||
const GRID_COLS = 3;
|
||||
const GRID_ROWS = 2;
|
||||
const CARD_W = 340;
|
||||
const CARD_H = 180;
|
||||
const GAP = 28;
|
||||
|
||||
// Card metadata. The "hero" card (index 1, top-center) corresponds to the
|
||||
// server the user just built. It stays highlighted with the indigo border.
|
||||
// Index 0 and 4 are also highlighted to suggest "popular templates".
|
||||
const CARDS = [
|
||||
{ name: 'github-issues', toolCount: 3, highlighted: false },
|
||||
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
|
||||
{ name: 'slack-digest', toolCount: 4, highlighted: false },
|
||||
{ name: 'linear-tasks', toolCount: 5, highlighted: false },
|
||||
{ name: 'gmail-triage', toolCount: 3, highlighted: true },
|
||||
{ name: 'jira-sprint', toolCount: 6, highlighted: false },
|
||||
];
|
||||
|
||||
export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||
// The caption above the grid.
|
||||
const captionIn = clampLerp(localFrame, 20, 36);
|
||||
|
||||
// Hero card animation — starts at its previous-scene location (centered,
|
||||
// big) and morphs to grid slot 1.
|
||||
const heroProgress = softSpring(localFrame, fps, 0, 30);
|
||||
|
||||
// The grid is centered. Compute slot positions.
|
||||
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
|
||||
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
|
||||
const gridLeft = (1920 - gridW) / 2;
|
||||
const gridTop = (1080 - gridH) / 2 + 30; // nudge down a bit for the caption
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* Caption */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: gridTop - 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 16,
|
||||
letterSpacing: 5,
|
||||
textTransform: 'uppercase',
|
||||
color: C.fgSubtle,
|
||||
opacity: captionIn,
|
||||
transform: `translateY(${interpolate(captionIn, [0, 1], [10, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
template library
|
||||
</div>
|
||||
|
||||
{CARDS.map((card, i) => {
|
||||
const col = i % GRID_COLS;
|
||||
const row = Math.floor(i / GRID_COLS);
|
||||
const targetX = gridLeft + col * (CARD_W + GAP);
|
||||
const targetY = gridTop + row * (CARD_H + GAP);
|
||||
|
||||
const isHero = i === 1;
|
||||
|
||||
if (isHero) {
|
||||
// Hero card morphs from "big centered" to its grid slot.
|
||||
const startW = 480;
|
||||
const startH = 220;
|
||||
const startX = (1920 - startW) / 2;
|
||||
const startY = (1080 - startH) / 2 + 90; // matches BuildScene y=180 offset
|
||||
const w = interpolate(heroProgress, [0, 1], [startW, CARD_W]);
|
||||
const h = interpolate(heroProgress, [0, 1], [startH, CARD_H]);
|
||||
const x = interpolate(heroProgress, [0, 1], [startX, targetX]);
|
||||
const y = interpolate(heroProgress, [0, 1], [startY, targetY]);
|
||||
return (
|
||||
<TemplateCard
|
||||
key={i}
|
||||
card={card}
|
||||
x={x}
|
||||
y={y}
|
||||
w={w}
|
||||
h={h}
|
||||
opacity={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Sibling cards fade in staggered.
|
||||
const delay = 16 + i * 3;
|
||||
const sib = springIn(localFrame, fps, delay);
|
||||
const opacity = clampLerp(localFrame, delay, delay + 14);
|
||||
// Slight scale-in from 0.92.
|
||||
const scale = interpolate(sib, [0, 1], [0.92, 1]);
|
||||
// Use deterministic randomness for tiny drift-in offsets.
|
||||
const drift = (rand(i + 7) - 0.5) * 14;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: targetX,
|
||||
top: targetY + drift * (1 - sib),
|
||||
width: CARD_W,
|
||||
height: CARD_H,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<TemplateCardInner card={card} w={CARD_W} h={CARD_H} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
card,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
opacity,
|
||||
}: {
|
||||
card: typeof CARDS[number];
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
opacity: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: w,
|
||||
height: h,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<TemplateCardInner card={card} w={w} h={h} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCardInner({
|
||||
card,
|
||||
w,
|
||||
h,
|
||||
}: {
|
||||
card: typeof CARDS[number];
|
||||
w: number;
|
||||
h: number;
|
||||
}) {
|
||||
const border = card.highlighted ? C.accent : C.border;
|
||||
const shadow = card.highlighted
|
||||
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
|
||||
: `0 8px 24px rgba(0,0,0,0.35)`;
|
||||
|
||||
// Pad inside scales mildly with card size (since hero is bigger).
|
||||
const padX = Math.max(18, w * 0.06);
|
||||
const padY = Math.max(16, h * 0.08);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1.5px solid ${border}`,
|
||||
borderRadius: 14,
|
||||
padding: `${padY}px ${padX}px`,
|
||||
boxShadow: shadow,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Top: title + indicator dot */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: w > 380 ? 18 : 15,
|
||||
color: C.fg,
|
||||
letterSpacing: 0.1,
|
||||
}}
|
||||
>
|
||||
{card.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
backgroundColor: card.highlighted ? C.success : C.fgSubtle,
|
||||
opacity: card.highlighted ? 1 : 0.5,
|
||||
boxShadow: card.highlighted ? `0 0 8px ${C.success}` : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle: faux tool bars */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{Array.from({ length: Math.min(3, card.toolCount) }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
style={{
|
||||
height: 7,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: C.bgSubtle,
|
||||
width: `${[88, 64, 76][j]}%`,
|
||||
border: `1px solid ${C.border}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom: tool count + status */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: w > 380 ? 13 : 11,
|
||||
color: C.fgSubtle,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
<span>{card.toolCount} tools</span>
|
||||
<span style={{ color: card.highlighted ? C.accent : C.fgSubtle }}>
|
||||
{card.highlighted ? '★ featured' : 'template'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,33 +1,36 @@
|
||||
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
||||
import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, clampLerp } from '../lib/easings';
|
||||
|
||||
// "Search our Notion workspace" — short, recognisable, fits one line at the
|
||||
// monospace size we're using. Each word springs in from below with a brief
|
||||
// indigo under-glow that fades as it settles.
|
||||
const PROMPT_WORDS = ['Search', 'our', 'Notion', 'workspace'];
|
||||
const WORD_STAGGER = 9; // frames between each word's entrance
|
||||
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.';
|
||||
|
||||
export function PromptScene() {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
// Phase 1: realistic text-input field. Cursor blinks, prompt types in
|
||||
// character by character (~20 chars/sec at 30fps = 1.5 frames/char), and a
|
||||
// "press Enter to build →" hint lights up once typing completes.
|
||||
|
||||
// Whole scene fades out after frame 55 so it dissolves into Transform.
|
||||
// The collapse: between frame 50-70 all four words scale down toward
|
||||
// their shared geometric center (960, 540) and fade. Visually the
|
||||
// prompt "drops" into a single bright point that Beat 2 will explode
|
||||
// from — Beat 2's particle origin matches this same point.
|
||||
const sceneOut = interpolate(frame, [60, 70], [1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const collapseT = interpolate(frame, [50, 68], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
// Ease the collapse so it accelerates inward at the very end.
|
||||
const collapseEase = collapseT * collapseT;
|
||||
const collapseScale = interpolate(collapseEase, [0, 1], [1, 0.2]);
|
||||
export function PromptScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||
const inputIn = springIn(localFrame, fps, 0);
|
||||
const labelIn = clampLerp(localFrame, 4, 16);
|
||||
|
||||
// Type ~20 cps: floor(frame / 1.5)
|
||||
const typedChars = Math.max(0, Math.floor(localFrame / 1.5));
|
||||
const typed = PROMPT_TEXT.slice(0, typedChars);
|
||||
const typingDoneAt = PROMPT_TEXT.length * 1.5;
|
||||
const typingDone = localFrame >= typingDoneAt;
|
||||
|
||||
// Hint appears once typing is done.
|
||||
const hintIn = clampLerp(localFrame, typingDoneAt - 4, typingDoneAt + 10);
|
||||
|
||||
// Caret blinks at 0.5Hz while idle; stays solid while typing.
|
||||
const caretVisible = !typingDone || Math.floor(localFrame / 15) % 2 === 0;
|
||||
|
||||
// Exit motion: drifts upward slightly in last 12 frames (opacity is the
|
||||
// parent crossfade — we just add a tiny lift so it feels purposeful).
|
||||
const exitProgress = clampLerp(localFrame, 75, 81);
|
||||
const exitY = interpolate(exitProgress, [0, 1], [0, -60]);
|
||||
const exitScale = interpolate(exitProgress, [0, 1], [1, 0.95]);
|
||||
|
||||
const enterScale = interpolate(inputIn, [0, 1], [0.92, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -35,88 +38,99 @@ export function PromptScene() {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: sceneOut,
|
||||
transform: `translateY(${exitY}px) scale(${exitScale})`,
|
||||
}}
|
||||
>
|
||||
{/* prompt.txt label — sits above the prompt as a file tag */}
|
||||
{/* Label above input */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: 460,
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 16,
|
||||
letterSpacing: '0.18em',
|
||||
fontSize: 18,
|
||||
letterSpacing: 4,
|
||||
textTransform: 'uppercase',
|
||||
color: C.fgSubtle,
|
||||
opacity: clampLerp(frame, 0, 8),
|
||||
marginBottom: 28,
|
||||
opacity: labelIn,
|
||||
transform: `translateY(${interpolate(labelIn, [0, 1], [8, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
prompt.txt
|
||||
describe your tool
|
||||
</div>
|
||||
|
||||
{/* Input field */}
|
||||
<div
|
||||
style={{
|
||||
width: 1100,
|
||||
height: 92,
|
||||
borderRadius: 16,
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1px solid ${typingDone ? C.accent : C.borderStrong}`,
|
||||
boxShadow: typingDone
|
||||
? `0 0 0 4px ${C.accentGlow}, 0 20px 60px rgba(0,0,0,0.55)`
|
||||
: `0 20px 60px rgba(0,0,0,0.55)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 32,
|
||||
paddingRight: 32,
|
||||
opacity: inputIn,
|
||||
transform: `scale(${enterScale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 28,
|
||||
color: C.accent,
|
||||
marginRight: 20,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
›
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 28,
|
||||
color: C.fg,
|
||||
whiteSpace: 'pre',
|
||||
letterSpacing: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<span>{typed}</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 3,
|
||||
height: 32,
|
||||
backgroundColor: C.accent,
|
||||
marginLeft: 4,
|
||||
opacity: caretVisible ? 1 : 0,
|
||||
boxShadow: `0 0 10px ${C.accentGlow}`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint below input */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 56,
|
||||
fontWeight: 600,
|
||||
color: C.fg,
|
||||
letterSpacing: '-0.01em',
|
||||
display: 'flex',
|
||||
gap: '18px',
|
||||
// Collapse the entire word row toward its center point (which is
|
||||
// canvas center 960,540 because the parent flex is fullscreen-
|
||||
// centered). transform-origin: center makes all four words pull
|
||||
// into a single bright point right before Beat 2's explosion.
|
||||
transform: `scale(${collapseScale})`,
|
||||
transformOrigin: 'center center',
|
||||
filter: collapseT > 0
|
||||
? `drop-shadow(0 0 ${12 + collapseEase * 28}px ${C.accentGlow})`
|
||||
: undefined,
|
||||
fontSize: 16,
|
||||
color: typingDone ? C.fgMuted : C.fgSubtle,
|
||||
marginTop: 28,
|
||||
opacity: hintIn,
|
||||
transform: `translateY(${interpolate(hintIn, [0, 1], [-6, 0])}px)`,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{PROMPT_WORDS.map((word, i) => {
|
||||
const delay = 6 + i * WORD_STAGGER;
|
||||
const t = springIn(frame, fps, delay);
|
||||
// Translate from +28px below, settling to 0. Slight rotate adds
|
||||
// mechanical-feel, undone as it settles.
|
||||
const y = interpolate(t, [0, 1], [28, 0]);
|
||||
const rotate = interpolate(t, [0, 1], [4, 0]);
|
||||
// Brief under-glow: rises with entrance, fades 12 frames later.
|
||||
const glowEnter = clampLerp(frame, delay, delay + 6);
|
||||
const glowFade = 1 - clampLerp(frame, delay + 8, delay + 22);
|
||||
const glowOpacity = Math.min(glowEnter, glowFade);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={word}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
transform: `translateY(${y}px) rotate(${rotate}deg)`,
|
||||
opacity: t,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -6,
|
||||
height: 3,
|
||||
background: `linear-gradient(90deg, transparent, ${C.accent}, transparent)`,
|
||||
opacity: glowOpacity,
|
||||
borderRadius: 2,
|
||||
filter: `blur(3px)`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
press Enter to build →
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,255 +0,0 @@
|
||||
import { useCurrentFrame, interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { clampLerp, easeInOut, softSpring } from '../lib/easings';
|
||||
import { BEAT } from '../HeroVideo';
|
||||
|
||||
// Beat 3 — Living server connected to a client.
|
||||
//
|
||||
// The server schematic from Beat 2 stays anchored. A "Claude" client
|
||||
// panel slides in from the right. A Bézier wire animates between them.
|
||||
// Three data-pulse dots travel back and forth along the wire. A "200"
|
||||
// status tag flashes briefly. Live-dot in the server's top-right corner
|
||||
// pulses through the whole scene.
|
||||
|
||||
const CX = 760;
|
||||
const CY = 540;
|
||||
const SERVER_W = 600;
|
||||
const SERVER_H = 360;
|
||||
|
||||
// Claude client panel — anchored right of the server
|
||||
const CLIENT_W = 360;
|
||||
const CLIENT_H = 240;
|
||||
const CLIENT_CX = 1480;
|
||||
const CLIENT_CY = 540;
|
||||
|
||||
export function ServerScene({ fps }: { fps: number }) {
|
||||
const frame = useCurrentFrame();
|
||||
const local = frame - BEAT.server.in; // 0..90
|
||||
|
||||
// Scene fade-in (over previous scene)
|
||||
const sceneIn = clampLerp(frame, BEAT.server.in, BEAT.server.in + 6);
|
||||
|
||||
// Client panel slides in from x=+1920 to its anchor
|
||||
const clientSlide = softSpring(frame, fps, BEAT.server.in + 4, 30);
|
||||
const clientX = interpolate(clientSlide, [0, 1], [1920 + CLIENT_W, CLIENT_CX]);
|
||||
|
||||
// Wire stroke-on (after client lands)
|
||||
const wireT = easeInOut(clampLerp(local, 22, 50));
|
||||
|
||||
// Data-pulse positions along the wire path — three packets, staggered
|
||||
const pulse1 = ((local - 40) * 0.025) % 1;
|
||||
const pulse2 = ((local - 50) * 0.025) % 1;
|
||||
const pulse3 = ((local - 60) * 0.025) % 1;
|
||||
|
||||
// "200 OK" tag — pops once at local frame 52, fades over 18 frames
|
||||
const okT = clampLerp(local, 52, 60);
|
||||
const okFade = 1 - clampLerp(local, 70, 84);
|
||||
const okAlpha = Math.min(okT, okFade);
|
||||
|
||||
// Live-dot pulse — pulses at 1Hz
|
||||
const liveDot = 0.6 + 0.4 * Math.sin(local * 0.21);
|
||||
|
||||
// Wire path — Bézier from server right-edge to client left-edge
|
||||
const wireStart = { x: CX + SERVER_W / 2, y: CY };
|
||||
const wireEnd = { x: CLIENT_CX - CLIENT_W / 2 + clientX - CLIENT_CX, y: CLIENT_CY };
|
||||
const ctrl1 = { x: wireStart.x + 80, y: wireStart.y };
|
||||
const ctrl2 = { x: wireEnd.x - 80, y: wireEnd.y };
|
||||
const wirePath = `M ${wireStart.x} ${wireStart.y} C ${ctrl1.x} ${ctrl1.y}, ${ctrl2.x} ${ctrl2.y}, ${wireEnd.x} ${wireEnd.y}`;
|
||||
|
||||
// Compute pulse position on Bézier — sample the cubic
|
||||
const pulsePos = (t: number) => {
|
||||
const tt = Math.max(0, Math.min(1, t));
|
||||
const u = 1 - tt;
|
||||
const x =
|
||||
u * u * u * wireStart.x +
|
||||
3 * u * u * tt * ctrl1.x +
|
||||
3 * u * tt * tt * ctrl2.x +
|
||||
tt * tt * tt * wireEnd.x;
|
||||
const y =
|
||||
u * u * u * wireStart.y +
|
||||
3 * u * u * tt * ctrl1.y +
|
||||
3 * u * tt * tt * ctrl2.y +
|
||||
tt * tt * tt * wireEnd.y;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: sceneIn }}>
|
||||
{/* Server schematic — same as Transform end state, holds its position */}
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
width={SERVER_W}
|
||||
height={SERVER_H}
|
||||
rx={6}
|
||||
fill={C.bgElevated}
|
||||
stroke={C.accent}
|
||||
strokeWidth={2}
|
||||
opacity={0.95}
|
||||
/>
|
||||
{/* Inner panel — slightly subtler */}
|
||||
<rect
|
||||
x={CX - SERVER_W / 2 + 6}
|
||||
y={CY - SERVER_H / 2 + 6}
|
||||
width={SERVER_W - 12}
|
||||
height={SERVER_H - 12}
|
||||
rx={4}
|
||||
fill={C.bgSubtle}
|
||||
opacity={0.6}
|
||||
/>
|
||||
|
||||
{/* Tool rows inside server */}
|
||||
{[0, 1, 2].map((r) => {
|
||||
const y = CY - 90 + r * 90;
|
||||
return (
|
||||
<g key={r}>
|
||||
<line
|
||||
x1={CX - SERVER_W / 2 + 24}
|
||||
y1={y}
|
||||
x2={CX + SERVER_W / 2 - 24}
|
||||
y2={y}
|
||||
stroke={C.borderStrong}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle cx={CX - SERVER_W / 2 + 30} cy={y} r={3} fill={C.accent} opacity={0.9} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Live dot */}
|
||||
<circle
|
||||
cx={CX + SERVER_W / 2 - 14}
|
||||
cy={CY - SERVER_H / 2 + 14}
|
||||
r={5}
|
||||
fill={C.success}
|
||||
opacity={liveDot}
|
||||
style={{ filter: `drop-shadow(0 0 6px ${C.success}88)` }}
|
||||
/>
|
||||
|
||||
{/* Wire — drawn with dash-offset progression */}
|
||||
<path
|
||||
d={wirePath}
|
||||
fill="none"
|
||||
stroke={C.accent}
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={400}
|
||||
strokeDashoffset={(1 - wireT) * 400}
|
||||
/>
|
||||
|
||||
{/* Data pulses traveling on wire — only after wire is fully drawn */}
|
||||
{wireT > 0.95 &&
|
||||
[pulse1, pulse2, pulse3].map((p, i) => {
|
||||
if (p < 0 || p > 1) return null;
|
||||
const pos = pulsePos(p);
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={4}
|
||||
fill={C.accent}
|
||||
opacity={0.95}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${C.accent})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Client panel — Claude Desktop style */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: clientX - CLIENT_W / 2,
|
||||
top: CLIENT_CY - CLIENT_H / 2,
|
||||
width: CLIENT_W,
|
||||
height: CLIENT_H,
|
||||
borderRadius: 8,
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1px solid ${C.borderStrong}`,
|
||||
padding: '18px 20px',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.16em',
|
||||
textTransform: 'uppercase',
|
||||
color: C.fgSubtle,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
claude desktop
|
||||
</div>
|
||||
<div style={{ height: 1, background: C.border, marginBottom: 16 }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: C.fgMuted,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
search "Q3 strategy"
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 12,
|
||||
color: C.fg,
|
||||
}}
|
||||
>
|
||||
→ 3 pages found
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 14,
|
||||
left: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
color: C.success,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: C.success,
|
||||
boxShadow: `0 0 6px ${C.success}`,
|
||||
}}
|
||||
/>
|
||||
connected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 200 OK tag — flashes above the wire midway through */}
|
||||
{okAlpha > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: (wireStart.x + wireEnd.x) / 2 - 30,
|
||||
top: wireStart.y - 40 - okT * 8,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.18)',
|
||||
border: `1px solid ${C.success}`,
|
||||
color: C.success,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 12,
|
||||
opacity: okAlpha,
|
||||
}}
|
||||
>
|
||||
200 OK
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,286 +0,0 @@
|
||||
import { useCurrentFrame, useVideoConfig, interpolate, spring } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { rand, clampLerp, easeInOut } from '../lib/easings';
|
||||
import { BEAT } from '../HeroVideo';
|
||||
|
||||
// Beat 2 — the wow moment.
|
||||
//
|
||||
// The collapsed prompt point at (960, 540) detonates RADIALLY into ~60
|
||||
// glowing particles that scatter spherically, then magnetically snap into
|
||||
// target slots along a SERVER SCHEMATIC. Particles are supporting players:
|
||||
// small enough that the schematic — the thing being built — reads as the
|
||||
// primary subject. Schematic strokes on IN PARALLEL with the convergence
|
||||
// so the eye always has something to anchor to.
|
||||
|
||||
const PARTICLE_COUNT = 60;
|
||||
|
||||
const SERVER_W = 720;
|
||||
const SERVER_H = 420;
|
||||
const CX = 960;
|
||||
const CY = 540;
|
||||
|
||||
function targetSlot(i: number) {
|
||||
const N = PARTICLE_COUNT;
|
||||
if (i < N / 2) {
|
||||
// perimeter walk
|
||||
const t = i / (N / 2);
|
||||
const perim = 2 * (SERVER_W + SERVER_H);
|
||||
const d = t * perim;
|
||||
const left = CX - SERVER_W / 2;
|
||||
const top = CY - SERVER_H / 2;
|
||||
let px = left;
|
||||
let py = top;
|
||||
if (d < SERVER_W) { px = left + d; py = top; }
|
||||
else if (d < SERVER_W + SERVER_H) { px = left + SERVER_W; py = top + (d - SERVER_W); }
|
||||
else if (d < 2 * SERVER_W + SERVER_H) { px = left + SERVER_W - (d - SERVER_W - SERVER_H); py = top + SERVER_H; }
|
||||
else { px = left; py = top + SERVER_H - (d - 2 * SERVER_W - SERVER_H); }
|
||||
return { x: px, y: py };
|
||||
}
|
||||
// Inside the box: three tool rows
|
||||
const j = i - N / 2;
|
||||
const perRow = Math.ceil(N / 2 / 3);
|
||||
const row = Math.floor(j / perRow);
|
||||
const col = (j % perRow) / Math.max(1, perRow - 1);
|
||||
const rowY = CY - 90 + row * 90;
|
||||
const rowX = CX - SERVER_W / 2 + 50 + col * (SERVER_W - 100);
|
||||
return { x: rowX, y: rowY };
|
||||
}
|
||||
|
||||
export function TransformScene() {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const local = frame - BEAT.transform.in;
|
||||
|
||||
const sceneIn = clampLerp(frame, BEAT.transform.in, BEAT.transform.in + 6);
|
||||
const sceneOut = 1 - clampLerp(frame, BEAT.transform.out - 8, BEAT.transform.out);
|
||||
const sceneAlpha = Math.min(sceneIn, sceneOut);
|
||||
|
||||
// Schematic strokes on IN PARALLEL with the convergence — starts at
|
||||
// local 8 instead of 30 so the box is visible before particles arrive.
|
||||
const strokeT = easeInOut(clampLerp(local, 8, 55));
|
||||
// Ports + rows show up as schematic completes
|
||||
const innerT = clampLerp(local, 35, 65);
|
||||
const portPulse = clampLerp(local, 55, 90);
|
||||
|
||||
// Scan-line — diagonal pass once the schematic is fully drawn
|
||||
const scanT = clampLerp(local, 55, 90);
|
||||
|
||||
// Central core glow — visible throughout Beat 2 so the eye has an
|
||||
// anchor even when particles are mid-flight. Pulses softly.
|
||||
const coreAlpha = clampLerp(local, 0, 12) * (1 - clampLerp(local, 95, 110) * 0.4);
|
||||
const corePulse = 1 + 0.25 * Math.sin(local * 0.18);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
|
||||
{/* Central core — a hint of radial glow at the explosion origin.
|
||||
Toned down from earlier versions so the schematic, not the core,
|
||||
carries the visual weight. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: CX - 110,
|
||||
top: CY - 110,
|
||||
width: 220,
|
||||
height: 220,
|
||||
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
|
||||
opacity: coreAlpha * 0.45,
|
||||
transform: `scale(${corePulse})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Particles — 60 small glowing dots radiating from a single origin.
|
||||
They support the schematic; they do not dominate it. */}
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
|
||||
// SINGLE-POINT ORIGIN. All 60 particles start at canvas center —
|
||||
// the exact point Beat 1's prompt just collapsed into. This is
|
||||
// what makes the explosion read as radial/spherical instead of
|
||||
// horizontal.
|
||||
const originX = CX;
|
||||
const originY = CY;
|
||||
|
||||
const slot = targetSlot(i);
|
||||
// Velocity vectors — even spherical distribution. Golden-angle
|
||||
// stratification of `i` plus a small jitter prevents the visible
|
||||
// banding you'd get from a pure uniform-random angle on 60 dots.
|
||||
const goldenAngle = (i * 2.39996323) % (Math.PI * 2);
|
||||
const jitter = (rand(i * 1.71) - 0.5) * 0.35;
|
||||
const angle = goldenAngle + jitter;
|
||||
const speed = 220 + rand(i * 4.13) * 320;
|
||||
const vx = Math.cos(angle) * speed;
|
||||
const vy = Math.sin(angle) * speed;
|
||||
|
||||
const explode = clampLerp(local, 0, 22);
|
||||
// Pull — slower, more deliberate. Inlined spring so we can set
|
||||
// the exact damping/mass/stiffness/duration the scene needs
|
||||
// without bloating the easings module.
|
||||
const pull = spring({
|
||||
frame: frame - (BEAT.transform.in + 22),
|
||||
fps,
|
||||
config: { damping: 25, mass: 1.3, stiffness: 55 },
|
||||
durationInFrames: 60,
|
||||
});
|
||||
|
||||
const driftX = originX + vx * explode * (1 - pull);
|
||||
const driftY = originY + vy * explode * (1 - pull);
|
||||
const x = driftX + (slot.x - driftX) * pull;
|
||||
const y = driftY + (slot.y - driftY) * pull;
|
||||
|
||||
// Radius: 4→2 as particles lock in. Smaller than v2 so the
|
||||
// schematic carries primary visual weight.
|
||||
const r = interpolate(pull, [0, 1], [4, 2]);
|
||||
const color = C.accent;
|
||||
const alpha = clampLerp(local, 0, 4);
|
||||
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={r}
|
||||
fill={color}
|
||||
opacity={alpha * fadeOut * 0.9}
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Server schematic */}
|
||||
<svg
|
||||
width={1920}
|
||||
height={1080}
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="scanline" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset={`${Math.max(0, scanT * 100 - 6)}%`} stopColor={C.accent} stopOpacity="0" />
|
||||
<stop offset={`${scanT * 100}%`} stopColor={C.accent} stopOpacity="0.95" />
|
||||
<stop offset={`${Math.min(100, scanT * 100 + 6)}%`} stopColor={C.accent} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Inner panel — fills earlier and darker so the schematic reads
|
||||
as a solid object the particles are building, not a wireframe
|
||||
sketch. Reaches 0.9 opacity by the time the strokes complete. */}
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
width={SERVER_W}
|
||||
height={SERVER_H}
|
||||
rx={8}
|
||||
fill={C.bgElevated}
|
||||
opacity={Math.min(0.9, strokeT * 1.5)}
|
||||
/>
|
||||
|
||||
{/* Outer rectangle stroke — wider and with a heavier drop-shadow
|
||||
so the chassis outline is the dominant element on screen. */}
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
width={SERVER_W}
|
||||
height={SERVER_H}
|
||||
rx={8}
|
||||
fill="none"
|
||||
stroke={C.accent}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={2 * (SERVER_W + SERVER_H)}
|
||||
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
|
||||
opacity={0.98}
|
||||
style={{ filter: `drop-shadow(0 0 16px ${C.accentGlow}) drop-shadow(0 0 4px ${C.accentGlow})` }}
|
||||
/>
|
||||
|
||||
{/* Three internal tool rows */}
|
||||
{[0, 1, 2].map((r) => {
|
||||
const y = CY - 90 + r * 90;
|
||||
return (
|
||||
<g key={r} opacity={innerT}>
|
||||
<line
|
||||
x1={CX - SERVER_W / 2 + 40}
|
||||
y1={y}
|
||||
x2={CX + SERVER_W / 2 - 40}
|
||||
y2={y}
|
||||
stroke={C.borderStrong}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle cx={CX - SERVER_W / 2 + 50} cy={y} r={5} fill={C.accent} opacity={0.95}
|
||||
style={{ filter: `drop-shadow(0 0 4px ${C.accentGlow})` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Port dots */}
|
||||
{[-1, 1].map((side) =>
|
||||
[-1, 0, 1].map((off) => (
|
||||
<circle
|
||||
key={`${side}-${off}`}
|
||||
cx={CX + (SERVER_W / 2) * side}
|
||||
cy={CY + off * 90}
|
||||
r={6}
|
||||
fill={C.accent}
|
||||
opacity={portPulse * (0.7 + 0.3 * Math.sin(local * 0.3 + off))}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
|
||||
{/* Scan-line sweep */}
|
||||
{scanT > 0 && scanT < 1 && (
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
width={SERVER_W}
|
||||
height={SERVER_H}
|
||||
rx={8}
|
||||
fill="url(#scanline)"
|
||||
opacity={0.65}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Corner labels — bigger and earlier */}
|
||||
<CornerLabel x={CX - SERVER_W / 2} y={CY - SERVER_H / 2 - 36} text="mcp-notion" appearAt={local} delay={42} />
|
||||
<CornerLabel x={CX + SERVER_W / 2} y={CY - SERVER_H / 2 - 36} text="OAuth 2.1" appearAt={local} delay={48} align="right" />
|
||||
<CornerLabel x={CX - SERVER_W / 2} y={CY + SERVER_H / 2 + 16} text="search_pages" appearAt={local} delay={54} />
|
||||
<CornerLabel x={CX + SERVER_W / 2} y={CY + SERVER_H / 2 + 16} text="get_page_content" appearAt={local} delay={60} align="right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CornerLabel({
|
||||
x, y, text, appearAt, delay, align = 'left',
|
||||
}: {
|
||||
x: number; y: number; text: string; appearAt: number; delay: number; align?: 'left' | 'right';
|
||||
}) {
|
||||
const t = clampLerp(appearAt, delay, delay + 8);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: align === 'left' ? x : undefined,
|
||||
right: align === 'right' ? 1920 - x : undefined,
|
||||
top: y,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 17,
|
||||
letterSpacing: '0.08em',
|
||||
color: C.fgMuted,
|
||||
opacity: t,
|
||||
transform: `translateY(${(1 - t) * 4}px)`,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user