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

@@ -10,7 +10,6 @@ import { accountRoutes } from './routes/account.js';
import { adminRoutes } from './routes/admin.js';
import { authRoutes } from './routes/auth.js';
import { billingRoutes } from './routes/billing.js';
import { geoRoutes } from './routes/geo.js';
import { oauthRoutes } from './routes/oauth.js';
import { serverRoutes } from './routes/servers.js';
import { settingsRoutes } from './routes/settings.js';
@@ -82,7 +81,6 @@ await app.register(templateRoutes);
await app.register(billingRoutes);
await app.register(supportRoutes);
await app.register(accountRoutes);
await app.register(geoRoutes);
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier

View File

@@ -1,33 +0,0 @@
import type { FastifyInstance } from 'fastify';
/**
* GET /v1/geo/country → { country: 'CH' | 'DE' | ... | null }
*
* Returns the country derived from Cloudflare's `CF-IPCountry` request
* header, which CF adds to every request before it reaches our origin.
* The header is an ISO-3166 alpha-2 code (or 'XX' for unknown / Tor).
*
* Used by the login page to pre-select the dial-code in the country
* picker for SMS auth — visitors hitting the page from Germany see +49
* already selected, etc. No IP is exposed to the client; only the
* country code.
*
* Returns `country: null` when:
* - Request is hitting the origin directly (dev / outside CF)
* - CF couldn't classify the IP ('XX' is normalised to null)
*
* Public route — no auth required. Reading a country code from a
* geo-IP header is not PII under GDPR / DSG.
*/
export async function geoRoutes(app: FastifyInstance) {
app.get('/v1/geo/country', async (req) => {
const raw = req.headers['cf-ipcountry'];
const header = Array.isArray(raw) ? raw[0] : raw;
if (!header) return { country: null };
const code = header.toUpperCase();
if (code === 'XX' || code === 'T1' || code.length !== 2) {
return { country: null };
}
return { country: code };
});
}

View File

@@ -1,4 +1,5 @@
import { HeroStepRotator } from '@/components/hero-step-rotator';
import { HeroVideo } from '@/components/hero-video';
import { JsonLd } from '@/components/json-ld';
import { ParticleHero } from '@/components/particle-hero';
import { PulseLink } from '@/components/pulse';
@@ -174,19 +175,11 @@ export default function Landing() {
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
>
<div className="relative aspect-video w-full">
<video
autoPlay
muted
loop
playsInline
preload="auto"
poster="/videos/hero-poster.jpg"
className="size-full object-cover"
aria-label="Animation: a prompt becomes a live MCP server and connects to Claude Desktop"
>
<source src="/videos/hero.webm" type="video/webm" />
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
{/* HeroVideo: native <video> with autoplay+muted+loop, plus a
frosted mute toggle pinned bottom-right so visitors can
switch the narration on. See components/hero-video.tsx for
the autoplay/unmute mechanics. */}
<HeroVideo />
{/* Subtle vignette to integrate edges into the rest of the page */}
<div
aria-hidden

View File

@@ -231,22 +231,6 @@ export default function LoginPage() {
else if (p.sms) setMethod('phone');
})
.catch(() => undefined);
// Pre-select the country picker from the visitor's geo-IP. The
// backend reads Cloudflare's CF-IPCountry header (never the IP
// itself) and returns the ISO-3166 alpha-2 code. We only override
// the default 'CH' if the detected code is one we actually carry
// a dial code for — otherwise the picker would show "Select country".
apiFetch<{ country: string | null }>('/v1/geo/country')
.then((r) => {
const code = r.country;
if (!code) return;
if (COUNTRIES.some((c) => c.code === code)) {
setCountry(code);
}
})
.catch(() => undefined);
const err = new URLSearchParams(window.location.search).get('error');
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
}, []);

View File

@@ -0,0 +1,74 @@
'use client';
import { Volume2, VolumeX } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
/**
* Hero video player with a frosted-glass mute toggle.
*
* Why not the native `controls` attribute: it pulls in a chrome bar that
* fights the section's design vocabulary. We need exactly one control —
* mute / unmute — so we render just that, styled to match the rest of
* the brand (frosted indigo-bordered pill, bottom-right of the video).
*
* Default state is MUTED because every modern browser blocks autoplay
* for unmuted media. The toggle is the user's explicit affordance to
* turn the voice-over on; we keep the `muted` attribute on the element
* as the initial value so the autoplay still fires.
*
* On unmute we also call `.play()` defensively — some browsers (mobile
* Safari especially) pause the element when the muted property flips,
* even mid-playback. The promise is swallowed because a rejection here
* just means the user has to click again, which the toggle already
* affords.
*/
export function HeroVideo() {
const videoRef = useRef<HTMLVideoElement>(null);
const [muted, setMuted] = useState(true);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const next = !v.muted;
v.muted = next;
setMuted(next);
if (!next && v.paused) {
// Best-effort: keep playback running after the user enables sound.
v.play().catch(() => undefined);
}
}, []);
return (
<>
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
preload="auto"
poster="/videos/hero-poster.jpg"
className="size-full object-cover"
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
>
<source src="/videos/hero.webm" type="video/webm" />
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
<button
type="button"
onClick={toggleMute}
aria-label={muted ? 'Unmute video' : 'Mute video'}
aria-pressed={!muted}
className="absolute bottom-4 right-4 z-10 inline-flex size-10 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
borderColor: 'var(--color-border)',
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
}}
>
{muted ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button>
</>
);
}

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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Binary file not shown.