feat(video): v10 hero video with mute toggle — voice + bg music
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s

Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.

## Video composition (remotion/)

Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:

- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
  excerpt, an oauth_callback.ts snippet, an .env file with a yellow
  squiggle warning ("in git history since v0.3.1"), and a live-ticking
  502 retry toast. Tangle now reads as a developer's desktop right
  before they give up, not as four icons drifting.

- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
  the parse beat: three sequential highlights on the prompt text
  (MCP server / searches / Notion workspace), three chips below the
  input (intent / tool / secret → vault), three-stat summary panel
  (tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
  global, on the voice line "the prompt path and the secret path
  never cross") a mini two-rail diagram with an explicit X-marker
  ring lands, visualising the architectural promise the moment it's
  spoken.

- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
  env-var injection beats; added the lock-snap flash at frame 66,
  pinned the vault at full opacity throughout, and added a dashed
  vault → container connector so the secret's provenance is visible.
  The "what the AI sees" panel is now 680 px wide with an eye icon,
  four corner viewfinder brackets around the prompt text, and three
  explicit denied lines (no secrets / no environment variables / no
  tokens).

- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
  card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
  container caption, <60s countdown.

- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
  chips that collided with the card and with the tokens-only badge
  are replaced by a clean vertical chip column at x=760: read-only
  filesystem · dropped capabilities · no new privileges · 512 MB
  memory cap · 0.5 CPU limit · ✓ your token only (last in green).
  A vault graphic now sits below the server card with a dashed arrow
  up into its env slot so the architecture story is complete in one
  frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
  gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
  the curious. Handshake stages simplified to your client → verified
  → scoped token. Final settlement arrow in success-green curves
  from the scoped-token pill back into the card.

- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
  with 36 px gaps. The "templates carry code, not credentials"
  sub-caption was pulled (felt on-the-nose; the detached lock and
  empty NOTION_API_KEY=? slot carry the story visually).

- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
  versions had a fake "1,200+ developers building" fork counter
  (pulled — solo-founder, hadn't earned). Replaced with a two-lane
  architecture diagram that visualises "no paths cross" literally:
  top lane prompt → AI → code, bottom lane vault → encrypted →
  env, both converging at the server box on the right. v10
  refinements: all seven boxes visible from frame 0 (no late
  server arrival), a parallel glow tour walks across both lanes
  simultaneously, a dashed vertical divider with a "no shared
  node" chip pinned in the middle, and the closing line "One
  sentence in. Live server out." slides down from above and lands
  centred while the diagram fades to 0.12 opacity behind it —
  no overlap.

- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
  loop seam.

The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.

Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.

## Web integration (apps/web/components/hero-video.tsx)

New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-28 02:31:10 +02:00
parent 6197ee7f5e
commit 438ce3cfbc
27 changed files with 3449 additions and 589 deletions

View File

@@ -169,18 +169,16 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
const renderUniforms = {
uPositions: { value: rtB.texture },
// Huge soft blobs at very low per-particle alpha → no individual
// dots are visible, but 65k of them additively composite into a
// continuous indigo cloud. This matches the brief "no white dots,
// just a glow." When dots were 2.8px at 0.6 alpha, dense areas
// saturated additive-blended into white; with 14px at 0.05 the
// saturation point is far above what 65k particles ever sum to,
// so the cloud stays indigo even at its brightest.
uPointSize: { value: textureSize === 256 ? 14.0 : 20.0 },
// 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.055 },
uBaseAlpha: { value: 0.6 },
};
const particleMat = new THREE.ShaderMaterial({
vertexShader: renderVertex,
@@ -252,15 +250,10 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
// Asymmetric fade: ramp in quickly when the pointer enters, decay
// slowly when it leaves. The brief said "glow longer + attractive
// to mouse but always in motion" — fast ramp-in keeps the cursor
// feeling responsive, slow decay lets the glow linger after the
// pointer moves away rather than snapping off.
// Fade ring in/out when the pointer enters/leaves.
const targetActive = hasPointer ? 1 : 0;
const cur = simUniforms.uRingActive.value;
const alpha = targetActive > cur ? 0.14 : 0.012;
simUniforms.uRingActive.value = cur * (1 - alpha) + targetActive * alpha;
simUniforms.uRingActive.value =
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
simUniforms.uTime.value = t;
simUniforms.uDelta.value = delta;

View File

@@ -136,41 +136,28 @@ export function ParticleHero() {
return () => reduce.removeEventListener('change', onReduceChange);
}, []);
// Static fallback: pure indigo radial glow, no dot grid. The
// dot-mask was confusing — it read as "stippled white texture"
// against the indigo glow rather than as resting particles. The
// cleaner, dotless gradient holds up better as a fallback.
// Static fallback: radial indigo glow + faint dotted mask.
// Used both for 'unknown' (pre-hydration) and 'fallback'.
if (cap.kind !== 'webgl') {
return (
<div
aria-hidden="true"
className="absolute inset-0 size-full overflow-hidden"
style={{
background:
'radial-gradient(65% 80% at 50% 45%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)',
backgroundImage: [
// Soft indigo glow centered on the hero
'radial-gradient(60% 80% at 50% 45%, rgba(99,102,241,0.18), rgba(99,102,241,0) 70%)',
// Very faint dotted texture — reads as "field of particles
// at rest" rather than a flat gradient.
'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 1.5px)',
].join(', '),
backgroundSize: '100% 100%, 24px 24px',
}}
/>
);
}
// WebGL path: an always-on indigo radial behind the canvas so the
// hero never feels visually empty (even between frames, even when the
// pointer is far away). The canvas paints the dynamic cloud + halo
// additively on top of this baseline glow — the result is "longer
// glow" without needing the simulation to keep a permanent ring on.
return (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 size-full"
style={{
background:
'radial-gradient(70% 85% at 50% 48%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)',
}}
/>
<ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />
</>
);
return <ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />;
}
export default ParticleHero;

View File

@@ -162,18 +162,18 @@ export const simFragment = /* glsl */ `
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
// --- Mouse halo pull (attraction, not repulsion) ---
// Particles are drawn toward a soft halo orbiting the cursor —
// strongest at ~0.20 distance, fading both closer and farther.
// Closer-fade prevents the cloud from collapsing onto the cursor;
// farther-fade keeps the influence local. The result is a moving
// bright spot that follows the pointer with a continuous breathing
// ring of indigo around it, rather than the old outward push that
// hollowed the cloud where the cursor sat.
vec2 toMouse = uRingPos - pos;
float distToMouse = length(toMouse) + 0.001;
float halo = exp(-pow(distToMouse - 0.20, 2.0) * 22.0);
vec2 ringVel = (toMouse / distToMouse) * halo * 0.05 * uRingActive * uMotionScale;
// --- Ring push: gradient of the ring field, pointing outward ---
float h = 0.003;
float fx0 = ringField(pos - vec2(h, 0.0));
float fx1 = ringField(pos + vec2(h, 0.0));
float fy0 = ringField(pos - vec2(0.0, h));
float fy1 = ringField(pos + vec2(0.0, h));
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
float fieldHere = ringField(pos);
// Push along gradient — particles get nudged away from the ring crest.
// Magnitude is scaled by uMotionScale so reduced-motion users get a
// softer shove while the ring position still tracks at full fidelity.
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
// --- Soft containment toward origin if particle escaped ---
float r = length(pos);
@@ -247,19 +247,14 @@ export const renderFragment = /* glsl */ `
varying float vScale;
void main() {
// Soft Gaussian blob — no hard disc edge. Combined with the bigger
// uPointSize on the JS side (14-20px vs the old 2.8) and the much
// lower uBaseAlpha (0.05 vs 0.6), individual particles disappear
// into a continuous indigo cloud. The exp() falloff means each blob
// contributes most at its centre and fades smoothly to nothing —
// adjacent blobs blend without seams, so 65k of them additively
// composite into a volumetric glow instead of a stipple texture.
// Disc SDF — anti-aliased round dot.
float d = length(gl_PointCoord - 0.5);
float a = exp(-d * d * 6.0);
float a = smoothstep(0.5, 0.42, d);
if (a <= 0.001) discard;
// Velocity-driven mix kept, but with the new low base alpha the
// green tint is barely visible — by design. The cloud is calm.
// Velocity-driven mix: pin to indigo for typical drift, lerp toward
// green only on real shoves. The 0.04..0.18 band is roughly where
// ring pushes live; idle drift stays below 0.03.
float t = smoothstep(0.04, 0.18, vVel);
vec3 col = mix(uColorCalm, uColorHot, t);