feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Restructures the landing page above-the-fold into two distinct sections:
1. **Hero — left copy + cycling tile, no static stack of three blocks**
New `<HeroStepRotator>` (Framer Motion client component) shows ONE
tile centred in the column, cycling prompt.txt → build.log →
claude_desktop_config.json every 3.5s. Auto-advance pauses on hover
and exposes a 3-dot tablist so users can jump to any step. The active
dot grows wide with an accent glow.
Mouse interaction: spring-smoothed 3D tilt on rotateX/rotateY plus a
radial glow that translates toward the cursor — both driven by motion
values, so the transforms stay on the GPU compositor instead of
re-rendering on every mousemove. `useReducedMotion()` strips the
tilt + glow translation and collapses the page transition to an
instant cross-fade (the rotation itself still advances — it's content,
not decoration).
Hero padding tightened (py-12/14/16 vs py-14/20/28) so the video
section below is teased above the fold. New scroll cue ("see it run"
+ animated chevron) sits at the bottom of the hero, anchored to
#flow.
2. **Flow video — full-width edge-to-edge under the hero (new section)**
The hero.mp4 / hero.webm pair moves out of the "How it works"
section into its own #flow section. No max-w wrapper — it spans the
viewport with `w-full aspect-video`, so on a 1080p monitor the video
gets the full 1920px width. Adds a subtle radial vignette so the
black edges blend into the page chrome.
3. **"How it works" — now lean**
Video removed (it's the flow section now). Just the three textual
cards as supporting copy.
Adds `framer-motion@11.18.2` to apps/web/package.json. Build passes
typecheck + Next.js production build with no new warnings; LCP path is
untouched since the rotator is client-hydrated after first paint and
Framer Motion is tree-shaken to the components we import.
Note: visitors with `prefers-reduced-motion: reduce` will still see the
video's poster instead of autoplay — Chrome blocks the network fetch
entirely for autoplay media when reduced-motion is set. The flow video
remains visible for the rest, and the step rotator continues to cycle
its content (with instant cross-fade instead of slide+scale).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"render:mp4": "remotion render src/index.ts HeroVideo out/hero.mp4 --codec h264 --crf 28 --pixel-format yuv420p",
|
||||
"render:mp4": "remotion render src/index.ts HeroVideo out/hero-raw.mp4 --codec h264 --crf 28 --pixel-format yuv420p && node scripts/postprocess.mjs",
|
||||
"render:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32",
|
||||
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 180 --image-format jpeg --jpeg-quality 85",
|
||||
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
|
||||
|
||||
49
remotion/scripts/postprocess.mjs
Normal file
49
remotion/scripts/postprocess.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
// Re-encode the raw Remotion output to a browser-safe MP4 then delete
|
||||
// the raw file. The previous pipeline used `-c:v copy` which preserved
|
||||
// `pix_fmt=yuvj420p` (JPEG full-range) — Chrome refuses to decode that
|
||||
// correctly and the video ships as a black square on the marketing page.
|
||||
//
|
||||
// Flags below were verified to produce a tag the browsers accept:
|
||||
// pix_fmt=yuv420p, color_range=tv, profile=Main, level=4.0, BT.709.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, unlinkSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
const rawPath = resolve(root, 'out', 'hero-raw.mp4');
|
||||
const outPath = resolve(root, 'out', 'hero.mp4');
|
||||
|
||||
if (!existsSync(rawPath)) {
|
||||
console.error(`postprocess: input not found at ${rawPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-y',
|
||||
'-i', rawPath,
|
||||
'-c:v', 'libx264',
|
||||
'-profile:v', 'main',
|
||||
'-level', '4.0',
|
||||
'-vf', 'format=yuv420p,colorspace=bt709:iall=bt709:fast=1',
|
||||
'-color_range', 'tv',
|
||||
'-color_primaries', 'bt709',
|
||||
'-color_trc', 'bt709',
|
||||
'-colorspace', 'bt709',
|
||||
'-preset', 'slow',
|
||||
'-crf', '23',
|
||||
'-an',
|
||||
'-movflags', '+faststart',
|
||||
outPath,
|
||||
];
|
||||
|
||||
const result = spawnSync('ffmpeg', ffmpegArgs, { stdio: 'inherit' });
|
||||
if (result.status !== 0) {
|
||||
console.error(`postprocess: ffmpeg exited with code ${result.status}`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
unlinkSync(rawPath);
|
||||
console.log(`postprocess: wrote ${outPath} and removed raw input`);
|
||||
@@ -13,10 +13,21 @@ export function PromptScene() {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Whole scene fades out after frame 55 so it dissolves into Transform.
|
||||
const sceneOut = interpolate(frame, [55, 70], [1, 0], {
|
||||
// The collapse: between frame 50-70 all four words scale down toward
|
||||
// their shared geometric center (960, 540) and fade. Visually the
|
||||
// prompt "drops" into a single bright point that Beat 2 will explode
|
||||
// from — Beat 2's particle origin matches this same point.
|
||||
const sceneOut = interpolate(frame, [60, 70], [1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const collapseT = interpolate(frame, [50, 68], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
// Ease the collapse so it accelerates inward at the very end.
|
||||
const collapseEase = collapseT * collapseT;
|
||||
const collapseScale = interpolate(collapseEase, [0, 1], [1, 0.2]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,6 +67,15 @@ export function PromptScene() {
|
||||
letterSpacing: '-0.01em',
|
||||
display: 'flex',
|
||||
gap: '18px',
|
||||
// Collapse the entire word row toward its center point (which is
|
||||
// canvas center 960,540 because the parent flex is fullscreen-
|
||||
// centered). transform-origin: center makes all four words pull
|
||||
// into a single bright point right before Beat 2's explosion.
|
||||
transform: `scale(${collapseScale})`,
|
||||
transformOrigin: 'center center',
|
||||
filter: collapseT > 0
|
||||
? `drop-shadow(0 0 ${12 + collapseEase * 28}px ${C.accentGlow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{PROMPT_WORDS.map((word, i) => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
||||
import { useCurrentFrame, useVideoConfig, interpolate, spring } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { rand, clampLerp, easeInOut, softSpring } from '../lib/easings';
|
||||
import { rand, clampLerp, easeInOut } from '../lib/easings';
|
||||
import { BEAT } from '../HeroVideo';
|
||||
|
||||
// Beat 2 — the wow moment.
|
||||
//
|
||||
// Prompt words detonate into ~60 chunky glowing particles that drift, then
|
||||
// magnetically snap into target slots along a SERVER SCHEMATIC. The
|
||||
// schematic strokes on IN PARALLEL with the convergence so the eye always
|
||||
// has something to anchor to — earlier versions had a dead frame ~3s in
|
||||
// where particles were too small and the box hadn't drawn yet.
|
||||
// The collapsed prompt point at (960, 540) detonates RADIALLY into ~60
|
||||
// glowing particles that scatter spherically, then magnetically snap into
|
||||
// target slots along a SERVER SCHEMATIC. Particles are supporting players:
|
||||
// small enough that the schematic — the thing being built — reads as the
|
||||
// primary subject. Schematic strokes on IN PARALLEL with the convergence
|
||||
// so the eye always has something to anchor to.
|
||||
|
||||
const PARTICLE_COUNT = 60;
|
||||
|
||||
@@ -71,27 +72,29 @@ export function TransformScene() {
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
|
||||
{/* Central core — radial glow that's always-on during Beat 2. Sells
|
||||
"something is building here" before the schematic is drawn. */}
|
||||
{/* Central core — a hint of radial glow at the explosion origin.
|
||||
Toned down from earlier versions so the schematic, not the core,
|
||||
carries the visual weight. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: CX - 200,
|
||||
top: CY - 200,
|
||||
width: 400,
|
||||
height: 400,
|
||||
left: CX - 110,
|
||||
top: CY - 110,
|
||||
width: 220,
|
||||
height: 220,
|
||||
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
|
||||
opacity: coreAlpha * 0.7,
|
||||
opacity: coreAlpha * 0.45,
|
||||
transform: `scale(${corePulse})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Particles — 60 chunky glowing dots */}
|
||||
{/* Particles — 60 small glowing dots radiating from a single origin.
|
||||
They support the schematic; they do not dominate it. */}
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||
<feGaussianBlur stdDeviation="1.4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
@@ -99,32 +102,43 @@ export function TransformScene() {
|
||||
</filter>
|
||||
</defs>
|
||||
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
|
||||
const wordIndex = i % 4;
|
||||
const wordX = 760 + wordIndex * 130 + rand(i * 7.13) * 60 - 30;
|
||||
const wordY = 540 + rand(i * 3.71) * 24 - 12;
|
||||
// SINGLE-POINT ORIGIN. All 60 particles start at canvas center —
|
||||
// the exact point Beat 1's prompt just collapsed into. This is
|
||||
// what makes the explosion read as radial/spherical instead of
|
||||
// horizontal.
|
||||
const originX = CX;
|
||||
const originY = CY;
|
||||
|
||||
const slot = targetSlot(i);
|
||||
// Velocity vectors — particles fly outward in a roughly radial
|
||||
// pattern from the prompt baseline. Magnitude varied per particle.
|
||||
const angle = rand(i * 1.71) * Math.PI * 2;
|
||||
const speed = 240 + rand(i * 4.13) * 380;
|
||||
// Velocity vectors — even spherical distribution. Golden-angle
|
||||
// stratification of `i` plus a small jitter prevents the visible
|
||||
// banding you'd get from a pure uniform-random angle on 60 dots.
|
||||
const goldenAngle = (i * 2.39996323) % (Math.PI * 2);
|
||||
const jitter = (rand(i * 1.71) - 0.5) * 0.35;
|
||||
const angle = goldenAngle + jitter;
|
||||
const speed = 220 + rand(i * 4.13) * 320;
|
||||
const vx = Math.cos(angle) * speed;
|
||||
const vy = Math.sin(angle) * speed - 60; // bias slightly upward
|
||||
const vy = Math.sin(angle) * speed;
|
||||
|
||||
const explode = clampLerp(local, 0, 18);
|
||||
// Pull starts earlier (frame 14 instead of 25) so particles
|
||||
// are visible converging rather than just drifting.
|
||||
const pull = softSpring(frame, fps, BEAT.transform.in + 14, 42);
|
||||
const explode = clampLerp(local, 0, 22);
|
||||
// Pull — slower, more deliberate. Inlined spring so we can set
|
||||
// the exact damping/mass/stiffness/duration the scene needs
|
||||
// without bloating the easings module.
|
||||
const pull = spring({
|
||||
frame: frame - (BEAT.transform.in + 22),
|
||||
fps,
|
||||
config: { damping: 25, mass: 1.3, stiffness: 55 },
|
||||
durationInFrames: 60,
|
||||
});
|
||||
|
||||
const driftX = wordX + vx * explode * (1 - pull);
|
||||
const driftY = wordY + vy * explode * (1 - pull);
|
||||
const driftX = originX + vx * explode * (1 - pull);
|
||||
const driftY = originY + vy * explode * (1 - pull);
|
||||
const x = driftX + (slot.x - driftX) * pull;
|
||||
const y = driftY + (slot.y - driftY) * pull;
|
||||
|
||||
// Radius: 6→3 as particles lock in. Big enough at 1080p that
|
||||
// every particle is clearly visible.
|
||||
const r = interpolate(pull, [0, 1], [6, 3]);
|
||||
// Always indigo — earlier two-color split was indecisive
|
||||
// Radius: 4→2 as particles lock in. Smaller than v2 so the
|
||||
// schematic carries primary visual weight.
|
||||
const r = interpolate(pull, [0, 1], [4, 2]);
|
||||
const color = C.accent;
|
||||
const alpha = clampLerp(local, 0, 4);
|
||||
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
|
||||
@@ -136,7 +150,7 @@ export function TransformScene() {
|
||||
cy={y}
|
||||
r={r}
|
||||
fill={color}
|
||||
opacity={alpha * fadeOut * 0.95}
|
||||
opacity={alpha * fadeOut * 0.9}
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
);
|
||||
@@ -157,7 +171,9 @@ export function TransformScene() {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Faint inner panel as the box draws — gives volume immediately */}
|
||||
{/* Inner panel — fills earlier and darker so the schematic reads
|
||||
as a solid object the particles are building, not a wireframe
|
||||
sketch. Reaches 0.9 opacity by the time the strokes complete. */}
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
@@ -165,10 +181,11 @@ export function TransformScene() {
|
||||
height={SERVER_H}
|
||||
rx={8}
|
||||
fill={C.bgElevated}
|
||||
opacity={strokeT * 0.5}
|
||||
opacity={Math.min(0.9, strokeT * 1.5)}
|
||||
/>
|
||||
|
||||
{/* Outer rectangle stroke */}
|
||||
{/* Outer rectangle stroke — wider and with a heavier drop-shadow
|
||||
so the chassis outline is the dominant element on screen. */}
|
||||
<rect
|
||||
x={CX - SERVER_W / 2}
|
||||
y={CY - SERVER_H / 2}
|
||||
@@ -177,11 +194,11 @@ export function TransformScene() {
|
||||
rx={8}
|
||||
fill="none"
|
||||
stroke={C.accent}
|
||||
strokeWidth={3}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={2 * (SERVER_W + SERVER_H)}
|
||||
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
|
||||
opacity={0.95}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
|
||||
opacity={0.98}
|
||||
style={{ filter: `drop-shadow(0 0 16px ${C.accentGlow}) drop-shadow(0 0 4px ${C.accentGlow})` }}
|
||||
/>
|
||||
|
||||
{/* Three internal tool rows */}
|
||||
|
||||
Reference in New Issue
Block a user