feat(video): v10 hero video with mute toggle — voice + bg music
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
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:
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}, []);
|
||||
|
||||
74
apps/web/components/hero-video.tsx
Normal file
74
apps/web/components/hero-video.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user