fix(video): make Beat 2 visible — bigger particles, parallel schematic stroke
All checks were successful
Deploy to Production / deploy (push) Successful in 52s

User report: "I only see 'Search our Notion workspace' — no video."
Cause: Beat 2 (frames 55-165) was a near-empty dead moment. Particles
were 1.5-2.5px on a 1080p canvas (nearly invisible), and the server
schematic didn't start drawing until local frame 30 (= global 85),
leaving a 30-frame gap of empty space mid-clip. The viewer's brain
correctly registered "the video stops after Beat 1."

Fixes:
- 60 particles (was 36) at radius 6→3 with SVG Gaussian-blur glow
  filter, always indigo (was an indecisive two-color split).
- Schematic stroke starts at local frame 8 (was 30) so the box draws
  IN PARALLEL with particle convergence — eye always has something
  to track.
- Central radial-glow attractor visible the whole beat — gives the
  "something is forming here" cue before the schematic appears.
- Server schematic enlarged 460×300 → 720×420 so it commands
  attention rather than feeling small.
- Inner tool-row dots and port dots doubled in size with stronger
  drop-shadow.
- Beat 3 schematic + client panel sizes scaled to match, and the
  wire base position adjusted (server CX moved from 960 to 760 so
  the wire has room to breathe before reaching the client).
- Poster frame moved from 60 (mid-fade dead spot) to 180 (Beat 3
  Connection layout — the most "this is a real product" shot).

File sizes still well under budget: 514 KB mp4, 319 KB webm, 29 KB poster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-27 11:06:26 +02:00
parent fd147f9998
commit 22ba23f353
6 changed files with 135 additions and 120 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Binary file not shown.

View File

@ -6,7 +6,7 @@
"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.mp4 --codec h264 --crf 28 --pixel-format yuv420p",
"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 60 --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",
"to-web": "node scripts/publish-to-web.mjs", "to-web": "node scripts/publish-to-web.mjs",
"build": "pnpm render:all && pnpm to-web" "build": "pnpm render:all && pnpm to-web"

View File

