fix(video): make Beat 2 visible — bigger particles, parallel schematic stroke
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
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:
parent
fd147f9998
commit
22ba23f353
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 29 KiB |
Binary file not shown.
Binary file not shown.
@ -6,7 +6,7 @@
|
||||
"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: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",
|
||||
"to-web": "node scripts/publish-to-web.mjs",
|
||||
"build": "pnpm render:all && pnpm to-web"
|
||||
|
||||
@ -11,15 +11,15 @@ import { BEAT } from '../HeroVideo';
|
||||
// status tag flashes briefly. Live-dot in the server's top-right corner
|
||||
// pulses through the whole scene.
|
||||
|
||||
const CX = 960;
|
||||
const CX = 760;
|
||||
const CY = 540;
|
||||
const SERVER_W = 460;
|
||||
const SERVER_H = 300;
|
||||
const SERVER_W = 600;
|
||||
const SERVER_H = 360;
|
||||
|
||||
// Claude client panel — anchored right of the server
|
||||
const CLIENT_W = 280;
|
||||
const CLIENT_H = 200;
|
||||
const CLIENT_CX = 1500;
|
||||
const CLIENT_W = 360;
|
||||
const CLIENT_H = 240;
|
||||
const CLIENT_CX = 1480;
|
||||
const CLIENT_CY = 540;
|
||||
|
||||
export function ServerScene({ fps }: { fps: number }) {
|
||||
@ -101,7 +101,7 @@ export function ServerScene({ fps }: { fps: number }) {
|
||||
|
||||
{/* Tool rows inside server */}
|
||||
{[0, 1, 2].map((r) => {
|
||||
const y = CY - 60 + r * 60;
|
||||
const y = CY - 90 + r * 90;
|
||||
return (
|
||||
<g key={r}>
|
||||
<line
|
||||
|
||||
@ -5,27 +5,23 @@ import { BEAT } from '../HeroVideo';
|
||||
|
||||
// Beat 2 — the wow moment.
|
||||
//
|
||||
// The prompt words detonate into ~30 particle dots. Each particle has
|
||||
// (a) a random scatter velocity that decays, (b) a magnetic pull toward
|
||||
// a designated target slot on the SERVER SCHEMATIC that materialises in
|
||||
// the centre. As particles arrive, the schematic strokes itself on
|
||||
// line-by-line. A scan-line sweeps over the formed server right before
|
||||
// it settles.
|
||||
// Prompt words detonate into ~60 chunky glowing particles that drift, then
|
||||
// magnetically snap into target slots along a SERVER SCHEMATIC. The
|
||||
// schematic strokes on IN PARALLEL with the convergence so the eye always
|
||||
// has something to anchor to — earlier versions had a dead frame ~3s in
|
||||
// where particles were too small and the box hadn't drawn yet.
|
||||
|
||||
const PARTICLE_COUNT = 36;
|
||||
const PARTICLE_COUNT = 60;
|
||||
|
||||
// Target slots on the server schematic — points on the rectangle's
|
||||
// perimeter plus internal "row" markers. Particles slot into these.
|
||||
const SERVER_W = 460;
|
||||
const SERVER_H = 300;
|
||||
const SERVER_W = 720;
|
||||
const SERVER_H = 420;
|
||||
const CX = 960;
|
||||
const CY = 540;
|
||||
|
||||
function targetSlot(i: number) {
|
||||
const N = PARTICLE_COUNT;
|
||||
// Distribute slots: half on the perimeter, half on internal "tool rows".
|
||||
if (i < N / 2) {
|
||||
// perimeter — walk around the rectangle
|
||||
// perimeter walk
|
||||
const t = i / (N / 2);
|
||||
const perim = 2 * (SERVER_W + SERVER_H);
|
||||
const d = t * perim;
|
||||
@ -33,87 +29,105 @@ function targetSlot(i: number) {
|
||||
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);
|
||||
}
|
||||
if (d < SERVER_W) { px = left + d; py = top; }
|
||||
else if (d < SERVER_W + SERVER_H) { px = left + SERVER_W; py = top + (d - SERVER_W); }
|
||||
else if (d < 2 * SERVER_W + SERVER_H) { px = left + SERVER_W - (d - SERVER_W - SERVER_H); py = top + SERVER_H; }
|
||||
else { px = left; py = top + SERVER_H - (d - 2 * SERVER_W - SERVER_H); }
|
||||
return { x: px, y: py };
|
||||
}
|
||||
// Internal tool rows — three horizontal lines inside the server
|
||||
// Inside the box: three tool rows
|
||||
const j = i - N / 2;
|
||||
const row = j % 3;
|
||||
const col = Math.floor(j / 3) / (N / 2 / 3 - 1);
|
||||
const rowY = CY - 60 + row * 60;
|
||||
const rowX = CX - SERVER_W / 2 + 30 + col * (SERVER_W - 60);
|
||||
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();
|
||||
// Local frame within the beat — frame 0 is the start of Transform.
|
||||
const local = frame - BEAT.transform.in; // 0..110
|
||||
const total = BEAT.transform.out - BEAT.transform.in;
|
||||
const local = frame - BEAT.transform.in;
|
||||
|
||||
// Entrance + exit envelopes for the scene as a whole.
|
||||
const sceneIn = clampLerp(frame, BEAT.transform.in, BEAT.transform.in + 6);
|
||||
const sceneOut = 1 - clampLerp(frame, BEAT.transform.out - 8, BEAT.transform.out);
|
||||
const sceneAlpha = Math.min(sceneIn, sceneOut);
|
||||
|
||||
// Schematic stroke-on: 0→1 between frames 35..65 of the beat.
|
||||
const strokeT = easeInOut(clampLerp(local, 30, 70));
|
||||
// Port pulse readiness — only after schematic strokes complete.
|
||||
const portPulse = clampLerp(local, 70, 95);
|
||||
// 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 sweep — diagonal gradient passes once across the formed server
|
||||
const scanT = clampLerp(local, 70, 100);
|
||||
// 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 }}>
|
||||
{/* 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 }}>
|
||||
<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) => {
|
||||
// Source: prompt word approximate position spread across the line
|
||||
const wordIndex = i % 4;
|
||||
const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30;
|
||||
const wordY = 540 + rand(i * 3.71) * 20 - 10;
|
||||
const wordY = 540 + rand(i * 3.71) * 24 - 12;
|
||||
|
||||
// Target slot on server
|
||||
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;
|
||||
// then frames 25..60 it pulls toward the target with spring.
|
||||
const vx = (rand(i * 1.31) - 0.5) * 600;
|
||||
const vy = (rand(i * 2.71) - 0.5) * 400;
|
||||
const explode = clampLerp(local, 0, 18);
|
||||
// Pull starts earlier (frame 14 instead of 25) so particles
|
||||
// are visible converging rather than just drifting.
|
||||
const pull = softSpring(frame, fps, BEAT.transform.in + 14, 42);
|
||||
|
||||
// Phase 1: explosion 0..25
|
||||
const explode = clampLerp(local, 0, 25);
|
||||
// Phase 2: magnetic pull 25..60
|
||||
const pull = softSpring(frame, fps, BEAT.transform.in + 25, 36);
|
||||
|
||||
// Position is: (wordPos) + (vx,vy * explode) lerped toward target by pull
|
||||
const driftX = wordX + vx * explode * (1 - pull);
|
||||
const driftY = wordY + vy * explode * (1 - pull);
|
||||
const x = driftX + (slot.x - driftX) * pull;
|
||||
const y = driftY + (slot.y - driftY) * pull;
|
||||
|
||||
// Size grows as particles "lock in"
|
||||
const r = interpolate(pull, [0, 1], [2.5, 1.8]);
|
||||
// Color shifts from white → indigo as they lock to schematic
|
||||
const color = pull > 0.6 ? C.accent : C.fg;
|
||||
// Fade in fast at the start so the explosion is visible
|
||||
// Radius: 6→3 as particles lock in. Big enough at 1080p that
|
||||
// every particle is clearly visible.
|
||||
const r = interpolate(pull, [0, 1], [6, 3]);
|
||||
// Always indigo — earlier two-color split was indecisive
|
||||
const color = C.accent;
|
||||
const alpha = clampLerp(local, 0, 4);
|
||||
// Slight fade-out near end as the schematic takes visual primacy
|
||||
const fadeOut = 1 - clampLerp(local, 85, 105) * 0.3;
|
||||
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
|
||||
|
||||
return (
|
||||
<circle
|
||||
@ -123,15 +137,13 @@ export function TransformScene() {
|
||||
r={r}
|
||||
fill={color}
|
||||
opacity={alpha * fadeOut * 0.95}
|
||||
style={{
|
||||
filter: pull > 0.5 ? `drop-shadow(0 0 4px ${C.accentGlow})` : undefined,
|
||||
}}
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Server schematic — strokes on as particles arrive */}
|
||||
{/* Server schematic */}
|
||||
<svg
|
||||
width={1920}
|
||||
height={1080}
|
||||
@ -139,98 +151,101 @@ export function TransformScene() {
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="scanline" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset={`${Math.max(0, scanT * 100 - 8)}%`} stopColor={C.accent} stopOpacity="0" />
|
||||
<stop offset={`${scanT * 100}%`} stopColor={C.accent} stopOpacity="0.85" />
|
||||
<stop offset={`${Math.min(100, scanT * 100 + 8)}%`} stopColor={C.accent} stopOpacity="0" />
|
||||
<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>
|
||||
|
||||
{/* Outer rectangle — stroke draws perimeter from top-left clockwise */}
|
||||
{/* Faint inner panel as the box draws — gives volume immediately */}
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
width={SERVER_W}
|
||||
height={SERVER_H}
|
||||
rx={6}
|
||||
fill="none"
|
||||
stroke={C.accent}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={2 * (SERVER_W + SERVER_H)}
|
||||
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
|
||||
opacity={0.9}
|
||||
rx={8}
|
||||
fill={C.bgElevated}
|
||||
opacity={strokeT * 0.5}
|
||||
/>
|
||||
|
||||
{/* 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) => {
|
||||
const rowAlpha = clampLerp(local, 60 + r * 4, 72 + r * 4);
|
||||
const y = CY - 60 + r * 60;
|
||||
const y = CY - 90 + r * 90;
|
||||
return (
|
||||
<g key={r} opacity={rowAlpha}>
|
||||
<g key={r} opacity={innerT}>
|
||||
<line
|
||||
x1={CX - SERVER_W / 2 + 24}
|
||||
x1={CX - SERVER_W / 2 + 40}
|
||||
y1={y}
|
||||
x2={CX + SERVER_W / 2 - 24}
|
||||
x2={CX + SERVER_W / 2 - 40}
|
||||
y2={y}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Port dots — 4 each side, pulse once schematic locks in */}
|
||||
{/* Port dots */}
|
||||
{[-1, 1].map((side) =>
|
||||
[-1, 0, 1].map((off) => (
|
||||
<circle
|
||||
key={`${side}-${off}`}
|
||||
cx={CX + (SERVER_W / 2) * side}
|
||||
cy={CY + off * 60}
|
||||
r={4}
|
||||
cy={CY + off * 90}
|
||||
r={6}
|
||||
fill={C.accent}
|
||||
opacity={portPulse * (0.6 + 0.4 * Math.sin(local * 0.3 + off))}
|
||||
style={{ filter: `drop-shadow(0 0 6px ${C.accentGlow})` }}
|
||||
opacity={portPulse * (0.7 + 0.3 * Math.sin(local * 0.3 + off))}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
|
||||
{/* Scan-line sweep — diagonal pass after stroke completes */}
|
||||
{/* 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={6}
|
||||
rx={8}
|
||||
fill="url(#scanline)"
|
||||
opacity={0.55}
|
||||
opacity={0.65}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Corner labels — typographic detail that sells "this is a real server" */}
|
||||
<CornerLabel x={CX - SERVER_W / 2 - 8} y={CY - SERVER_H / 2 - 28} text="mcp-notion" appearAt={local} delay={62} />
|
||||
<CornerLabel x={CX + SERVER_W / 2 + 8} y={CY - SERVER_H / 2 - 28} text="OAuth 2.1" appearAt={local} delay={68} align="right" />
|
||||
<CornerLabel x={CX - SERVER_W / 2 - 8} y={CY + SERVER_H / 2 + 18} text="search_pages" appearAt={local} delay={72} />
|
||||
<CornerLabel x={CX + SERVER_W / 2 + 8} y={CY + SERVER_H / 2 + 18} text="get_page_content" appearAt={local} delay={76} align="right" />
|
||||
{/* 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, y, text, appearAt, delay, align = 'left',
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
appearAt: number;
|
||||
delay: number;
|
||||
align?: 'left' | 'right';
|
||||
x: number; y: number; text: string; appearAt: number; delay: number; align?: 'left' | 'right';
|
||||
}) {
|
||||
const t = clampLerp(appearAt, delay, delay + 8);
|
||||
return (
|
||||
@ -241,8 +256,8 @@ function CornerLabel({
|
||||
right: align === 'right' ? 1920 - x : undefined,
|
||||
top: y,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 13,
|
||||
letterSpacing: '0.06em',
|
||||
fontSize: 17,
|
||||
letterSpacing: '0.08em',
|
||||
color: C.fgMuted,
|
||||
opacity: t,
|
||||
transform: `translateY(${(1 - t) * 4}px)`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user