@ -11,15 +11,15 @@ import { BEAT } from '../HeroVideo';
// status tag flashes briefly. Live-dot in the server's top-right corner // status tag flashes briefly. Live-dot in the server's top-right corner
// pulses through the whole scene. // pulses through the whole scene.
const CX = 960; const CX = 760;
const CY = 540; const CY = 540;
const SERVER_W = 460; const SERVER_W = 600;
const SERVER_H = 300; const SERVER_H = 360;
// Claude client panel — anchored right of the server // Claude client panel — anchored right of the server
const CLIENT_W = 280; const CLIENT_W = 360;
const CLIENT_H = 200; const CLIENT_H = 240;
const CLIENT_CX = 1500; const CLIENT_CX = 1480;
const CLIENT_CY = 540; const CLIENT_CY = 540;
export function ServerScene({ fps }: { fps: number }) { export function ServerScene({ fps }: { fps: number }) {
@ -101,7 +101,7 @@ export function ServerScene({ fps }: { fps: number }) {
{/* Tool rows inside server */} {/* Tool rows inside server */}
{[0, 1, 2].map((r) => { {[0, 1, 2].map((r) => {
const y = CY - 60 + r * 60; const y = CY - 90 + r * 90;
return ( return (
<g key={r}> <g key={r}>
<line <line

View File

@ -5,27 +5,23 @@ import { BEAT } from '../HeroVideo';
// Beat 2 — the wow moment. // Beat 2 — the wow moment.
// //
// The prompt words detonate into ~30 particle dots. Each particle has // Prompt words detonate into ~60 chunky glowing particles that drift, then
// (a) a random scatter velocity that decays, (b) a magnetic pull toward // magnetically snap into target slots along a SERVER SCHEMATIC. The
// a designated target slot on the SERVER SCHEMATIC that materialises in // schematic strokes on IN PARALLEL with the convergence so the eye always
// the centre. As particles arrive, the schematic strokes itself on // has something to anchor to — earlier versions had a dead frame ~3s in
// line-by-line. A scan-line sweeps over the formed server right before // where particles were too small and the box hadn't drawn yet.
// it settles.
const PARTICLE_COUNT = 36; const PARTICLE_COUNT = 60;
// Target slots on the server schematic — points on the rectangle's const SERVER_W = 720;
// perimeter plus internal "row" markers. Particles slot into these. const SERVER_H = 420;
const SERVER_W = 460;
const SERVER_H = 300;
const CX = 960; const CX = 960;
const CY = 540; const CY = 540;
function targetSlot(i: number) { function targetSlot(i: number) {
const N = PARTICLE_COUNT; const N = PARTICLE_COUNT;
// Distribute slots: half on the perimeter, half on internal "tool rows".
if (i < N / 2) { if (i < N / 2) {
// perimeter walk around the rectangle // perimeter walk
const t = i / (N / 2); const t = i / (N / 2);
const perim = 2 * (SERVER_W + SERVER_H); const perim = 2 * (SERVER_W + SERVER_H);
const d = t * perim; const d = t * perim;
@ -33,87 +29,105 @@ function targetSlot(i: number) {
const top = CY - SERVER_H / 2; const top = CY - SERVER_H / 2;
let px = left; let px = left;
let py = top; let py = top;
if (d < SERVER_W) { if (d < SERVER_W) { px = left + d; py = top; }
px = left + d; else if (d < SERVER_W + SERVER_H) { px = left + SERVER_W; py = top + (d - SERVER_W); }
py = top; else if (d < 2 * SERVER_W + SERVER_H) { px = left + SERVER_W - (d - SERVER_W - SERVER_H); py = top + SERVER_H; }
} else if (d < SERVER_W + SERVER_H) { else { px = left; py = top + SERVER_H - (d - 2 * 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 }; return { x: px, y: py };
} }
// Internal tool rows — three horizontal lines inside the server // Inside the box: three tool rows
const j = i - N / 2; const j = i - N / 2;
const row = j % 3; const perRow = Math.ceil(N / 2 / 3);
const col = Math.floor(j / 3) / (N / 2 / 3 - 1); const row = Math.floor(j / perRow);
const rowY = CY - 60 + row * 60; const col = (j % perRow) / Math.max(1, perRow - 1);
const rowX = CX - SERVER_W / 2 + 30 + col * (SERVER_W - 60); const rowY = CY - 90 + row * 90;
const rowX = CX - SERVER_W / 2 + 50 + col * (SERVER_W - 100);
return { x: rowX, y: rowY }; return { x: rowX, y: rowY };
} }
export function TransformScene() { export function TransformScene() {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
// Local frame within the beat — frame 0 is the start of Transform. const local = frame - BEAT.transform.in;
const local = frame - BEAT.transform.in; // 0..110
const total = BEAT.transform.out - BEAT.transform.in;
// Entrance + exit envelopes for the scene as a whole.
const sceneIn = clampLerp(frame, BEAT.transform.in, BEAT.transform.in + 6); const sceneIn = clampLerp(frame, BEAT.transform.in, BEAT.transform.in + 6);
const sceneOut = 1 - clampLerp(frame, BEAT.transform.out - 8, BEAT.transform.out); const sceneOut = 1 - clampLerp(frame, BEAT.transform.out - 8, BEAT.transform.out);
const sceneAlpha = Math.min(sceneIn, sceneOut); const sceneAlpha = Math.min(sceneIn, sceneOut);
// Schematic stroke-on: 0→1 between frames 35..65 of the beat. // Schematic strokes on IN PARALLEL with the convergence — starts at
const strokeT = easeInOut(clampLerp(local, 30, 70)); // local 8 instead of 30 so the box is visible before particles arrive.
// Port pulse readiness — only after schematic strokes complete. const strokeT = easeInOut(clampLerp(local, 8, 55));
const portPulse = clampLerp(local, 70, 95); // Ports + rows show up as schematic completes
const innerT = clampLerp(local, 35, 65);
const portPulse = clampLerp(local, 55, 90);
// Scan-line sweep — diagonal gradient passes once across the formed server // Scan-line — diagonal pass once the schematic is fully drawn
const scanT = clampLerp(local, 70, 100); 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 ( return (
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}> <div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
{/* Particles */} {/* Central core radial glow that's always-on during Beat 2. Sells
"something is building here" before the schematic is drawn. */}
<div
style={{
position: 'absolute',
left: CX - 200,
top: CY - 200,
width: 400,
height: 400,
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
opacity: coreAlpha * 0.7,
transform: `scale(${corePulse})`,
pointerEvents: 'none',
}}
/>
{/* Particles — 60 chunky glowing dots */}
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}> <svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => { {Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
// Source: prompt word approximate position spread across the line
const wordIndex = i % 4; const wordIndex = i % 4;
const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30; const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30;
const wordY = 540 + rand(i * 3.71) * 20 - 10; const wordY = 540 + rand(i * 3.71) * 24 - 12;
// Target slot on server
const slot = targetSlot(i); const slot = targetSlot(i);
// Velocity vectors — particles fly outward in a roughly radial
// pattern from the prompt baseline. Magnitude varied per particle.
const angle = rand(i * 1.71) * Math.PI * 2;
const speed = 240 + rand(i * 4.13) * 380;
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed - 60; // bias slightly upward
// Scatter velocity — frames 0..25 the particle drifts outward; const explode = clampLerp(local, 0, 18);
// then frames 25..60 it pulls toward the target with spring. // Pull starts earlier (frame 14 instead of 25) so particles
const vx = (rand(i * 1.31) - 0.5) * 600; // are visible converging rather than just drifting.
const vy = (rand(i * 2.71) - 0.5) * 400; const pull = softSpring(frame, fps, BEAT.transform.in + 14, 42);
// Phase 1: explosion 0..25
const explode = clampLerp(local, 0, 25);
// Phase 2: magnetic pull 25..60
const pull = softSpring(frame, fps, BEAT.transform.in + 25, 36);
// Position is: (wordPos) + (vx,vy * explode) lerped toward target by pull
const driftX = wordX + vx * explode * (1 - pull); const driftX = wordX + vx * explode * (1 - pull);
const driftY = wordY + vy * explode * (1 - pull); const driftY = wordY + 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;
// Size grows as particles "lock in" // Radius: 6→3 as particles lock in. Big enough at 1080p that
const r = interpolate(pull, [0, 1], [2.5, 1.8]); // every particle is clearly visible.
// Color shifts from white → indigo as they lock to schematic const r = interpolate(pull, [0, 1], [6, 3]);
const color = pull > 0.6 ? C.accent : C.fg; // Always indigo — earlier two-color split was indecisive
// Fade in fast at the start so the explosion is visible const color = C.accent;
const alpha = clampLerp(local, 0, 4); const alpha = clampLerp(local, 0, 4);
// Slight fade-out near end as the schematic takes visual primacy const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
const fadeOut = 1 - clampLerp(local, 85, 105) * 0.3;
return ( return (
<circle <circle
@ -123,15 +137,13 @@ export function TransformScene() {
r={r} r={r}
fill={color} fill={color}
opacity={alpha * fadeOut * 0.95} opacity={alpha * fadeOut * 0.95}
style={{ filter="url(#glow)"
filter: pull > 0.5 ? `drop-shadow(0 0 4px ${C.accentGlow})` : undefined,
}}
/> />
); );
})} })}
</svg> </svg>
{/* Server schematic — strokes on as particles arrive */} {/* Server schematic */}
<svg <svg
width={1920} width={1920}
height={1080} height={1080}
@ -139,98 +151,101 @@ export function TransformScene() {
> >
<defs> <defs>
<linearGradient id="scanline" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="scanline" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset={`${Math.max(0, scanT * 100 - 8)}%`} stopColor={C.accent} stopOpacity="0" /> <stop offset={`${Math.max(0, scanT * 100 - 6)}%`} stopColor={C.accent} stopOpacity="0" />
<stop offset={`${scanT * 100}%`} stopColor={C.accent} stopOpacity="0.85" /> <stop offset={`${scanT * 100}%`} stopColor={C.accent} stopOpacity="0.95" />
<stop offset={`${Math.min(100, scanT * 100 + 8)}%`} stopColor={C.accent} stopOpacity="0" /> <stop offset={`${Math.min(100, scanT * 100 + 6)}%`} stopColor={C.accent} stopOpacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
{/* Outer rectangle — stroke draws perimeter from top-left clockwise */} {/* Faint inner panel as the box draws — gives volume immediately */}
<rect <rect
x={CX - SERVER_W / 2} x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2} y={CY - SERVER_H / 2}
width={SERVER_W} width={SERVER_W}
height={SERVER_H} height={SERVER_H}
rx={6} rx={8}
fill="none" fill={C.bgElevated}
stroke={C.accent} opacity={strokeT * 0.5}
strokeWidth={2}
strokeDasharray={2 * (SERVER_W + SERVER_H)}
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
opacity={0.9}
/> />
{/* Three internal "tool" rows — appear after perimeter completes */} {/* Outer rectangle stroke */}
<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={3}
strokeDasharray={2 * (SERVER_W + SERVER_H)}
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
opacity={0.95}
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
/>
{/* Three internal tool rows */}
{[0, 1, 2].map((r) => { {[0, 1, 2].map((r) => {
const rowAlpha = clampLerp(local, 60 + r * 4, 72 + r * 4); const y = CY - 90 + r * 90;
const y = CY - 60 + r * 60;
return ( return (
<g key={r} opacity={rowAlpha}> <g key={r} opacity={innerT}>
<line <line
x1={CX - SERVER_W / 2 + 24} x1={CX - SERVER_W / 2 + 40}
y1={y} y1={y}
x2={CX + SERVER_W / 2 - 24} x2={CX + SERVER_W / 2 - 40}
y2={y} y2={y}
stroke={C.borderStrong} stroke={C.borderStrong}
strokeWidth={1} 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})` }}
/> />
<circle cx={CX - SERVER_W / 2 + 30} cy={y} r={3} fill={C.accent} opacity={0.9} />
</g> </g>
); );
})} })}
{/* Port dots — 4 each side, pulse once schematic locks in */} {/* Port dots */}
{[-1, 1].map((side) => {[-1, 1].map((side) =>
[-1, 0, 1].map((off) => ( [-1, 0, 1].map((off) => (
<circle <circle
key={`${side}-${off}`} key={`${side}-${off}`}
cx={CX + (SERVER_W / 2) * side} cx={CX + (SERVER_W / 2) * side}
cy={CY + off * 60} cy={CY + off * 90}
r={4} r={6}
fill={C.accent} fill={C.accent}
opacity={portPulse * (0.6 + 0.4 * Math.sin(local * 0.3 + off))} opacity={portPulse * (0.7 + 0.3 * Math.sin(local * 0.3 + off))}
style={{ filter: `drop-shadow(0 0 6px ${C.accentGlow})` }} style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
/> />
)), )),
)} )}
{/* Scan-line sweep — diagonal pass after stroke completes */} {/* Scan-line sweep */}
{scanT > 0 && scanT < 1 && ( {scanT > 0 && scanT < 1 && (
<rect <rect
x={CX - SERVER_W / 2} x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2} y={CY - SERVER_H / 2}
width={SERVER_W} width={SERVER_W}
height={SERVER_H} height={SERVER_H}
rx={6} rx={8}
fill="url(#scanline)" fill="url(#scanline)"
opacity={0.55} opacity={0.65}
/> />
)} )}
</svg> </svg>
{/* Corner labels — typographic detail that sells "this is a real server" */} {/* Corner labels — bigger and earlier */}
<CornerLabel x={CX - SERVER_W / 2 - 8} y={CY - SERVER_H / 2 - 28} text="mcp-notion" appearAt={local} delay={62} /> <CornerLabel x={CX - SERVER_W / 2} y={CY - SERVER_H / 2 - 36} text="mcp-notion" appearAt={local} delay={42} />
<CornerLabel x={CX + SERVER_W / 2 + 8} y={CY - SERVER_H / 2 - 28} text="OAuth 2.1" appearAt={local} delay={68} align="right" /> <CornerLabel x={CX + SERVER_W / 2} y={CY - SERVER_H / 2 - 36} text="OAuth 2.1" appearAt={local} delay={48} align="right" />
<CornerLabel x={CX - SERVER_W / 2 - 8} y={CY + SERVER_H / 2 + 18} text="search_pages" appearAt={local} delay={72} /> <CornerLabel x={CX - SERVER_W / 2} y={CY + SERVER_H / 2 + 16} text="search_pages" appearAt={local} delay={54} />
<CornerLabel x={CX + SERVER_W / 2 + 8} y={CY + SERVER_H / 2 + 18} text="get_page_content" appearAt={local} delay={76} align="right" /> <CornerLabel x={CX + SERVER_W / 2} y={CY + SERVER_H / 2 + 16} text="get_page_content" appearAt={local} delay={60} align="right" />
</div> </div>
); );
} }
function CornerLabel({ function CornerLabel({
x, x, y, text, appearAt, delay, align = 'left',
y,
text,
appearAt,
delay,
align = 'left',
}: { }: {
x: number; x: number; y: number; text: string; appearAt: number; delay: number; align?: 'left' | 'right';
y: number;
text: string;
appearAt: number;
delay: number;
align?: 'left' | 'right';
}) { }) {
const t = clampLerp(appearAt, delay, delay + 8); const t = clampLerp(appearAt, delay, delay + 8);
return ( return (
@ -241,8 +256,8 @@ function CornerLabel({
right: align === 'right' ? 1920 - x : undefined, right: align === 'right' ? 1920 - x : undefined,
top: y, top: y,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13, fontSize: 17,
letterSpacing: '0.06em', letterSpacing: '0.08em',
color: C.fgMuted, color: C.fgMuted,
opacity: t, opacity: t,
transform: `translateY(${(1 - t) * 4}px)`, transform: `translateY(${(1 - t) * 4}px)`,