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:
parent
6197ee7f5e
commit
438ce3cfbc
@ -10,7 +10,6 @@ import { accountRoutes } from './routes/account.js';
|
|||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
import { billingRoutes } from './routes/billing.js';
|
import { billingRoutes } from './routes/billing.js';
|
||||||
import { geoRoutes } from './routes/geo.js';
|
|
||||||
import { oauthRoutes } from './routes/oauth.js';
|
import { oauthRoutes } from './routes/oauth.js';
|
||||||
import { serverRoutes } from './routes/servers.js';
|
import { serverRoutes } from './routes/servers.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
@ -82,7 +81,6 @@ await app.register(templateRoutes);
|
|||||||
await app.register(billingRoutes);
|
await app.register(billingRoutes);
|
||||||
await app.register(supportRoutes);
|
await app.register(supportRoutes);
|
||||||
await app.register(accountRoutes);
|
await app.register(accountRoutes);
|
||||||
await app.register(geoRoutes);
|
|
||||||
|
|
||||||
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
|
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
|
||||||
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier
|
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier
|
||||||
|
|||||||
@ -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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { HeroStepRotator } from '@/components/hero-step-rotator';
|
import { HeroStepRotator } from '@/components/hero-step-rotator';
|
||||||
|
import { HeroVideo } from '@/components/hero-video';
|
||||||
import { JsonLd } from '@/components/json-ld';
|
import { JsonLd } from '@/components/json-ld';
|
||||||
import { ParticleHero } from '@/components/particle-hero';
|
import { ParticleHero } from '@/components/particle-hero';
|
||||||
import { PulseLink } from '@/components/pulse';
|
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"
|
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video w-full">
|
<div className="relative aspect-video w-full">
|
||||||
<video
|
{/* HeroVideo: native <video> with autoplay+muted+loop, plus a
|
||||||
autoPlay
|
frosted mute toggle pinned bottom-right so visitors can
|
||||||
muted
|
switch the narration on. See components/hero-video.tsx for
|
||||||
loop
|
the autoplay/unmute mechanics. */}
|
||||||
playsInline
|
<HeroVideo />
|
||||||
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>
|
|
||||||
{/* Subtle vignette to integrate edges into the rest of the page */}
|
{/* Subtle vignette to integrate edges into the rest of the page */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@ -231,22 +231,6 @@ export default function LoginPage() {
|
|||||||
else if (p.sms) setMethod('phone');
|
else if (p.sms) setMethod('phone');
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.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');
|
const err = new URLSearchParams(window.location.search).get('error');
|
||||||
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
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 = {
|
const renderUniforms = {
|
||||||
uPositions: { value: rtB.texture },
|
uPositions: { value: rtB.texture },
|
||||||
// Huge soft blobs at very low per-particle alpha → no individual
|
// Bigger dots + higher base alpha = more volumetric "calm field"
|
||||||
// dots are visible, but 65k of them additively composite into a
|
// read at the load-in (was 1.8 / 0.42 — read as too thin, looked
|
||||||
// continuous indigo cloud. This matches the brief "no white dots,
|
// stuttery because individual particles were hard to track between
|
||||||
// just a glow." When dots were 2.8px at 0.6 alpha, dense areas
|
// frames). With these values the field has a denser cumulative
|
||||||
// saturated additive-blended into white; with 14px at 0.05 the
|
// glow without any change to the simulation itself.
|
||||||
// saturation point is far above what 65k particles ever sum to,
|
uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 },
|
||||||
// so the cloud stays indigo even at its brightest.
|
|
||||||
uPointSize: { value: textureSize === 256 ? 14.0 : 20.0 },
|
|
||||||
uDpr: { value: dpr },
|
uDpr: { value: dpr },
|
||||||
uColorCalm: { value: colorCalm },
|
uColorCalm: { value: colorCalm },
|
||||||
uColorHot: { value: colorHot },
|
uColorHot: { value: colorHot },
|
||||||
uBaseAlpha: { value: 0.055 },
|
uBaseAlpha: { value: 0.6 },
|
||||||
};
|
};
|
||||||
const particleMat = new THREE.ShaderMaterial({
|
const particleMat = new THREE.ShaderMaterial({
|
||||||
vertexShader: renderVertex,
|
vertexShader: renderVertex,
|
||||||
@ -252,15 +250,10 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
|
|||||||
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
|
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
|
||||||
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
|
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
|
||||||
|
|
||||||
// Asymmetric fade: ramp in quickly when the pointer enters, decay
|
// Fade ring in/out when the pointer enters/leaves.
|
||||||
// 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.
|
|
||||||
const targetActive = hasPointer ? 1 : 0;
|
const targetActive = hasPointer ? 1 : 0;
|
||||||
const cur = simUniforms.uRingActive.value;
|
simUniforms.uRingActive.value =
|
||||||
const alpha = targetActive > cur ? 0.14 : 0.012;
|
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
|
||||||
simUniforms.uRingActive.value = cur * (1 - alpha) + targetActive * alpha;
|
|
||||||
|
|
||||||
simUniforms.uTime.value = t;
|
simUniforms.uTime.value = t;
|
||||||
simUniforms.uDelta.value = delta;
|
simUniforms.uDelta.value = delta;
|
||||||
|
|||||||
@ -136,41 +136,28 @@ export function ParticleHero() {
|
|||||||
return () => reduce.removeEventListener('change', onReduceChange);
|
return () => reduce.removeEventListener('change', onReduceChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Static fallback: pure indigo radial glow, no dot grid. The
|
// Static fallback: radial indigo glow + faint dotted mask.
|
||||||
// dot-mask was confusing — it read as "stippled white texture"
|
// Used both for 'unknown' (pre-hydration) and 'fallback'.
|
||||||
// against the indigo glow rather than as resting particles. The
|
|
||||||
// cleaner, dotless gradient holds up better as a fallback.
|
|
||||||
if (cap.kind !== 'webgl') {
|
if (cap.kind !== 'webgl') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 size-full overflow-hidden"
|
className="absolute inset-0 size-full overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background:
|
backgroundImage: [
|
||||||
'radial-gradient(65% 80% at 50% 45%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)',
|
// 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
|
return <ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />;
|
||||||
// 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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ParticleHero;
|
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);
|
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
|
vec2 driftVel = vec2(-n2, n1) * 0.028 * uMotionScale; // curl-like rotation
|
||||||
|
|
||||||
// --- Mouse halo pull (attraction, not repulsion) ---
|
// --- Ring push: gradient of the ring field, pointing outward ---
|
||||||
// Particles are drawn toward a soft halo orbiting the cursor —
|
float h = 0.003;
|
||||||
// strongest at ~0.20 distance, fading both closer and farther.
|
float fx0 = ringField(pos - vec2(h, 0.0));
|
||||||
// Closer-fade prevents the cloud from collapsing onto the cursor;
|
float fx1 = ringField(pos + vec2(h, 0.0));
|
||||||
// farther-fade keeps the influence local. The result is a moving
|
float fy0 = ringField(pos - vec2(0.0, h));
|
||||||
// bright spot that follows the pointer with a continuous breathing
|
float fy1 = ringField(pos + vec2(0.0, h));
|
||||||
// ring of indigo around it, rather than the old outward push that
|
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
|
||||||
// hollowed the cloud where the cursor sat.
|
float fieldHere = ringField(pos);
|
||||||
vec2 toMouse = uRingPos - pos;
|
// Push along gradient — particles get nudged away from the ring crest.
|
||||||
float distToMouse = length(toMouse) + 0.001;
|
// Magnitude is scaled by uMotionScale so reduced-motion users get a
|
||||||
float halo = exp(-pow(distToMouse - 0.20, 2.0) * 22.0);
|
// softer shove while the ring position still tracks at full fidelity.
|
||||||
vec2 ringVel = (toMouse / distToMouse) * halo * 0.05 * uRingActive * uMotionScale;
|
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
|
||||||
|
|
||||||
// --- Soft containment toward origin if particle escaped ---
|
// --- Soft containment toward origin if particle escaped ---
|
||||||
float r = length(pos);
|
float r = length(pos);
|
||||||
@ -247,19 +247,14 @@ export const renderFragment = /* glsl */ `
|
|||||||
varying float vScale;
|
varying float vScale;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Soft Gaussian blob — no hard disc edge. Combined with the bigger
|
// Disc SDF — anti-aliased round dot.
|
||||||
// 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.
|
|
||||||
float d = length(gl_PointCoord - 0.5);
|
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;
|
if (a <= 0.001) discard;
|
||||||
|
|
||||||
// Velocity-driven mix kept, but with the new low base alpha the
|
// Velocity-driven mix: pin to indigo for typical drift, lerp toward
|
||||||
// green tint is barely visible — by design. The cloud is calm.
|
// 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);
|
float t = smoothstep(0.04, 0.18, vVel);
|
||||||
vec3 col = mix(uColorCalm, uColorHot, t);
|
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.
BIN
remotion/Midnight Dashboard.mp3
Normal file
BIN
remotion/Midnight Dashboard.mp3
Normal file
Binary file not shown.
@ -5,8 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"studio": "remotion studio src/index.ts",
|
"studio": "remotion studio src/index.ts",
|
||||||
"render:mp4": "remotion render src/index.ts HeroVideo out/hero-raw.mp4 --codec h264 --crf 28 --pixel-format yuv420p && node scripts/postprocess.mjs",
|
"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:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32 --audio-codec opus",
|
||||||
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 325 --image-format jpeg --jpeg-quality 85",
|
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 1620 --image-format jpeg --jpeg-quality 85",
|
||||||
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
|
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
|
||||||
"to-web": "node scripts/publish-to-web.mjs",
|
"to-web": "node scripts/publish-to-web.mjs",
|
||||||
"build": "pnpm render:all && pnpm to-web"
|
"build": "pnpm render:all && pnpm to-web"
|
||||||
|
|||||||
BIN
remotion/public/Sub-bass Lullaby.wav
Normal file
BIN
remotion/public/Sub-bass Lullaby.wav
Normal file
Binary file not shown.
@ -34,7 +34,9 @@ const ffmpegArgs = [
|
|||||||
'-colorspace', 'bt709',
|
'-colorspace', 'bt709',
|
||||||
'-preset', 'slow',
|
'-preset', 'slow',
|
||||||
'-crf', '23',
|
'-crf', '23',
|
||||||
'-an',
|
'-c:a', 'aac',
|
||||||
|
'-b:a', '96k',
|
||||||
|
'-ar', '44100',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
outPath,
|
outPath,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,53 +1,80 @@
|
|||||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
Audio,
|
||||||
|
staticFile,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
} from 'remotion';
|
||||||
import { C } from './lib/colors';
|
import { C } from './lib/colors';
|
||||||
import { clampLerp } from './lib/easings';
|
import { clampLerp } from './lib/easings';
|
||||||
|
import { HookScene } from './scenes/HookScene';
|
||||||
import { PromptScene } from './scenes/PromptScene';
|
import { PromptScene } from './scenes/PromptScene';
|
||||||
import { SecretsScene } from './scenes/SecretsScene';
|
import { SecretsScene } from './scenes/SecretsScene';
|
||||||
import { BuildScene } from './scenes/BuildScene';
|
import { BuildScene } from './scenes/BuildScene';
|
||||||
|
import { IsolationScene } from './scenes/IsolationScene';
|
||||||
import { LibraryScene } from './scenes/LibraryScene';
|
import { LibraryScene } from './scenes/LibraryScene';
|
||||||
import { DiscoveryScene } from './scenes/DiscoveryScene';
|
import { DiscoveryScene } from './scenes/DiscoveryScene';
|
||||||
|
import { LogoLockup } from './scenes/LogoLockup';
|
||||||
|
|
||||||
export const HERO_FPS = 30;
|
export const HERO_FPS = 30;
|
||||||
export const HERO_DURATION_FRAMES = 450; // 15s
|
// 71.5s @ 30fps. Audio is 71.47s — the 30ms tail gives us room for a clean
|
||||||
|
// loop-fade to black.
|
||||||
|
export const HERO_DURATION_FRAMES = 2145;
|
||||||
|
|
||||||
// Scene timing. Each beat overlaps the next by ~6 frames so the
|
// 8-phase composition matched to the 71.47s narration. Each scene overlaps
|
||||||
// transitions crossfade rather than hard-cut.
|
// its neighbour by 6 frames so the parent crossfade does the transition.
|
||||||
//
|
//
|
||||||
// P1 prompt [ 0, 81) 81f prompt typed
|
// Phase ranges below match the brief; sum of unique frames = 2145.
|
||||||
// P1.5 secrets [ 75, 165) 90f vault panel + arrow fork
|
//
|
||||||
// P2 build [159, 261) 102f log + server card
|
// Hook [ 0, 360) 360f "code-gen solved, plumbing left"
|
||||||
// P3 library [255, 366) 111f morph into template grid
|
// Prompt [ 354, 720) 366f "one sentence describes your tool"
|
||||||
// P4 discovery [360, 450) 90f fork counter + community
|
// Secrets [ 714, 1170) 456f "vault, AES-256, injected at runtime"
|
||||||
|
// Build [1164, 1380) 216f "TypeScript, checks, ship — < 60s"
|
||||||
|
// Isolation [1374, 1800) 426f "hardened container · OAuth 2.1+PKCE"
|
||||||
|
// Library [1794, 2010) 216f "export source · share template"
|
||||||
|
// Discovery [2004, 2100) 96f "one sentence in · live server out"
|
||||||
|
// LogoLockup [2094, 2145) 51f "BuildMyMCPServer.com"
|
||||||
export const BEAT = {
|
export const BEAT = {
|
||||||
prompt: { in: 0, out: 81 },
|
hook: { in: 0, out: 360 },
|
||||||
secrets: { in: 75, out: 165 },
|
prompt: { in: 354, out: 720 },
|
||||||
build: { in: 159, out: 261 },
|
secrets: { in: 714, out: 1170 },
|
||||||
library: { in: 255, out: 366 },
|
build: { in: 1164, out: 1380 },
|
||||||
discovery: { in: 360, out: 450 },
|
isolation: { in: 1374, out: 1800 },
|
||||||
|
library: { in: 1794, out: 2010 },
|
||||||
|
discovery: { in: 2004, out: 2100 },
|
||||||
|
lockup: { in: 2094, out: 2145 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const FADE_FRAMES = 12;
|
const LOOP_FADE_FRAMES = 18;
|
||||||
|
// Subtitle/caption layer was tried in v7 and pulled — the owner's
|
||||||
|
// note: most captions felt aggressive and on-the-nose, the technical
|
||||||
|
// terms (PKCE etc.) created more friction than they removed. We keep
|
||||||
|
// `components/Subtitle.tsx` around for future use but the composition
|
||||||
|
// no longer renders it. Scene visuals + the voice-over now carry the
|
||||||
|
// whole story.
|
||||||
|
|
||||||
export function HeroVideo() {
|
export function HeroVideo() {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
// Loop-clean: ramp opacity to 0 over the last 12 frames so the final frame
|
// Loop-clean fade-out on the final 18 frames so the final frame ≈ black.
|
||||||
// ≈ frame 0 (both essentially-black). Browser <video loop> jumps back and
|
// Note: LogoLockup itself also fades internally; this is a safety net.
|
||||||
// the seam is invisible.
|
|
||||||
const loopFade = interpolate(
|
const loopFade = interpolate(
|
||||||
frame,
|
frame,
|
||||||
[HERO_DURATION_FRAMES - FADE_FRAMES, HERO_DURATION_FRAMES - 1],
|
[HERO_DURATION_FRAMES - LOOP_FADE_FRAMES, HERO_DURATION_FRAMES - 1],
|
||||||
[1, 0],
|
[1, 0],
|
||||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
|
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Crossfade alpha for each scene over its 6-frame entry/exit overlap.
|
const hookAlpha = crossfade(frame, BEAT.hook.in, BEAT.hook.out, 6);
|
||||||
const promptAlpha = crossfade(frame, BEAT.prompt.in, BEAT.prompt.out, 6);
|
const promptAlpha = crossfade(frame, BEAT.prompt.in, BEAT.prompt.out, 6);
|
||||||
const secretsAlpha = crossfade(frame, BEAT.secrets.in, BEAT.secrets.out, 6);
|
const secretsAlpha = crossfade(frame, BEAT.secrets.in, BEAT.secrets.out, 6);
|
||||||
const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6);
|
const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6);
|
||||||
|
const isolationAlpha = crossfade(frame, BEAT.isolation.in, BEAT.isolation.out, 6);
|
||||||
const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6);
|
const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6);
|
||||||
const discoveryAlpha = crossfade(frame, BEAT.discovery.in, BEAT.discovery.out, 6);
|
const discoveryAlpha = crossfade(frame, BEAT.discovery.in, BEAT.discovery.out, 6);
|
||||||
|
const lockupAlpha = crossfade(frame, BEAT.lockup.in, BEAT.lockup.out, 6);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}>
|
<AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}>
|
||||||
@ -55,6 +82,11 @@ export function HeroVideo() {
|
|||||||
<Vignette />
|
<Vignette />
|
||||||
|
|
||||||
<AbsoluteFill style={{ opacity: loopFade }}>
|
<AbsoluteFill style={{ opacity: loopFade }}>
|
||||||
|
{hookAlpha > 0 && (
|
||||||
|
<AbsoluteFill style={{ opacity: hookAlpha }}>
|
||||||
|
<HookScene localFrame={frame - BEAT.hook.in} fps={fps} />
|
||||||
|
</AbsoluteFill>
|
||||||
|
)}
|
||||||
{promptAlpha > 0 && (
|
{promptAlpha > 0 && (
|
||||||
<AbsoluteFill style={{ opacity: promptAlpha }}>
|
<AbsoluteFill style={{ opacity: promptAlpha }}>
|
||||||
<PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} />
|
<PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} />
|
||||||
@ -70,6 +102,11 @@ export function HeroVideo() {
|
|||||||
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} />
|
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} />
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
)}
|
)}
|
||||||
|
{isolationAlpha > 0 && (
|
||||||
|
<AbsoluteFill style={{ opacity: isolationAlpha }}>
|
||||||
|
<IsolationScene localFrame={frame - BEAT.isolation.in} fps={fps} />
|
||||||
|
</AbsoluteFill>
|
||||||
|
)}
|
||||||
{libraryAlpha > 0 && (
|
{libraryAlpha > 0 && (
|
||||||
<AbsoluteFill style={{ opacity: libraryAlpha }}>
|
<AbsoluteFill style={{ opacity: libraryAlpha }}>
|
||||||
<LibraryScene localFrame={frame - BEAT.library.in} fps={fps} />
|
<LibraryScene localFrame={frame - BEAT.library.in} fps={fps} />
|
||||||
@ -80,14 +117,37 @@ export function HeroVideo() {
|
|||||||
<DiscoveryScene localFrame={frame - BEAT.discovery.in} fps={fps} />
|
<DiscoveryScene localFrame={frame - BEAT.discovery.in} fps={fps} />
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
)}
|
)}
|
||||||
|
{lockupAlpha > 0 && (
|
||||||
|
<AbsoluteFill style={{ opacity: lockupAlpha }}>
|
||||||
|
<LogoLockup localFrame={frame - BEAT.lockup.in} fps={fps} />
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
|
)}
|
||||||
|
</AbsoluteFill>
|
||||||
|
|
||||||
|
{/* Audio: voice-over at full volume + sub-bass music ducked underneath.
|
||||||
|
Music fades in over 30f, fades out over the final 24f. */}
|
||||||
|
<Audio src={staticFile('audio.mp3')} />
|
||||||
|
<Audio
|
||||||
|
src={staticFile('Sub-bass Lullaby.wav')}
|
||||||
|
volume={(f) => {
|
||||||
|
const fadeIn = interpolate(f, [0, 30], [0, 0.16], {
|
||||||
|
extrapolateLeft: 'clamp',
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
const fadeOut = interpolate(
|
||||||
|
f,
|
||||||
|
[HERO_DURATION_FRAMES - 24, HERO_DURATION_FRAMES - 1],
|
||||||
|
[0.16, 0],
|
||||||
|
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
|
||||||
|
);
|
||||||
|
return Math.min(fadeIn, fadeOut);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function crossfade(frame: number, start: number, end: number, overlap: number) {
|
function crossfade(frame: number, start: number, end: number, overlap: number) {
|
||||||
// Returns 0..1: ramps up `overlap` frames after start, ramps down `overlap`
|
|
||||||
// frames before end. Outside [start, end) returns 0.
|
|
||||||
if (frame < start || frame >= end) return 0;
|
if (frame < start || frame >= end) return 0;
|
||||||
const intoStart = clampLerp(frame, start, start + overlap);
|
const intoStart = clampLerp(frame, start, start + overlap);
|
||||||
const beforeEnd = 1 - clampLerp(frame, end - overlap, end);
|
const beforeEnd = 1 - clampLerp(frame, end - overlap, end);
|
||||||
@ -122,8 +182,6 @@ function DottedGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Vignette() {
|
function Vignette() {
|
||||||
// Faint indigo glow at the center so the product mockups have
|
|
||||||
// something to sit on. Static across the whole timeline.
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
136
remotion/src/components/Subtitle.tsx
Normal file
136
remotion/src/components/Subtitle.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinetic-typography subtitle overlay.
|
||||||
|
*
|
||||||
|
* Designed to layer ON TOP of the existing scenes — they continue running
|
||||||
|
* underneath, and these captions ride the lower third (or any chosen Y)
|
||||||
|
* to fill what would otherwise be "hold" moments where the scene's
|
||||||
|
* animation has ended but the voice-over keeps going.
|
||||||
|
*
|
||||||
|
* Curation principle: only key emphasis phrases get captioned, never the
|
||||||
|
* full voice-over. Pulled from the Apple / Linear / Stripe playbook —
|
||||||
|
* captions punctuate, they don't transcribe.
|
||||||
|
*
|
||||||
|
* Word-by-word entrance: each word springs up from +16px with a tight
|
||||||
|
* damped spring at a 4-frame stagger. Whole-line exit fade over 14
|
||||||
|
* frames. Rendering is gated by `local < -6 || local > durationFrames+6`
|
||||||
|
* so the component returns null outside its window — keeps the render
|
||||||
|
* tree slim even when twelve captions are scheduled.
|
||||||
|
*
|
||||||
|
* Typography:
|
||||||
|
* - `variant='sans'`: ui-sans-serif stack, weight 700, tightened tracking
|
||||||
|
* - `variant='mono'`: ui-monospace stack, weight 700, neutral tracking —
|
||||||
|
* use for technical labels like `AES-256` or `OAuth 2.1 + PKCE` so
|
||||||
|
* they read as terms rather than prose
|
||||||
|
*
|
||||||
|
* Size tiers (1080p reference):
|
||||||
|
* - `lg` 54px — default body caption
|
||||||
|
* - `xl` 76px — emphasis lines that should hit hard
|
||||||
|
* - `display` 104px — closing-shot hero text ("Live server out.")
|
||||||
|
*
|
||||||
|
* Positioning is from-top percentage. Default 84% sits the caption in the
|
||||||
|
* lower third without colliding with most scene content. For the closing
|
||||||
|
* display-size line set `yPercent` to 50 so it lands centred.
|
||||||
|
*/
|
||||||
|
interface SubtitleProps {
|
||||||
|
text: string;
|
||||||
|
/** Global frame at which the entrance begins. */
|
||||||
|
startFrame: number;
|
||||||
|
/** Total visible frames including entrance + hold + 14-frame exit. */
|
||||||
|
durationFrames: number;
|
||||||
|
variant?: 'sans' | 'mono';
|
||||||
|
size?: 'lg' | 'xl' | 'display';
|
||||||
|
/** Y position as % from top of the 1920×1080 frame. Default 84 (lower third). */
|
||||||
|
yPercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORD_STAGGER = 4;
|
||||||
|
const EXIT_FRAMES = 14;
|
||||||
|
|
||||||
|
export function Subtitle({
|
||||||
|
text,
|
||||||
|
startFrame,
|
||||||
|
durationFrames,
|
||||||
|
variant = 'sans',
|
||||||
|
size = 'lg',
|
||||||
|
yPercent = 84,
|
||||||
|
}: SubtitleProps) {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const local = frame - startFrame;
|
||||||
|
|
||||||
|
// Bail out cheap when off-screen. The 6-frame envelope on each side
|
||||||
|
// covers the spring overshoot at entrance + exit so we don't blink.
|
||||||
|
if (local < -6 || local > durationFrames + 6) return null;
|
||||||
|
|
||||||
|
const words = text.split(' ');
|
||||||
|
|
||||||
|
const exitT = interpolate(
|
||||||
|
local,
|
||||||
|
[durationFrames - EXIT_FRAMES, durationFrames],
|
||||||
|
[1, 0],
|
||||||
|
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const fontPx = size === 'display' ? 104 : size === 'xl' ? 76 : 54;
|
||||||
|
const yPx = (yPercent / 100) * 1080;
|
||||||
|
|
||||||
|
const fontFamily =
|
||||||
|
variant === 'mono'
|
||||||
|
? 'ui-monospace, SF Mono, Menlo, monospace'
|
||||||
|
: 'ui-sans-serif, system-ui, -apple-system, sans-serif';
|
||||||
|
|
||||||
|
const letterSpacing =
|
||||||
|
size === 'display' ? '-0.025em' : variant === 'mono' ? '0' : '-0.015em';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: yPx,
|
||||||
|
textAlign: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
fontFamily,
|
||||||
|
fontSize: fontPx,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing,
|
||||||
|
lineHeight: 1.08,
|
||||||
|
color: '#fafafa',
|
||||||
|
// Drop-shadow blurs the text against whatever scene paint sits
|
||||||
|
// behind it; the tiny indigo glow ties the type to the brand
|
||||||
|
// without making the caption itself look colored.
|
||||||
|
textShadow:
|
||||||
|
'0 2px 24px rgba(0, 0, 0, 0.55), 0 0 1px rgba(99, 102, 241, 0.22)',
|
||||||
|
opacity: exitT,
|
||||||
|
padding: '0 6%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{words.map((word, i) => {
|
||||||
|
const wordEnter = i * WORD_STAGGER;
|
||||||
|
const t = spring({
|
||||||
|
frame: local - wordEnter,
|
||||||
|
fps,
|
||||||
|
config: { damping: 18, mass: 0.5, stiffness: 140 },
|
||||||
|
durationInFrames: 16,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${word}-${i}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
transform: `translateY(${(1 - t) * 16}px)`,
|
||||||
|
opacity: t,
|
||||||
|
marginRight: '0.28em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -23,11 +23,16 @@ const LOG_LINES = [
|
|||||||
{ label: 'Deploying', detail: 'live' },
|
{ label: 'Deploying', detail: 'live' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LINE_STAGGER = 10;
|
const LINE_STAGGER = 18;
|
||||||
const LINE_START = 4;
|
const LINE_START = 8;
|
||||||
const CARD_START = 58;
|
const CARD_START = 110;
|
||||||
const SLOTS_START = 78;
|
const SLOTS_START = 140;
|
||||||
const CAPTION_START = 92;
|
const CAPTION_START = 160;
|
||||||
|
const SCENE_LEN = 216;
|
||||||
|
// Countdown displays "< NN s" in the corner while build is running.
|
||||||
|
// We tick from 58 → 12 between frames 8 → CARD_START.
|
||||||
|
const COUNTDOWN_START_VAL = 58;
|
||||||
|
const COUNTDOWN_END_VAL = 12;
|
||||||
|
|
||||||
export function BuildScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
export function BuildScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
const panelIn = springIn(localFrame, fps, 0);
|
const panelIn = springIn(localFrame, fps, 0);
|
||||||
@ -45,6 +50,13 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
|
|||||||
// Caption appears last.
|
// Caption appears last.
|
||||||
const captionIn = clampLerp(localFrame, CAPTION_START, CAPTION_START + 10);
|
const captionIn = clampLerp(localFrame, CAPTION_START, CAPTION_START + 10);
|
||||||
|
|
||||||
|
// Countdown: ticks from 58 → 12 as the log lines stream.
|
||||||
|
const countT = clampLerp(localFrame, 8, CARD_START);
|
||||||
|
const countVal = Math.round(
|
||||||
|
interpolate(countT, [0, 1], [COUNTDOWN_START_VAL, COUNTDOWN_END_VAL]),
|
||||||
|
);
|
||||||
|
const countShown = localFrame >= 4 && localFrame <= CARD_START + 24;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -88,7 +100,14 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>build · notion-search</span>
|
<span>build · notion-search</span>
|
||||||
<span style={{ color: C.fgMuted }}>● running</span>
|
<span style={{ color: C.fgMuted, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
{countShown && (
|
||||||
|
<span style={{ color: C.accent, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{'< '}{countVal}{' s'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>● running</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{LOG_LINES.map((line, i) => (
|
{LOG_LINES.map((line, i) => (
|
||||||
|
|||||||
@ -1,62 +1,125 @@
|
|||||||
import { interpolate } from 'remotion';
|
import { interpolate } from 'remotion';
|
||||||
import { C } from '../lib/colors';
|
import { C } from '../lib/colors';
|
||||||
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
|
import { softSpring, clampLerp } from '../lib/easings';
|
||||||
|
|
||||||
// Phase 4 (frames 360–450 global → localFrame 0..90): the user's card is
|
// Phase 4 · 90 frames · 3 s
|
||||||
// the hero. Fork count ticks 0 → 247 with micro-particles. Subtitle pops:
|
|
||||||
// "1,200+ developers building".
|
|
||||||
//
|
//
|
||||||
// New beat in v5: as forks accumulate, a few non-hero cards get a small
|
// Two-lane architecture diagram. PRIOR VERSIONS:
|
||||||
// "NEW SECRET" pulse near their slot, implying each forker plugs in their
|
// v7: fake "1,200+ developers building" fork counter (pulled)
|
||||||
// own credential. Subtle, not loud.
|
// v9: lane-boxes entered staggered, server entered LATE at frame 18 —
|
||||||
|
// owner noted "comes from the right not visible at the start"
|
||||||
|
//
|
||||||
|
// THIS VERSION (v10):
|
||||||
|
// • All seven boxes appear simultaneously at frame 0-6 (no late
|
||||||
|
// server arrival).
|
||||||
|
// • A guided GLOW TOUR runs in parallel across both lanes — prompt
|
||||||
|
// and vault light up first, then AI and encrypted, then code and
|
||||||
|
// env, then the server gets its own glow as the convergent arrows
|
||||||
|
// reach it. Each box's border pulses indigo for ~14 frames at its
|
||||||
|
// turn so the eye reads the data flow without anything needing to
|
||||||
|
// pop in or out.
|
||||||
|
// • Closing line slides DOWN from above the canvas and lands
|
||||||
|
// centred; the diagram fades cleanly to ~0.12 opacity behind it
|
||||||
|
// so the closing card sits in clear space, not overlapping.
|
||||||
|
|
||||||
const GRID_COLS = 3;
|
interface LaneBox {
|
||||||
const GRID_ROWS = 2;
|
id: string;
|
||||||
const CARD_W = 340;
|
label: string;
|
||||||
const CARD_H = 180;
|
detail?: string;
|
||||||
const GAP = 28;
|
cx: number;
|
||||||
|
/** Local frame at which this box's glow starts. */
|
||||||
|
glowAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const CARDS = [
|
const LANE_TOP_Y = 360;
|
||||||
{ name: 'github-issues', toolCount: 3, highlighted: false },
|
const LANE_BOTTOM_Y = 660;
|
||||||
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
|
|
||||||
{ name: 'slack-digest', toolCount: 4, highlighted: false },
|
const BOX_W = 200;
|
||||||
{ name: 'linear-tasks', toolCount: 5, highlighted: false },
|
const BOX_H = 96;
|
||||||
{ name: 'gmail-triage', toolCount: 3, highlighted: false },
|
|
||||||
{ name: 'jira-sprint', toolCount: 6, highlighted: false },
|
// Glow tour timing — top + bottom lane advance in parallel so the
|
||||||
|
// "two paths" narrative is enforced by the animation cadence itself.
|
||||||
|
const GLOW_DURATION = 16; // each box stays "hot" this long
|
||||||
|
const GLOW_STAGGER = 9; // gap between consecutive boxes inside one lane
|
||||||
|
|
||||||
|
const TOP_LANE_BOXES: LaneBox[] = [
|
||||||
|
{ id: 'prompt', label: 'prompt', detail: 'your sentence', cx: 260, glowAt: 4 },
|
||||||
|
{ id: 'ai', label: 'AI', detail: 'generates code', cx: 560, glowAt: 4 + GLOW_STAGGER },
|
||||||
|
{ id: 'code', label: 'code', detail: 'typescript', cx: 860, glowAt: 4 + GLOW_STAGGER * 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TARGET_FORKS = 247;
|
const BOTTOM_LANE_BOXES: LaneBox[] = [
|
||||||
|
{ id: 'vault', label: 'vault', detail: 'AES-256', cx: 260, glowAt: 4 },
|
||||||
|
{ id: 'enc', label: 'encrypted', detail: 'at rest', cx: 560, glowAt: 4 + GLOW_STAGGER },
|
||||||
|
{ id: 'envar', label: 'env', detail: 'injected at runtime', cx: 860, glowAt: 4 + GLOW_STAGGER * 2 },
|
||||||
|
];
|
||||||
|
|
||||||
// Frames at which each non-hero card flashes "NEW SECRET" (staggered).
|
const SERVER_CX = 1480;
|
||||||
const NEW_SECRET_TIMINGS: Record<number, number> = {
|
const SERVER_CY = 510;
|
||||||
0: 22, // github-issues
|
const SERVER_W = 290;
|
||||||
3: 36, // linear-tasks
|
const SERVER_H = 320;
|
||||||
5: 52, // jira-sprint
|
const SERVER_GLOW_AT = 4 + GLOW_STAGGER * 3; // glow lights up after both lanes' last boxes
|
||||||
};
|
|
||||||
|
// Closing line takes the final ~1.3 s. Diagram fades to a very low
|
||||||
|
// opacity at this moment so the card has clean space.
|
||||||
|
const CLOSING_START = 54;
|
||||||
|
|
||||||
export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
const zoom = clampLerp(localFrame, 0, 50);
|
// Universal entrance: every visible element shares this fade-in over
|
||||||
const scale = interpolate(zoom, [0, 1], [1.0, 1.08]);
|
// frames 0–8. No element pops in late.
|
||||||
|
const allInOpacity = clampLerp(localFrame, 0, 8);
|
||||||
|
|
||||||
const tickProgress = clampLerp(localFrame, 6, 42);
|
// Diagram dim-down as the closing line takes the stage. Drops to
|
||||||
const forkCount = Math.floor(tickProgress * TARGET_FORKS);
|
// 0.12 (still legible behind, no visual fight) by frame CLOSING + 14.
|
||||||
|
const diagramDim = 1 - clampLerp(localFrame, CLOSING_START, CLOSING_START + 14) * 0.88;
|
||||||
|
|
||||||
const subIn = softSpring(localFrame, fps, 28, 18);
|
const baseAlpha = allInOpacity * diagramDim;
|
||||||
|
|
||||||
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
|
|
||||||
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
|
|
||||||
const gridLeft = (1920 - gridW) / 2;
|
|
||||||
const gridTop = (1080 - gridH) / 2 + 30;
|
|
||||||
|
|
||||||
const heroX = gridLeft + 1 * (CARD_W + GAP);
|
|
||||||
const heroY = gridTop + 0 * (CARD_H + GAP);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
{/* Section caption — fades out as closing card slides in */}
|
||||||
|
<SectionCaption localFrame={localFrame} />
|
||||||
|
|
||||||
|
<div style={{ opacity: baseAlpha }}>
|
||||||
|
{/* Lane boxes — all appear at frame 0, glow in sequence */}
|
||||||
|
{TOP_LANE_BOXES.map((b) => (
|
||||||
|
<LaneBoxView key={b.id} box={b} cy={LANE_TOP_Y} localFrame={localFrame} fps={fps} />
|
||||||
|
))}
|
||||||
|
{BOTTOM_LANE_BOXES.map((b) => (
|
||||||
|
<LaneBoxView key={b.id} box={b} cy={LANE_BOTTOM_Y} localFrame={localFrame} fps={fps} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Intra-lane arrows draw between adjacent glows */}
|
||||||
|
<IntraLaneArrows boxes={TOP_LANE_BOXES} cy={LANE_TOP_Y} localFrame={localFrame} />
|
||||||
|
<IntraLaneArrows boxes={BOTTOM_LANE_BOXES} cy={LANE_BOTTOM_Y} localFrame={localFrame} />
|
||||||
|
|
||||||
|
{/* Server connectors converge from both lane endpoints */}
|
||||||
|
<ServerConnectors localFrame={localFrame} />
|
||||||
|
|
||||||
|
{/* "no shared node" vertical chip — the visual claim */}
|
||||||
|
<NoSharedNodeDivider localFrame={localFrame} />
|
||||||
|
|
||||||
|
{/* Server box — present from frame 0, glows at SERVER_GLOW_AT */}
|
||||||
|
<ServerBox localFrame={localFrame} fps={fps} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Closing line — slides DOWN from above the canvas, lands centred.
|
||||||
|
Sits on top of the dimmed diagram with clear space around it. */}
|
||||||
|
<ClosingLine localFrame={localFrame} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sub-components ----
|
||||||
|
|
||||||
|
function SectionCaption({ localFrame }: { localFrame: number }) {
|
||||||
|
const inT = clampLerp(localFrame, 4, 18);
|
||||||
|
const outT = 1 - clampLerp(localFrame, CLOSING_START - 6, CLOSING_START + 8);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: gridTop - 80,
|
top: 130,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@ -65,279 +128,444 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
|
|||||||
letterSpacing: 5,
|
letterSpacing: 5,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: C.fgSubtle,
|
color: C.fgSubtle,
|
||||||
|
opacity: Math.min(inT, outT),
|
||||||
|
transform: `translateY(${interpolate(inT, [0, 1], [6, 0])}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
template library
|
two paths · never cross
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
transformOrigin: `${heroX + CARD_W / 2}px ${heroY + CARD_H / 2}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{CARDS.map((card, i) => {
|
|
||||||
const col = i % GRID_COLS;
|
|
||||||
const row = Math.floor(i / GRID_COLS);
|
|
||||||
const x = gridLeft + col * (CARD_W + GAP);
|
|
||||||
const y = gridTop + row * (CARD_H + GAP);
|
|
||||||
|
|
||||||
const isHero = i === 1;
|
|
||||||
const dim = !isHero ? interpolate(zoom, [0, 1], [1, 0.55]) : 1;
|
|
||||||
|
|
||||||
// NEW SECRET pulse for non-hero cards.
|
|
||||||
const flashFrame = NEW_SECRET_TIMINGS[i];
|
|
||||||
const newSecretPulse =
|
|
||||||
flashFrame !== undefined
|
|
||||||
? Math.max(0, 1 - Math.max(0, localFrame - flashFrame) / 20)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
width: CARD_W,
|
|
||||||
height: CARD_H,
|
|
||||||
opacity: dim,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateCardInner
|
|
||||||
card={card}
|
|
||||||
isHero={isHero}
|
|
||||||
forkCount={isHero ? forkCount : null}
|
|
||||||
newSecretPulse={newSecretPulse}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<ForkParticles
|
|
||||||
localFrame={localFrame}
|
|
||||||
x={heroX + CARD_W - 22}
|
|
||||||
y={heroY + CARD_H - 22}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 90,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
|
||||||
fontSize: 22,
|
|
||||||
color: C.fgMuted,
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
opacity: subIn,
|
|
||||||
transform: `translateY(${interpolate(subIn, [0, 1], [10, 0])}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: C.fg, fontWeight: 600 }}>1,200+</span> developers building
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateCardInner({
|
function LaneBoxView({
|
||||||
card,
|
box,
|
||||||
isHero,
|
cy,
|
||||||
forkCount,
|
localFrame,
|
||||||
newSecretPulse,
|
fps,
|
||||||
}: {
|
}: {
|
||||||
card: typeof CARDS[number];
|
box: LaneBox;
|
||||||
isHero: boolean;
|
cy: number;
|
||||||
forkCount: number | null;
|
localFrame: number;
|
||||||
newSecretPulse: number;
|
fps: number;
|
||||||
}) {
|
}) {
|
||||||
const border = isHero ? C.accent : C.border;
|
// Box is visible from frame 0 with a soft entrance.
|
||||||
const shadow = isHero
|
const entryT = softSpring(localFrame, fps, 0, 18);
|
||||||
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
|
const entryOpacity = clampLerp(localFrame, 0, 14);
|
||||||
: `0 8px 24px rgba(0,0,0,0.35)`;
|
const scale = interpolate(entryT, [0, 1], [0.92, 1]);
|
||||||
|
|
||||||
|
// Glow ramp — peaks at glowAt + GLOW_DURATION/2, settles after.
|
||||||
|
const glowRamp = clampLerp(localFrame, box.glowAt, box.glowAt + 4);
|
||||||
|
const glowFade = 1 - clampLerp(localFrame, box.glowAt + GLOW_DURATION - 4, box.glowAt + GLOW_DURATION + 6);
|
||||||
|
const glow = Math.min(glowRamp, glowFade);
|
||||||
|
|
||||||
|
// Once "lit", the box keeps a slightly elevated default state — the
|
||||||
|
// viewer should be able to read "this one has been visited."
|
||||||
|
const visited = localFrame > box.glowAt + GLOW_DURATION ? 0.45 : 0;
|
||||||
|
const heat = Math.max(glow, visited);
|
||||||
|
|
||||||
|
// Border colour ramps from accentDim → accent as heat rises.
|
||||||
|
const borderColor = heat > 0.05 ? C.accent : C.accentDim;
|
||||||
|
const ringIntensity = 0.08 + heat * 0.35;
|
||||||
|
const liftPx = -heat * 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
position: 'absolute',
|
||||||
height: '100%',
|
left: box.cx - BOX_W / 2,
|
||||||
|
top: cy - BOX_H / 2 + liftPx,
|
||||||
|
width: BOX_W,
|
||||||
|
height: BOX_H,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
opacity: entryOpacity,
|
||||||
backgroundColor: C.bgElevated,
|
backgroundColor: C.bgElevated,
|
||||||
border: `1.5px solid ${border}`,
|
border: `1.5px solid ${borderColor}`,
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
padding: '16px 20px',
|
boxShadow: `0 0 0 ${4 + heat * 6}px rgba(99,102,241,${ringIntensity}), 0 16px 40px rgba(0,0,0,0.5)`,
|
||||||
boxShadow: shadow,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'center',
|
||||||
position: 'relative',
|
padding: '12px 18px',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 17,
|
||||||
|
color: C.fg,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{box.label}
|
||||||
|
</div>
|
||||||
|
{box.detail && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: C.fgSubtle,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'lowercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{box.detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntraLaneArrows({
|
||||||
|
boxes,
|
||||||
|
cy,
|
||||||
|
localFrame,
|
||||||
|
}: {
|
||||||
|
boxes: LaneBox[];
|
||||||
|
cy: number;
|
||||||
|
localFrame: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id={`arrowLane-${cy}`}
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="8"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 Z" fill={C.accent} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{boxes.slice(0, -1).map((b, i) => {
|
||||||
|
const next = boxes[i + 1]!;
|
||||||
|
// Arrow draws when the FROM box's glow has started and the TO
|
||||||
|
// box is about to glow — visualises the data flow.
|
||||||
|
const x1 = b.cx + BOX_W / 2 + 4;
|
||||||
|
const x2 = next.cx - BOX_W / 2 - 8;
|
||||||
|
const drawStart = b.glowAt + 4;
|
||||||
|
const drawEnd = next.glowAt;
|
||||||
|
const progress = clampLerp(localFrame, drawStart, drawEnd);
|
||||||
|
if (progress < 0.01) return null;
|
||||||
|
const segLen = x2 - x1;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`${b.id}-${next.id}`}
|
||||||
|
x1={x1}
|
||||||
|
y1={cy}
|
||||||
|
x2={x2}
|
||||||
|
y2={cy}
|
||||||
|
stroke={C.accent}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeDasharray={segLen}
|
||||||
|
strokeDashoffset={(1 - progress) * segLen}
|
||||||
|
opacity={0.92}
|
||||||
|
markerEnd={`url(#arrowLane-${cy})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoSharedNodeDivider({ localFrame }: { localFrame: number }) {
|
||||||
|
const reveal = clampLerp(localFrame, 22, 38);
|
||||||
|
const fadeOut = 1 - clampLerp(localFrame, CLOSING_START - 4, CLOSING_START + 8);
|
||||||
|
const opacity = Math.min(reveal, fadeOut);
|
||||||
|
if (opacity < 0.02) return null;
|
||||||
|
|
||||||
|
// Vertical dashed line + chip pinned in the centre between the lanes
|
||||||
|
// at the second column of boxes (x ≈ 560).
|
||||||
|
const dividerX = 560;
|
||||||
|
const dividerTopY = LANE_TOP_Y + BOX_H / 2 + 12;
|
||||||
|
const dividerBottomY = LANE_BOTTOM_Y - BOX_H / 2 - 12;
|
||||||
|
const chipY = (dividerTopY + dividerBottomY) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
x1={dividerX}
|
||||||
|
y1={dividerTopY}
|
||||||
|
x2={dividerX}
|
||||||
|
y2={dividerBottomY}
|
||||||
|
stroke={C.fgSubtle}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 5"
|
||||||
|
opacity={0.55}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: dividerX,
|
||||||
|
top: chipY,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
opacity,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 2.2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgMuted,
|
||||||
|
backgroundColor: C.bg,
|
||||||
|
border: `1px solid ${C.borderStrong}`,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '5px 11px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: `0 4px 14px rgba(0,0,0,0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
no shared node
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerConnectors({ localFrame }: { localFrame: number }) {
|
||||||
|
// Connectors reveal as each lane's final box reaches its glow.
|
||||||
|
const reveal = clampLerp(localFrame, 4 + GLOW_STAGGER * 2 + 4, 4 + GLOW_STAGGER * 3);
|
||||||
|
if (reveal < 0.01) return null;
|
||||||
|
|
||||||
|
const codeBox = TOP_LANE_BOXES[TOP_LANE_BOXES.length - 1]!;
|
||||||
|
const envBox = BOTTOM_LANE_BOXES[BOTTOM_LANE_BOXES.length - 1]!;
|
||||||
|
|
||||||
|
const fromTopX = codeBox.cx + BOX_W / 2 + 4;
|
||||||
|
const fromTopY = LANE_TOP_Y;
|
||||||
|
const fromBotX = envBox.cx + BOX_W / 2 + 4;
|
||||||
|
const fromBotY = LANE_BOTTOM_Y;
|
||||||
|
|
||||||
|
const toX = SERVER_CX - SERVER_W / 2 - 4;
|
||||||
|
const toTopY = SERVER_CY - SERVER_H / 4;
|
||||||
|
const toBotY = SERVER_CY + SERVER_H / 4;
|
||||||
|
|
||||||
|
const lenTop = 460;
|
||||||
|
const lenBot = 460;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowConverge"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="8"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 Z" fill={C.accent} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d={`M ${fromTopX} ${fromTopY} C ${fromTopX + 220} ${fromTopY}, ${toX - 200} ${toTopY}, ${toX} ${toTopY}`}
|
||||||
|
stroke={C.accent}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={lenTop}
|
||||||
|
strokeDashoffset={(1 - reveal) * lenTop}
|
||||||
|
opacity={0.92}
|
||||||
|
markerEnd="url(#arrowConverge)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={`M ${fromBotX} ${fromBotY} C ${fromBotX + 220} ${fromBotY}, ${toX - 200} ${toBotY}, ${toX} ${toBotY}`}
|
||||||
|
stroke={C.accent}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={lenBot}
|
||||||
|
strokeDashoffset={(1 - reveal) * lenBot}
|
||||||
|
opacity={0.92}
|
||||||
|
markerEnd="url(#arrowConverge)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerBox({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
// Server box is VISIBLE from frame 0 (no late entry) but gets its own
|
||||||
|
// glow moment once both lanes have completed their tours.
|
||||||
|
const entryT = softSpring(localFrame, fps, 0, 22);
|
||||||
|
const entryOpacity = clampLerp(localFrame, 0, 14);
|
||||||
|
const scale = interpolate(entryT, [0, 1], [0.9, 1]);
|
||||||
|
|
||||||
|
// Glow ramp at SERVER_GLOW_AT.
|
||||||
|
const glowT = clampLerp(localFrame, SERVER_GLOW_AT, SERVER_GLOW_AT + 6);
|
||||||
|
const glowDown = clampLerp(localFrame, SERVER_GLOW_AT + 12, SERVER_GLOW_AT + 22);
|
||||||
|
const glow = glowT - glowDown;
|
||||||
|
const heatBase = localFrame > SERVER_GLOW_AT + 22 ? 0.55 : 0;
|
||||||
|
const heat = Math.max(glow, heatBase);
|
||||||
|
|
||||||
|
// Live-pulse on the dot after the glow has landed.
|
||||||
|
const pulsePhase = Math.max(0, localFrame - SERVER_GLOW_AT - 4) / 30;
|
||||||
|
const pulse = 0.5 + 0.5 * Math.sin(pulsePhase * Math.PI * 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: SERVER_CX - SERVER_W / 2,
|
||||||
|
top: SERVER_CY - SERVER_H / 2,
|
||||||
|
width: SERVER_W,
|
||||||
|
height: SERVER_H,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
opacity: entryOpacity,
|
||||||
|
backgroundColor: C.bgElevated,
|
||||||
|
border: `1.5px solid ${C.accent}`,
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: `0 0 0 ${5 + heat * 6}px rgba(99,102,241,${0.12 + heat * 0.30}), 0 24px 70px rgba(0,0,0,0.6)`,
|
||||||
|
padding: '22px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
color: C.fg,
|
color: C.fg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.name}
|
your server
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.success,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 7,
|
width: 7,
|
||||||
height: 7,
|
height: 7,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: isHero ? C.success : C.fgSubtle,
|
backgroundColor: C.success,
|
||||||
opacity: isHero ? 1 : 0.5,
|
boxShadow: `0 0 ${6 + pulse * 10}px ${C.success}`,
|
||||||
boxShadow: isHero ? `0 0 8px ${C.success}` : 'none',
|
opacity: 0.55 + 0.45 * pulse,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
live
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, position: 'relative' }}>
|
|
||||||
{Array.from({ length: 3 }).map((_, j) => (
|
|
||||||
<div
|
|
||||||
key={j}
|
|
||||||
style={{
|
|
||||||
height: 7,
|
|
||||||
borderRadius: 3.5,
|
|
||||||
backgroundColor: C.bgSubtle,
|
|
||||||
width: `${[88, 64, 76][j]}%`,
|
|
||||||
border: `1px solid ${C.border}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* NEW SECRET pulse — small chip near the tool bars */}
|
|
||||||
{newSecretPulse > 0.02 && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
|
||||||
right: -4,
|
|
||||||
top: -8,
|
|
||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
fontSize: 9,
|
fontSize: 12,
|
||||||
|
color: C.fgSubtle,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: C.accent,
|
|
||||||
backgroundColor: 'rgba(99,102,241,0.18)',
|
|
||||||
border: `1px solid ${C.accentDim}`,
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '2px 7px',
|
|
||||||
opacity: newSecretPulse,
|
|
||||||
transform: `scale(${0.85 + newSecretPulse * 0.15})`,
|
|
||||||
boxShadow: `0 0 ${10 * newSecretPulse}px ${C.accentGlow}`,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MiniLockDot />
|
receives
|
||||||
new secret
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
gap: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: C.bgSubtle,
|
||||||
|
border: `1px solid ${C.border}`,
|
||||||
|
borderRadius: 8,
|
||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
fontSize: 11,
|
|
||||||
color: C.fgSubtle,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{card.toolCount} tools</span>
|
|
||||||
{isHero && forkCount !== null ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
color: C.accent,
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
color: C.fg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ForkIcon />
|
<span style={{ color: C.fgMuted, fontSize: 11 }}>1.</span>
|
||||||
{forkCount.toString()}
|
<span style={{ color: C.accent, fontSize: 12 }}>‹›</span>
|
||||||
</span>
|
<span>code</span>
|
||||||
) : (
|
</div>
|
||||||
<span>{isHero ? '★ featured' : 'template'}</span>
|
<div
|
||||||
)}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'rgba(99,102,241,0.10)',
|
||||||
|
border: `1px solid ${C.accentDim}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: C.fg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: C.fgMuted, fontSize: 11 }}>2.</span>
|
||||||
|
<SmallLockGlyph />
|
||||||
|
<span>env</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniLockDot() {
|
function SmallLockGlyph() {
|
||||||
return (
|
|
||||||
<svg width="8" height="8" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path d="M 5 8 L 5 6 A 3 3 0 0 1 11 6 L 11 8" stroke={C.accent} strokeWidth={1.6} fill="none" />
|
|
||||||
<rect x="3.5" y="7.5" width="9" height="7" rx="1.5" fill={C.accent} stroke={C.accent} strokeWidth={1.4} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ForkIcon() {
|
|
||||||
return (
|
return (
|
||||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
|
||||||
<circle cx="4" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" />
|
<path d="M 5 8 L 5 6 A 3 3 0 0 1 11 6 L 11 8" stroke={C.accent} strokeWidth={1.4} fill="none" />
|
||||||
<circle cx="12" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" />
|
<rect x="3.5" y="7.5" width="9" height="7" rx="1.5" fill="rgba(99,102,241,0.20)" stroke={C.accent} strokeWidth={1.4} />
|
||||||
<circle cx="8" cy="13" r="1.6" stroke={C.accent} strokeWidth="1.4" />
|
<circle cx="8" cy="11" r="1" fill={C.accent} />
|
||||||
<path d="M 4 4.6 L 4 7 Q 4 8 5 8 L 11 8 Q 12 8 12 7 L 12 4.6" stroke={C.accent} strokeWidth="1.4" fill="none" />
|
|
||||||
<path d="M 8 8 L 8 11.4" stroke={C.accent} strokeWidth="1.4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ForkParticles({
|
function ClosingLine({ localFrame }: { localFrame: number }) {
|
||||||
localFrame,
|
const reveal = clampLerp(localFrame, CLOSING_START, CLOSING_START + 16);
|
||||||
x,
|
if (reveal < 0.01) return null;
|
||||||
y,
|
// Slides DOWN from above (translateY = -180 → 0) with spring-like
|
||||||
}: {
|
// ease, lands centred at canvas vertical centre. No overlap with the
|
||||||
localFrame: number;
|
// (now-dimmed) diagram below.
|
||||||
x: number;
|
const slideY = interpolate(reveal, [0, 1], [-180, 0]);
|
||||||
y: number;
|
const scale = interpolate(reveal, [0, 1], [0.94, 1]);
|
||||||
}) {
|
return (
|
||||||
const PARTICLES = 12;
|
|
||||||
const EMIT_START = 6;
|
|
||||||
const EMIT_INTERVAL = 3;
|
|
||||||
const PARTICLE_LIFE = 16;
|
|
||||||
|
|
||||||
const out = [];
|
|
||||||
for (let i = 0; i < PARTICLES; i++) {
|
|
||||||
const birth = EMIT_START + i * EMIT_INTERVAL;
|
|
||||||
const age = localFrame - birth;
|
|
||||||
if (age < 0 || age > PARTICLE_LIFE) continue;
|
|
||||||
const t = age / PARTICLE_LIFE;
|
|
||||||
const opacity = (1 - t) * 0.9;
|
|
||||||
const r = rand(i * 31);
|
|
||||||
const dx = (r - 0.5) * 50;
|
|
||||||
const dy = -36 * t - 6;
|
|
||||||
const size = 4 + r * 3;
|
|
||||||
out.push(
|
|
||||||
<div
|
<div
|
||||||
key={i}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: x + dx,
|
inset: 0,
|
||||||
top: y + dy,
|
display: 'flex',
|
||||||
width: size,
|
alignItems: 'center',
|
||||||
height: size,
|
justifyContent: 'center',
|
||||||
borderRadius: size / 2,
|
opacity: reveal,
|
||||||
backgroundColor: C.accent,
|
pointerEvents: 'none',
|
||||||
opacity,
|
|
||||||
boxShadow: `0 0 8px ${C.accentGlow}`,
|
|
||||||
}}
|
}}
|
||||||
/>,
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(10,10,11,0.92)',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
border: `1px solid ${C.borderStrong}`,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '30px 60px',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 38,
|
||||||
|
color: C.fg,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
transform: `translateY(${slideY}px) scale(${scale})`,
|
||||||
|
boxShadow: `0 0 0 6px ${C.accentGlow}, 0 30px 80px rgba(0,0,0,0.65)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
One sentence in. <span style={{ color: C.accent }}>Live server out.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <>{out}</>;
|
|
||||||
}
|
|
||||||
|
|||||||
732
remotion/src/scenes/HookScene.tsx
Normal file
732
remotion/src/scenes/HookScene.tsx
Normal file
@ -0,0 +1,732 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { interpolate } from 'remotion';
|
||||||
|
import { C } from '../lib/colors';
|
||||||
|
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
||||||
|
|
||||||
|
// FloatingChaos overlay — see definition at the bottom of this file. Adds
|
||||||
|
// real plumbing-pain to the tangle phase: code-snippet cards, an env file
|
||||||
|
// with a leaked-secret squiggle, and a 502 retry toast. Without it the
|
||||||
|
// "tangle" reads as four icons drifting; with it the tangle reads as a
|
||||||
|
// developer's actual desktop right before they give up. Fades out during
|
||||||
|
// the resolve phase so the clean row at the end isn't competing for eye.
|
||||||
|
|
||||||
|
// Phase 1 (frames 0–360 global → localFrame 0..360): the "problem" scene.
|
||||||
|
//
|
||||||
|
// Four floating mini-icons (hosting / OAuth / Docker / secrets) drift into the
|
||||||
|
// frame from corners and slowly tangle together with dotted lines forming
|
||||||
|
// a mess. A small "the plumbing" label fades in. In the last ~1.5s the mess
|
||||||
|
// RESOLVES: icons snap into a clean horizontal row and a fifth icon (the
|
||||||
|
// "prompt cursor") slides in from the right — foreshadowing PromptScene.
|
||||||
|
//
|
||||||
|
// Typography monospace, indigo + fg-muted. No flashy gradients.
|
||||||
|
|
||||||
|
interface Icon {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
start: { x: number; y: number };
|
||||||
|
tangle: { x: number; y: number };
|
||||||
|
resolved: { x: number; y: number };
|
||||||
|
draw: (color: string) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved row centered at y=540, evenly spaced.
|
||||||
|
const ROW_Y = 540;
|
||||||
|
const ROW_CENTER_X = 1920 / 2;
|
||||||
|
const RESOLVED_SPACING = 230;
|
||||||
|
const RESOLVED_OFFSETS = [-1.5, -0.5, 0.5, 1.5];
|
||||||
|
|
||||||
|
const ICONS: Icon[] = [
|
||||||
|
{
|
||||||
|
id: 'hosting',
|
||||||
|
label: 'hosting',
|
||||||
|
start: { x: 360, y: 220 },
|
||||||
|
tangle: { x: 760, y: 420 },
|
||||||
|
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[0] * RESOLVED_SPACING, y: ROW_Y },
|
||||||
|
draw: (color) => <ServerStackIcon color={color} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oauth',
|
||||||
|
label: 'OAuth',
|
||||||
|
start: { x: 1560, y: 240 },
|
||||||
|
tangle: { x: 1100, y: 460 },
|
||||||
|
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[1] * RESOLVED_SPACING, y: ROW_Y },
|
||||||
|
draw: (color) => <KeyIcon color={color} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
label: 'containers',
|
||||||
|
start: { x: 380, y: 820 },
|
||||||
|
tangle: { x: 840, y: 620 },
|
||||||
|
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[2] * RESOLVED_SPACING, y: ROW_Y },
|
||||||
|
draw: (color) => <ContainerIcon color={color} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secrets',
|
||||||
|
label: 'secrets',
|
||||||
|
start: { x: 1540, y: 820 },
|
||||||
|
tangle: { x: 1080, y: 660 },
|
||||||
|
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[3] * RESOLVED_SPACING, y: ROW_Y },
|
||||||
|
draw: (color) => <LockBigIcon color={color} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ICON_SIZE = 96;
|
||||||
|
|
||||||
|
// Resolve transition timings (in local frames):
|
||||||
|
const RESOLVE_START = 300; // 10s
|
||||||
|
const RESOLVE_END = 345; // 11.5s — clean row landed
|
||||||
|
const HANDOFF_START = 330; // prompt-cursor icon slides in from the right
|
||||||
|
const HANDOFF_END = 358;
|
||||||
|
|
||||||
|
export function HookScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
// Icons enter staggered over the first ~36 frames.
|
||||||
|
const iconEntries = ICONS.map((_, i) => springIn(localFrame, fps, 6 + i * 8));
|
||||||
|
|
||||||
|
// Tangle phase: drift from start position toward tangle position between
|
||||||
|
// localFrame 60 and 240.
|
||||||
|
const tangleProgress = clampLerp(localFrame, 60, 240);
|
||||||
|
|
||||||
|
// Resolve phase: from tangled centroid to clean row.
|
||||||
|
const resolveProgress = clampLerp(localFrame, RESOLVE_START, RESOLVE_END);
|
||||||
|
|
||||||
|
// Sub-label "the plumbing nobody talks about" fades in at ~30, fades
|
||||||
|
// out at the resolve.
|
||||||
|
const labelIn = clampLerp(localFrame, 30, 60);
|
||||||
|
const labelOut = 1 - clampLerp(localFrame, RESOLVE_START - 10, RESOLVE_START + 14);
|
||||||
|
const labelOpacity = Math.min(labelIn, labelOut);
|
||||||
|
|
||||||
|
// Resolved-row sub-label: monospace, fades in as row settles.
|
||||||
|
const rowLabelIn = clampLerp(localFrame, RESOLVE_END - 6, RESOLVE_END + 14);
|
||||||
|
|
||||||
|
// Handoff cursor icon — slides in from the right edge.
|
||||||
|
const handoffProgress = clampLerp(localFrame, HANDOFF_START, HANDOFF_END);
|
||||||
|
const handoffX = interpolate(handoffProgress, [0, 1], [1920 + 60, ROW_CENTER_X + 2.5 * RESOLVED_SPACING]);
|
||||||
|
|
||||||
|
// Tangle SVG opacity: full while tangled, fades out as resolve happens.
|
||||||
|
const tangleOpacity = Math.min(
|
||||||
|
clampLerp(localFrame, 30, 90),
|
||||||
|
1 - clampLerp(localFrame, RESOLVE_START - 6, RESOLVE_START + 18),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
{/* Top label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 140,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 18,
|
||||||
|
letterSpacing: 6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
opacity: labelOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
the plumbing nobody talks about
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resolved-row label, appears when icons land in clean row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: ROW_Y - 110,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 16,
|
||||||
|
letterSpacing: 5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
opacity: rowLabelIn,
|
||||||
|
transform: `translateY(${interpolate(rowLabelIn, [0, 1], [6, 0])}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
what if one prompt handled all of it
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating chaos — code-snippet cards + 502 toast + env warning.
|
||||||
|
Sits behind the icons, behind the tangle lines. Their entrance
|
||||||
|
is staggered across frames 80–200 so the tangle grows in
|
||||||
|
visible complexity rather than just sitting at peak. */}
|
||||||
|
<FloatingChaos localFrame={localFrame} />
|
||||||
|
|
||||||
|
{/* Tangle SVG (dotted lines between icons) */}
|
||||||
|
<TangleLines
|
||||||
|
icons={ICONS}
|
||||||
|
tangleProgress={tangleProgress}
|
||||||
|
opacity={tangleOpacity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Icons themselves */}
|
||||||
|
{ICONS.map((icon, i) => {
|
||||||
|
const entry = iconEntries[i];
|
||||||
|
// Position interpolation: start → tangle → resolved.
|
||||||
|
const sx = icon.start.x;
|
||||||
|
const sy = icon.start.y;
|
||||||
|
const tx = icon.tangle.x;
|
||||||
|
const ty = icon.tangle.y;
|
||||||
|
const rx = icon.resolved.x;
|
||||||
|
const ry = icon.resolved.y;
|
||||||
|
|
||||||
|
// Three-stage lerp.
|
||||||
|
let x: number;
|
||||||
|
let y: number;
|
||||||
|
if (resolveProgress > 0) {
|
||||||
|
x = interpolate(resolveProgress, [0, 1], [tx, rx]);
|
||||||
|
y = interpolate(resolveProgress, [0, 1], [ty, ry]);
|
||||||
|
} else {
|
||||||
|
x = interpolate(tangleProgress, [0, 1], [sx, tx]);
|
||||||
|
y = interpolate(tangleProgress, [0, 1], [sy, ty]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtle floating drift while tangled.
|
||||||
|
const driftPhase = (localFrame + i * 12) / 30;
|
||||||
|
const driftX = tangleProgress > 0.4 && resolveProgress < 0.1
|
||||||
|
? Math.sin(driftPhase * Math.PI) * 8
|
||||||
|
: 0;
|
||||||
|
const driftY = tangleProgress > 0.4 && resolveProgress < 0.1
|
||||||
|
? Math.cos(driftPhase * Math.PI * 0.8) * 8
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const scale = interpolate(entry, [0, 1], [0.6, 1]);
|
||||||
|
const opacity = entry;
|
||||||
|
|
||||||
|
// Icon color: muted during tangle, then accent on resolve.
|
||||||
|
const iconColor =
|
||||||
|
resolveProgress > 0.4 ? C.accent : C.fgMuted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={icon.id}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x + driftX - ICON_SIZE / 2,
|
||||||
|
top: y + driftY - ICON_SIZE / 2,
|
||||||
|
width: ICON_SIZE,
|
||||||
|
height: ICON_SIZE,
|
||||||
|
opacity,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
transition: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconCard color={iconColor}>
|
||||||
|
{icon.draw(iconColor)}
|
||||||
|
</IconCard>
|
||||||
|
{/* Label below */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: ICON_SIZE + 8,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: resolveProgress > 0.5 ? C.fgMuted : C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Hand-off cursor icon — slides in from the right at the end */}
|
||||||
|
{handoffProgress > 0.02 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: handoffX - ICON_SIZE / 2,
|
||||||
|
top: ROW_Y - ICON_SIZE / 2,
|
||||||
|
width: ICON_SIZE,
|
||||||
|
height: ICON_SIZE,
|
||||||
|
opacity: handoffProgress,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconCard color={C.accent} glow>
|
||||||
|
<PromptCursorIcon color={C.accent} />
|
||||||
|
</IconCard>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: ICON_SIZE + 8,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
one prompt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconCard({ children, color, glow }: { children: React.ReactNode; color: string; glow?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: C.bgElevated,
|
||||||
|
border: `1.5px solid ${color === C.accent ? C.accent : C.borderStrong}`,
|
||||||
|
boxShadow: glow
|
||||||
|
? `0 0 0 4px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.55)`
|
||||||
|
: `0 10px 30px rgba(0,0,0,0.5)`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TangleLines({
|
||||||
|
icons,
|
||||||
|
tangleProgress,
|
||||||
|
opacity,
|
||||||
|
}: {
|
||||||
|
icons: Icon[];
|
||||||
|
tangleProgress: number;
|
||||||
|
opacity: number;
|
||||||
|
}) {
|
||||||
|
if (opacity < 0.01) return null;
|
||||||
|
// Draw dotted lines between every pair of icons at their tangle positions.
|
||||||
|
// Reveal via strokeDashoffset as tangleProgress grows.
|
||||||
|
const pairs: Array<[Icon, Icon]> = [];
|
||||||
|
for (let i = 0; i < icons.length; i++) {
|
||||||
|
for (let j = i + 1; j < icons.length; j++) {
|
||||||
|
pairs.push([icons[i], icons[j]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}
|
||||||
|
>
|
||||||
|
{pairs.map(([a, b], i) => {
|
||||||
|
// Interpolate endpoints between start and tangle positions.
|
||||||
|
const ax = interpolate(tangleProgress, [0, 1], [a.start.x, a.tangle.x]);
|
||||||
|
const ay = interpolate(tangleProgress, [0, 1], [a.start.y, a.tangle.y]);
|
||||||
|
const bx = interpolate(tangleProgress, [0, 1], [b.start.x, b.tangle.x]);
|
||||||
|
const by = interpolate(tangleProgress, [0, 1], [b.start.y, b.tangle.y]);
|
||||||
|
const len = Math.hypot(bx - ax, by - ay);
|
||||||
|
const reveal = clampLerp(tangleProgress, 0.2 + i * 0.05, 0.6 + i * 0.05);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={ax}
|
||||||
|
y1={ay}
|
||||||
|
x2={bx}
|
||||||
|
y2={by}
|
||||||
|
stroke={C.fgSubtle}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 7"
|
||||||
|
strokeDashoffset={(1 - reveal) * len}
|
||||||
|
opacity={0.55}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Icons ----
|
||||||
|
|
||||||
|
function ServerStackIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<rect x="8" y="10" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<rect x="8" y="22" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<rect x="8" y="34" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<circle cx="12" cy="14.5" r="1.2" fill={color} />
|
||||||
|
<circle cx="12" cy="26.5" r="1.2" fill={color} />
|
||||||
|
<circle cx="12" cy="38.5" r="1.2" fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<circle cx="17" cy="24" r="9" stroke={color} strokeWidth={2.2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<circle cx="17" cy="24" r="3" fill={color} />
|
||||||
|
<path d="M 26 24 L 42 24 M 38 24 L 38 31 M 34 24 L 34 29" stroke={color} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<rect x="6" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<rect x="17" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<rect x="28" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<rect x="17" y="11" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
|
||||||
|
<path d="M 4 36 Q 24 42 44 36" stroke={color} strokeWidth={2} fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LockBigIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<path d="M 15 22 L 15 15 A 9 9 0 0 1 33 15 L 33 22" stroke={color} strokeWidth={2.2} fill="none" />
|
||||||
|
<rect x="11" y="22" width="26" height="18" rx="3" stroke={color} strokeWidth={2.2} fill="rgba(99,102,241,0.10)" />
|
||||||
|
<circle cx="24" cy="30" r="2.2" fill={color} />
|
||||||
|
<rect x="23" y="31" width="2" height="5" fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptCursorIcon({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<rect x="8" y="18" width="32" height="14" rx="3" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.10)" />
|
||||||
|
<text x="13" y="29" fill={color} fontSize="11" fontFamily="ui-monospace, SF Mono, Menlo, monospace" letterSpacing="1">›</text>
|
||||||
|
<rect x="20" y="22" width="2" height="6" fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== FloatingChaos ====================================
|
||||||
|
// Renders four small "fragment" cards during the tangle phase. Each one
|
||||||
|
// makes the chaos concrete instead of abstract: real config snippets,
|
||||||
|
// a real-looking error toast, a real env file with a security squiggle.
|
||||||
|
// Together they sell the "tagelange Bastelei" reality the rest of the
|
||||||
|
// scene only gestures at.
|
||||||
|
//
|
||||||
|
// Timing:
|
||||||
|
// Each card has a personal `appearAt` frame, spring-entrances with a
|
||||||
|
// slight rotation. During the hold (frames 240–270) they jitter ±2px
|
||||||
|
// so the layout doesn't feel frozen. From frame 280 they collapse —
|
||||||
|
// scale to 0.85, translate inward toward the icon row, fade out, so
|
||||||
|
// the resolve reads as "the chaos folds into the clean answer."
|
||||||
|
//
|
||||||
|
// Layout: four corners of the canvas, each card sized < 380px so they
|
||||||
|
// frame the centre tangle without crowding the icons.
|
||||||
|
|
||||||
|
interface ChaosCard {
|
||||||
|
id: string;
|
||||||
|
appearAt: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotate: number; // degrees
|
||||||
|
render: () => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAOS_HOLD_START = 240;
|
||||||
|
const CHAOS_RESOLVE_START = 280;
|
||||||
|
const CHAOS_RESOLVE_END = 315;
|
||||||
|
|
||||||
|
function FloatingChaos({ localFrame }: { localFrame: number }) {
|
||||||
|
// Collapse alpha + collapse-toward-centre during resolve.
|
||||||
|
const collapse = clampLerp(localFrame, CHAOS_RESOLVE_START, CHAOS_RESOLVE_END);
|
||||||
|
if (collapse >= 0.99) return null;
|
||||||
|
|
||||||
|
const cards: ChaosCard[] = [
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
appearAt: 70,
|
||||||
|
x: 130,
|
||||||
|
y: 360,
|
||||||
|
rotate: -3.5,
|
||||||
|
render: () => <ConfigCard />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oauth',
|
||||||
|
appearAt: 100,
|
||||||
|
x: 1450,
|
||||||
|
y: 220,
|
||||||
|
rotate: 2.4,
|
||||||
|
render: () => <OAuthCodeCard />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'env',
|
||||||
|
appearAt: 135,
|
||||||
|
x: 1330,
|
||||||
|
y: 740,
|
||||||
|
rotate: -2.6,
|
||||||
|
render: () => <EnvLeakCard />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'error',
|
||||||
|
appearAt: 175,
|
||||||
|
x: 170,
|
||||||
|
y: 750,
|
||||||
|
rotate: 3.2,
|
||||||
|
render: () => <ErrorToastCard localFrame={localFrame} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity: 1 - collapse,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cards.map((card) => {
|
||||||
|
// Entrance: scale + opacity ramp over 14 frames.
|
||||||
|
const entrance = clampLerp(localFrame, card.appearAt, card.appearAt + 14);
|
||||||
|
if (entrance < 0.01) return null;
|
||||||
|
const entryScale = interpolate(entrance, [0, 1], [0.85, 1]);
|
||||||
|
const entryY = interpolate(entrance, [0, 1], [14, 0]);
|
||||||
|
|
||||||
|
// Hold jitter — keeps the cards alive during the centre tangle
|
||||||
|
// hold without making them feel busy. Different phase per card.
|
||||||
|
const jitterPhase = (localFrame + card.appearAt * 1.7) / 22;
|
||||||
|
const jitterX = Math.sin(jitterPhase * Math.PI) * 1.6;
|
||||||
|
const jitterY = Math.cos(jitterPhase * Math.PI * 0.7) * 1.4;
|
||||||
|
|
||||||
|
// Resolve: cards collapse inward toward canvas centre (960, 540)
|
||||||
|
// and scale down. By the time the icons settle into their row,
|
||||||
|
// the chaos has folded away.
|
||||||
|
const collapseScale = interpolate(collapse, [0, 1], [1, 0.7]);
|
||||||
|
const collapseDx = interpolate(collapse, [0, 1], [0, (960 - card.x) * 0.35]);
|
||||||
|
const collapseDy = interpolate(collapse, [0, 1], [0, (540 - card.y) * 0.35]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: card.x,
|
||||||
|
top: card.y,
|
||||||
|
opacity: entrance * (1 - collapse * 0.4),
|
||||||
|
transform: `translate(${jitterX + collapseDx}px, ${jitterY + collapseDy + entryY}px) rotate(${card.rotate}deg) scale(${entryScale * collapseScale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
filter: collapse > 0.4 ? `blur(${collapse * 1.5}px)` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.render()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chaos card variants ----
|
||||||
|
|
||||||
|
function CardFrame({
|
||||||
|
children,
|
||||||
|
width,
|
||||||
|
label,
|
||||||
|
intent = 'neutral',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
width: number;
|
||||||
|
label?: string;
|
||||||
|
intent?: 'neutral' | 'warn' | 'error';
|
||||||
|
}) {
|
||||||
|
const borderColor =
|
||||||
|
intent === 'error' ? '#dc2626' : intent === 'warn' ? '#f59e0b' : C.borderStrong;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
backgroundColor: C.bgElevated,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
boxShadow: '0 16px 36px rgba(0,0,0,0.55)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderBottom: `1px solid ${C.border}`,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: intent === 'error' ? '#dc2626' : intent === 'warn' ? '#f59e0b' : C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: '12px 14px' }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigCard() {
|
||||||
|
return (
|
||||||
|
<CardFrame width={340} label="docker-compose.yml">
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: C.fgMuted,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports: ["443:443"]
|
||||||
|
volumes:
|
||||||
|
- ./certs:/etc/ssl
|
||||||
|
- ./conf.d:/etc/nginx`}
|
||||||
|
</pre>
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthCodeCard() {
|
||||||
|
return (
|
||||||
|
<CardFrame width={360} label="oauth_callback.ts">
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: C.fgMuted,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`app.use(passport.authenticate(
|
||||||
|
'oauth2', {
|
||||||
|
clientID: process.env.CID,
|
||||||
|
clientSecret: process.env.CS,
|
||||||
|
callbackURL: '/cb',
|
||||||
|
}
|
||||||
|
));`}
|
||||||
|
</pre>
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnvLeakCard() {
|
||||||
|
// The squiggle hints at "this got committed to git by accident" —
|
||||||
|
// a recognisable plumbing failure mode.
|
||||||
|
return (
|
||||||
|
<CardFrame width={320} label="// .env" intent="warn">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
color: C.fgMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>STRIPE_KEY=sk_live_•••</div>
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
NOTION_API_KEY=secret_3a8f
|
||||||
|
<svg
|
||||||
|
width="170"
|
||||||
|
height="6"
|
||||||
|
viewBox="0 0 170 6"
|
||||||
|
style={{ position: 'absolute', left: 0, bottom: -3, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M 0 3 Q 4 0 8 3 T 16 3 T 24 3 T 32 3 T 40 3 T 48 3 T 56 3 T 64 3 T 72 3 T 80 3 T 88 3 T 96 3 T 104 3 T 112 3 T 120 3 T 128 3 T 136 3 T 144 3 T 152 3 T 160 3 T 168 3"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#f59e0b', fontSize: 10.5, marginTop: 8, letterSpacing: 0.4 }}>
|
||||||
|
⚠ in git history since v0.3.1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorToastCard({ localFrame }: { localFrame: number }) {
|
||||||
|
// The retry counter ticks while the card is visible — keeps the
|
||||||
|
// "still broken" feeling alive across the hold without changing the
|
||||||
|
// overall composition.
|
||||||
|
const elapsedSecs = Math.floor((localFrame - 175) / 30);
|
||||||
|
const retries = Math.max(1, Math.min(8, Math.floor(elapsedSecs / 1) + 1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardFrame width={320} intent="error">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
backgroundColor: 'rgba(220,38,38,0.16)',
|
||||||
|
border: '1.5px solid #dc2626',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: '#dc2626',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
502 Bad Gateway
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: C.fgMuted,
|
||||||
|
marginTop: 4,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
upstream timed out · retrying ({retries}/8)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
654
remotion/src/scenes/IsolationScene.tsx
Normal file
654
remotion/src/scenes/IsolationScene.tsx
Normal file
@ -0,0 +1,654 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { interpolate } from 'remotion';
|
||||||
|
import { C } from '../lib/colors';
|
||||||
|
import { softSpring, clampLerp } from '../lib/easings';
|
||||||
|
|
||||||
|
// Phase 5 · 14 s · 426 frames.
|
||||||
|
//
|
||||||
|
// Re-laid out from the original orbit-and-dock design after the owner
|
||||||
|
// flagged two collisions and the PKCE jargon:
|
||||||
|
//
|
||||||
|
// - "0.5 CPU" used to sit at angle π − 0.5 with distance 200, which
|
||||||
|
// drops the bubble right onto the bottom-left corner of the
|
||||||
|
// notion-search card. Fixed by moving chips to a clean vertical
|
||||||
|
// column at x = 720, well to the right of the card.
|
||||||
|
// - "no-new-privileges" docked at angle π/2 + 0.7 (lower-left) which
|
||||||
|
// overlapped the green "your tokens only" badge below the card.
|
||||||
|
// Fixed by merging tokens-only into the same chip column as the
|
||||||
|
// 6th green item — one ordered list of guarantees.
|
||||||
|
// - "OAuth 2.1 · PKCE" was unreadable to anyone who doesn't write
|
||||||
|
// auth code. Replaced with a three-stage flow labelled in plain
|
||||||
|
// language: your client → verified → scoped token reaches the
|
||||||
|
// server. The technical name stays as a small subscript so it's
|
||||||
|
// google-able, but it isn't the headline.
|
||||||
|
// - The vault wasn't visible in this scene at all — secrets just
|
||||||
|
// appeared as a pill on the card in BuildScene and were never
|
||||||
|
// shown again. Added a vault graphic below the card with a
|
||||||
|
// dashed arrow up into its env slot, so the runtime-injection
|
||||||
|
// story is complete in one frame.
|
||||||
|
|
||||||
|
interface ChipDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Local frame when this chip enters. */
|
||||||
|
enterAt: number;
|
||||||
|
/** Final colour: indigo accent for hardening, green for tokens-only. */
|
||||||
|
tone: 'accent' | 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-language labels, no docker flag names except where they're
|
||||||
|
// unambiguous ("read-only filesystem" is clearer than `--read-only`).
|
||||||
|
const CHIPS: ChipDef[] = [
|
||||||
|
{ id: 'ro', label: 'read-only filesystem', enterAt: 40, tone: 'accent' },
|
||||||
|
{ id: 'cap', label: 'dropped capabilities', enterAt: 62, tone: 'accent' },
|
||||||
|
{ id: 'nnp', label: 'no new privileges', enterAt: 84, tone: 'accent' },
|
||||||
|
{ id: 'mem', label: '512 MB memory cap', enterAt: 106, tone: 'accent' },
|
||||||
|
{ id: 'cpu', label: '0.5 CPU limit', enterAt: 128, tone: 'accent' },
|
||||||
|
{ id: 'token', label: 'your token only', enterAt: 360, tone: 'success' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Layout anchors — generous gaps so nothing collides.
|
||||||
|
const CARD_X = 320; // card centre
|
||||||
|
const CARD_Y = 460; // card centre (lifted up so vault has room)
|
||||||
|
const CARD_W = 360;
|
||||||
|
const CARD_H = 200;
|
||||||
|
|
||||||
|
const VAULT_X = 320;
|
||||||
|
const VAULT_Y = 740; // vault centre, below the card with a 60 px gap
|
||||||
|
const VAULT_W = 340;
|
||||||
|
const VAULT_H = 130;
|
||||||
|
|
||||||
|
const CHIP_COL_X = 760; // right of the card
|
||||||
|
const CHIP_COL_TOP = 350;
|
||||||
|
const CHIP_SPACING = 64;
|
||||||
|
|
||||||
|
const OAUTH_HEADER_Y = 280;
|
||||||
|
const OAUTH_FLOW_Y = 700;
|
||||||
|
|
||||||
|
// Three-stage simplified auth flow — no PKCE / verifier / SHA256 in the
|
||||||
|
// on-screen labels. The full technical name appears once, small, under
|
||||||
|
// the section header.
|
||||||
|
const OAUTH_STAGES = [
|
||||||
|
{ t: 0, label: 'your client', x: 1180, y: OAUTH_FLOW_Y },
|
||||||
|
{ t: 14, label: 'verified', x: 1440, y: OAUTH_FLOW_Y },
|
||||||
|
{ t: 28, label: 'scoped token', x: 1700, y: OAUTH_FLOW_Y },
|
||||||
|
];
|
||||||
|
const OAUTH_START = 200;
|
||||||
|
|
||||||
|
const GLOW_START = 340; // server card flashes green here
|
||||||
|
const TOKEN_CHIP_ENTER = 360; // token chip joins the column at this frame
|
||||||
|
const SCENE_LEN = 426;
|
||||||
|
|
||||||
|
export function IsolationScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
const sceneOpacity = clampLerp(localFrame, 0, 12);
|
||||||
|
|
||||||
|
// Green pulse on the server card — the moment the chain locks in.
|
||||||
|
const glowT = clampLerp(localFrame, GLOW_START, GLOW_START + 30);
|
||||||
|
const glowDown = clampLerp(localFrame, GLOW_START + 30, GLOW_START + 60);
|
||||||
|
const greenPulse = glowT - glowDown;
|
||||||
|
|
||||||
|
// Exit lift in the last few frames (parent crossfades).
|
||||||
|
const exitProgress = clampLerp(localFrame, SCENE_LEN - 12, SCENE_LEN);
|
||||||
|
const exitY = interpolate(exitProgress, [0, 1], [0, -18]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, opacity: sceneOpacity, transform: `translateY(${exitY}px)` }}>
|
||||||
|
{/* Section caption */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 130,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 16,
|
||||||
|
letterSpacing: 5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
hardened by default
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server card (left) */}
|
||||||
|
<ServerCard
|
||||||
|
cx={CARD_X}
|
||||||
|
cy={CARD_Y}
|
||||||
|
w={CARD_W}
|
||||||
|
h={CARD_H}
|
||||||
|
localFrame={localFrame}
|
||||||
|
greenPulse={greenPulse}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vault graphic (below card) — completes the architecture: the
|
||||||
|
secret in the vault is the one runtime-injected into the
|
||||||
|
card above it. Without this the scene only shows "server is
|
||||||
|
safe" and leaves the viewer wondering where the secret lives. */}
|
||||||
|
<Vault
|
||||||
|
cx={VAULT_X}
|
||||||
|
cy={VAULT_Y}
|
||||||
|
w={VAULT_W}
|
||||||
|
h={VAULT_H}
|
||||||
|
localFrame={localFrame}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hardening chips column (right of card) — one ordered list. The
|
||||||
|
first five enter staggered while the OAuth flow plays on the
|
||||||
|
far right; the final green "your token only" chip lands last
|
||||||
|
to close the scene. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: CHIP_COL_X,
|
||||||
|
top: CHIP_COL_TOP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CHIPS.map((chip, i) => (
|
||||||
|
<HardeningChip
|
||||||
|
key={chip.id}
|
||||||
|
chip={chip}
|
||||||
|
index={i}
|
||||||
|
localFrame={localFrame}
|
||||||
|
fps={fps}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth flow (far right) — three stages, plain language. The PKCE
|
||||||
|
term sits small under the header so it's findable but never
|
||||||
|
the headline. */}
|
||||||
|
<OAuthFlow localFrame={localFrame} fps={fps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ServerCard ----------
|
||||||
|
|
||||||
|
function ServerCard({
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
localFrame,
|
||||||
|
greenPulse,
|
||||||
|
}: {
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
localFrame: number;
|
||||||
|
greenPulse: number;
|
||||||
|
}) {
|
||||||
|
const entryT = clampLerp(localFrame, 0, 18);
|
||||||
|
const scale = interpolate(entryT, [0, 1], [0.92, 1]);
|
||||||
|
const borderColor =
|
||||||
|
greenPulse > 0.05 ? interpolateColor(C.accent, C.success, greenPulse) : C.accent;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: cx - w / 2,
|
||||||
|
top: cy - h / 2,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
backgroundColor: C.bgElevated,
|
||||||
|
border: `1.5px solid ${borderColor}`,
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow:
|
||||||
|
greenPulse > 0.05
|
||||||
|
? `0 0 0 ${4 + greenPulse * 4}px rgba(34,197,94,${0.20 + greenPulse * 0.20}), 0 24px 70px rgba(0,0,0,0.6)`
|
||||||
|
: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`,
|
||||||
|
opacity: entryT,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
padding: '22px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontSize: 18, color: C.fg }}>
|
||||||
|
notion-search
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.success,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: C.success,
|
||||||
|
boxShadow: `0 0 ${6 + greenPulse * 12}px ${C.success}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
live
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: C.fgMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row label="image" value="bmm/notion-search:1" />
|
||||||
|
<Row label="runtime" value="node-22-slim" />
|
||||||
|
<Row label="uptime" value="00:00:14" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span style={{ color: C.fg }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Vault ----------
|
||||||
|
|
||||||
|
function Vault({
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
localFrame,
|
||||||
|
}: {
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
localFrame: number;
|
||||||
|
}) {
|
||||||
|
// Vault enters slightly after the card so the eye reads the card first.
|
||||||
|
const entryT = clampLerp(localFrame, 12, 32);
|
||||||
|
const arrowReveal = clampLerp(localFrame, 30, 60);
|
||||||
|
const opacity = entryT;
|
||||||
|
const scale = interpolate(entryT, [0, 1], [0.92, 1]);
|
||||||
|
|
||||||
|
// Dashed arrow from vault top edge up into card bottom edge.
|
||||||
|
const cardBottom = 460 + 100; // CARD_Y + CARD_H/2 = 560
|
||||||
|
const vaultTop = cy - h / 2;
|
||||||
|
const arrowX = cx; // vertically aligned
|
||||||
|
const arrowYStart = vaultTop - 4;
|
||||||
|
const arrowYEnd = cardBottom + 4;
|
||||||
|
const arrowLen = arrowYStart - arrowYEnd;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Connector arrow */}
|
||||||
|
<svg
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
x1={arrowX}
|
||||||
|
y1={arrowYStart}
|
||||||
|
x2={arrowX}
|
||||||
|
y2={arrowYEnd + 8}
|
||||||
|
stroke={C.accent}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
strokeDashoffset={(1 - arrowReveal) * arrowLen}
|
||||||
|
opacity={0.85 * arrowReveal}
|
||||||
|
/>
|
||||||
|
<polygon
|
||||||
|
points={`${arrowX - 5},${arrowYEnd + 8} ${arrowX + 5},${arrowYEnd + 8} ${arrowX},${arrowYEnd}`}
|
||||||
|
fill={C.accent}
|
||||||
|
opacity={Math.max(0, arrowReveal - 0.7) * 3}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: cx - w / 2,
|
||||||
|
top: cy - h / 2,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
backgroundColor: C.bgElevated,
|
||||||
|
border: `1.5px solid ${C.accentDim}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
boxShadow: `0 0 0 4px rgba(79,70,229,0.12), 0 18px 50px rgba(0,0,0,0.55)`,
|
||||||
|
opacity,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
padding: '14px 18px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 2.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<SmallLockIcon />
|
||||||
|
secrets vault
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: C.accent,
|
||||||
|
border: `1px solid ${C.accentDim}`,
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
backgroundColor: 'rgba(99,102,241,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AES-256
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: C.bgSubtle,
|
||||||
|
border: `1px solid ${C.border}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
color: C.fg,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: C.fgSubtle }}>NOTION_API_KEY</span>
|
||||||
|
<span style={{ color: C.fgMuted }}>=</span>
|
||||||
|
<span>secret_•••••••3a8f</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallLockIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M 5 8 L 5 6 A 3 3 0 0 1 11 6 L 11 8" stroke={C.accent} strokeWidth={1.3} fill="none" />
|
||||||
|
<rect x="3.5" y="7.5" width="9" height="7" rx="1.5" fill="rgba(99,102,241,0.20)" stroke={C.accent} strokeWidth={1.3} />
|
||||||
|
<circle cx="8" cy="11" r="1" fill={C.accent} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HardeningChip ----------
|
||||||
|
|
||||||
|
function HardeningChip({
|
||||||
|
chip,
|
||||||
|
index,
|
||||||
|
localFrame,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
chip: ChipDef;
|
||||||
|
index: number;
|
||||||
|
localFrame: number;
|
||||||
|
fps: number;
|
||||||
|
}) {
|
||||||
|
// Spring from off-screen-right (+260 px) to the docked position.
|
||||||
|
const t = softSpring(localFrame, fps, chip.enterAt, 22);
|
||||||
|
if (t < 0.01) return null;
|
||||||
|
const x = interpolate(t, [0, 1], [260, 0]);
|
||||||
|
const opacity = clampLerp(localFrame, chip.enterAt, chip.enterAt + 12);
|
||||||
|
|
||||||
|
const isSuccess = chip.tone === 'success';
|
||||||
|
const colour = isSuccess ? C.success : C.accent;
|
||||||
|
const fill = isSuccess ? 'rgba(34,197,94,0.10)' : 'rgba(99,102,241,0.10)';
|
||||||
|
const glow = isSuccess
|
||||||
|
? '0 0 18px rgba(34,197,94,0.25)'
|
||||||
|
: '0 0 12px rgba(99,102,241,0.18)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: index * CHIP_SPACING,
|
||||||
|
left: 0,
|
||||||
|
transform: `translateX(${x}px)`,
|
||||||
|
opacity,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: colour,
|
||||||
|
backgroundColor: fill,
|
||||||
|
border: `1px solid ${colour}`,
|
||||||
|
borderRadius: 999,
|
||||||
|
boxShadow: glow,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChipBullet tone={chip.tone} />
|
||||||
|
{chip.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipBullet({ tone }: { tone: 'accent' | 'success' }) {
|
||||||
|
const colour = tone === 'success' ? C.success : C.accent;
|
||||||
|
if (tone === 'success') {
|
||||||
|
return (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M 2 7 L 6 11 L 12 3" stroke={colour} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: 3, backgroundColor: colour }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- OAuthFlow ----------
|
||||||
|
|
||||||
|
function OAuthFlow({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
const headerIn = clampLerp(localFrame, OAUTH_START - 20, OAUTH_START);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Plain-language header. The technical name sits small underneath
|
||||||
|
so anyone curious can google it; nobody is forced to know it. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 1100,
|
||||||
|
top: OAUTH_HEADER_Y,
|
||||||
|
width: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: headerIn,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 16,
|
||||||
|
letterSpacing: 5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
only your token gets in
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: C.fgSubtle,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
oauth 2.1 · proof-key flow
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three-stage flow, plain labels */}
|
||||||
|
{OAUTH_STAGES.map((stage, i) => {
|
||||||
|
const start = OAUTH_START + stage.t;
|
||||||
|
const inProg = softSpring(localFrame, fps, start, 18);
|
||||||
|
const op = clampLerp(localFrame, start, start + 12);
|
||||||
|
if (op < 0.01) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: stage.x,
|
||||||
|
top: stage.y,
|
||||||
|
transform: `translate(-50%, -50%) scale(${interpolate(inProg, [0, 1], [0.78, 1])})`,
|
||||||
|
opacity: op,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fg,
|
||||||
|
border: `1px solid ${C.accent}`,
|
||||||
|
backgroundColor: 'rgba(99,102,241,0.10)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '10px 18px',
|
||||||
|
boxShadow: `0 6px 18px rgba(0,0,0,0.5)`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stage.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Arrows between stages */}
|
||||||
|
<OAuthArrows localFrame={localFrame} />
|
||||||
|
|
||||||
|
{/* Final arrow from "scoped token" down-left INTO the server card —
|
||||||
|
completes the loop: this token is what reaches your server. */}
|
||||||
|
<SettlementArrow localFrame={localFrame} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthArrows({ localFrame }: { localFrame: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowOauth" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 Z" fill={C.accent} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{OAUTH_STAGES.slice(0, -1).map((stage, i) => {
|
||||||
|
const next = OAUTH_STAGES[i + 1];
|
||||||
|
const start = OAUTH_START + stage.t + 10;
|
||||||
|
const end = OAUTH_START + next.t;
|
||||||
|
const progress = clampLerp(localFrame, start, end);
|
||||||
|
if (progress < 0.01) return null;
|
||||||
|
const padFrom = 70;
|
||||||
|
const padTo = 70;
|
||||||
|
const x1 = stage.x + padFrom;
|
||||||
|
const y1 = stage.y;
|
||||||
|
const x2 = next.x - padTo;
|
||||||
|
const y2 = next.y;
|
||||||
|
const segLen = Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
stroke={C.accent}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
strokeDasharray={segLen}
|
||||||
|
strokeDashoffset={(1 - progress) * segLen}
|
||||||
|
opacity={0.85}
|
||||||
|
markerEnd="url(#arrowOauth)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettlementArrow({ localFrame }: { localFrame: number }) {
|
||||||
|
// Drawn after the last OAuth stage lands. Curves from the rightmost
|
||||||
|
// "scoped token" pill down and left toward the server card's right
|
||||||
|
// edge, showing the token completing the round-trip into the server.
|
||||||
|
const start = OAUTH_START + OAUTH_STAGES[OAUTH_STAGES.length - 1].t + 10;
|
||||||
|
const reveal = clampLerp(localFrame, start, start + 30);
|
||||||
|
if (reveal < 0.01) return null;
|
||||||
|
const fromX = OAUTH_STAGES[OAUTH_STAGES.length - 1].x;
|
||||||
|
const fromY = OAUTH_STAGES[OAUTH_STAGES.length - 1].y + 26;
|
||||||
|
const toX = CARD_X + CARD_W / 2 + 8;
|
||||||
|
const toY = CARD_Y - 10;
|
||||||
|
// Bezier control points for a graceful S-curve.
|
||||||
|
const cx1 = fromX;
|
||||||
|
const cy1 = fromY + 240;
|
||||||
|
const cx2 = toX + 220;
|
||||||
|
const cy2 = toY + 30;
|
||||||
|
const pathD = `M ${fromX} ${fromY} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${toX} ${toY}`;
|
||||||
|
// Approximate path length for dash offset.
|
||||||
|
const approxLen = 1200;
|
||||||
|
return (
|
||||||
|
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
stroke={C.success}
|
||||||
|
strokeWidth={1.7}
|
||||||
|
strokeDasharray="7 6"
|
||||||
|
fill="none"
|
||||||
|
strokeDashoffset={(1 - reveal) * approxLen}
|
||||||
|
opacity={0.85 * reveal}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
function interpolateColor(a: string, b: string, t: number) {
|
||||||
|
const pa = parseHex(a);
|
||||||
|
const pb = parseHex(b);
|
||||||
|
const r = Math.round(pa.r + (pb.r - pa.r) * t);
|
||||||
|
const g = Math.round(pa.g + (pb.g - pa.g) * t);
|
||||||
|
const bb = Math.round(pa.b + (pb.b - pa.b) * t);
|
||||||
|
return `rgb(${r},${g},${bb})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHex(h: string) {
|
||||||
|
const s = h.replace('#', '');
|
||||||
|
return {
|
||||||
|
r: parseInt(s.slice(0, 2), 16),
|
||||||
|
g: parseInt(s.slice(2, 4), 16),
|
||||||
|
b: parseInt(s.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -13,9 +13,13 @@ import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
|
|||||||
|
|
||||||
const GRID_COLS = 3;
|
const GRID_COLS = 3;
|
||||||
const GRID_ROWS = 2;
|
const GRID_ROWS = 2;
|
||||||
const CARD_W = 340;
|
// Cards enlarged in v10 — the previous 340×180 grid felt cramped on a
|
||||||
const CARD_H = 180;
|
// 1080p canvas. New 400×220 with 36 px gap spreads the library across
|
||||||
const GAP = 28;
|
// more of the available width (now grid spans x 252–1668) so each
|
||||||
|
// template card reads at a glance.
|
||||||
|
const CARD_W = 400;
|
||||||
|
const CARD_H = 220;
|
||||||
|
const GAP = 36;
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
{ name: 'github-issues', toolCount: 3, highlighted: false },
|
{ name: 'github-issues', toolCount: 3, highlighted: false },
|
||||||
@ -31,12 +35,24 @@ const LOCK_DETACH_START = 36; // when hero is settled into the grid
|
|||||||
const LOCK_DETACH_END = 56;
|
const LOCK_DETACH_END = 56;
|
||||||
const SUB_CAPTION_START = 56;
|
const SUB_CAPTION_START = 56;
|
||||||
const SUB_CAPTION_END = 70;
|
const SUB_CAPTION_END = 70;
|
||||||
|
// New: "export source ↓" chip on the hero card. Pulses once around frame 110.
|
||||||
|
const EXPORT_CHIP_START = 95;
|
||||||
|
const EXPORT_CHIP_END = 145;
|
||||||
|
|
||||||
export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
const captionIn = clampLerp(localFrame, 20, 36);
|
const captionIn = clampLerp(localFrame, 20, 36);
|
||||||
const heroProgress = softSpring(localFrame, fps, 0, 30);
|
const heroProgress = softSpring(localFrame, fps, 0, 30);
|
||||||
const lockDetach = clampLerp(localFrame, LOCK_DETACH_START, LOCK_DETACH_END);
|
const lockDetach = clampLerp(localFrame, LOCK_DETACH_START, LOCK_DETACH_END);
|
||||||
const subCaptionIn = clampLerp(localFrame, SUB_CAPTION_START, SUB_CAPTION_END);
|
const subCaptionIn = clampLerp(localFrame, SUB_CAPTION_START, SUB_CAPTION_END);
|
||||||
|
// Export chip: rises in then pulses out.
|
||||||
|
const exportIn = clampLerp(localFrame, EXPORT_CHIP_START, EXPORT_CHIP_START + 10);
|
||||||
|
const exportOut = 1 - clampLerp(localFrame, EXPORT_CHIP_END - 10, EXPORT_CHIP_END);
|
||||||
|
const exportOpacity = Math.min(exportIn, exportOut);
|
||||||
|
// Pulse modulation:
|
||||||
|
const exportPulse =
|
||||||
|
exportOpacity > 0.1
|
||||||
|
? 0.6 + 0.4 * Math.sin(((localFrame - EXPORT_CHIP_START) / 16) * Math.PI * 2)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
|
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
|
||||||
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
|
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
|
||||||
@ -128,25 +144,44 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Sub-caption: "templates carry code, not credentials" */}
|
{/* Export-source chip — floats above the hero card */}
|
||||||
|
{exportOpacity > 0.02 && (() => {
|
||||||
|
// Hero card position (col 1, row 0) — recompute here for clarity
|
||||||
|
const heroX = gridLeft + 1 * (CARD_W + GAP);
|
||||||
|
const heroY = gridTop + 0 * (CARD_H + GAP);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 110,
|
left: heroX + CARD_W / 2,
|
||||||
left: 0,
|
top: heroY - 28,
|
||||||
right: 0,
|
transform: `translate(-50%, -100%) scale(${0.9 + exportPulse * 0.1})`,
|
||||||
textAlign: 'center',
|
opacity: exportOpacity,
|
||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
fontSize: 14,
|
fontSize: 11,
|
||||||
letterSpacing: 3,
|
letterSpacing: 2,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: C.fgSubtle,
|
color: C.accent,
|
||||||
opacity: subCaptionIn,
|
backgroundColor: 'rgba(99,102,241,0.18)',
|
||||||
transform: `translateY(${interpolate(subCaptionIn, [0, 1], [8, 0])}px)`,
|
border: `1px solid ${C.accent}`,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '6px 12px',
|
||||||
|
boxShadow: `0 0 ${10 + exportPulse * 14}px rgba(99,102,241,${0.30 + exportPulse * 0.20})`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
templates carry <span style={{ color: C.fg }}>code</span>, not credentials
|
export source <span style={{ fontSize: 13 }}>↓</span>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Sub-caption was pulled — the voice-over already says the same
|
||||||
|
thing and the on-screen line read as "on-the-nose marketing".
|
||||||
|
The detached lock + empty slot pill on the hero card carries
|
||||||
|
the story visually now. */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
remotion/src/scenes/LogoLockup.tsx
Normal file
51
remotion/src/scenes/LogoLockup.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { interpolate } from 'remotion';
|
||||||
|
import { C } from '../lib/colors';
|
||||||
|
import { clampLerp } from '../lib/easings';
|
||||||
|
|
||||||
|
// Phase 8 (~51 frames, 1.7s): wordmark "BuildMyMCPServer.com" centered with
|
||||||
|
// indigo glow that pulses once. Hold full opacity for ~0.8s, then fade
|
||||||
|
// everything to black over the final 12 frames so the loop is invisible.
|
||||||
|
|
||||||
|
const SCENE_LEN = 51;
|
||||||
|
const FADE_OUT_FRAMES = 12;
|
||||||
|
|
||||||
|
export function LogoLockup({ localFrame, fps: _fps }: { localFrame: number; fps: number }) {
|
||||||
|
const inProgress = clampLerp(localFrame, 0, 10);
|
||||||
|
// Glow pulse: 0 → 1 → 0 over 30 frames.
|
||||||
|
const pulse =
|
||||||
|
clampLerp(localFrame, 4, 18) - clampLerp(localFrame, 18, 32);
|
||||||
|
|
||||||
|
// Internal fade-out across last 12 frames (parent crossfade also applies,
|
||||||
|
// but this guarantees the lockup itself goes dark).
|
||||||
|
const fadeOut = 1 - clampLerp(localFrame, SCENE_LEN - FADE_OUT_FRAMES, SCENE_LEN);
|
||||||
|
|
||||||
|
const scale = interpolate(inProgress, [0, 1], [0.96, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: fadeOut,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 84,
|
||||||
|
color: C.fg,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
textShadow: `0 0 ${20 + pulse * 60}px rgba(99,102,241,${0.4 + pulse * 0.4})`,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
opacity: inProgress,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BuildMyMCPServer
|
||||||
|
<span style={{ color: C.fgMuted, fontSize: 56, marginLeft: 4 }}>.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,35 +1,103 @@
|
|||||||
import { interpolate } from 'remotion';
|
import { interpolate } from 'remotion';
|
||||||
import { C } from '../lib/colors';
|
import { C } from '../lib/colors';
|
||||||
import { springIn, clampLerp } from '../lib/easings';
|
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
||||||
|
|
||||||
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.';
|
// Phase 2 · 366 frames · 12.2s
|
||||||
|
//
|
||||||
|
// Beats:
|
||||||
|
// 0-85 typing (existing — character-by-character at ~20 cps)
|
||||||
|
// 85-105 hint "press Enter to build →" pops in
|
||||||
|
// 105-225 PARSE BEAT (new) — three highlights light up the prompt in
|
||||||
|
// sequence; each gets a chip below the input that names what
|
||||||
|
// was detected. The third chip points right with a → arrow,
|
||||||
|
// visually foreshadowing the vault scene that follows.
|
||||||
|
// 225-310 SUMMARY PANEL (new) — three-column stats row materialises
|
||||||
|
// under the chips: tools · secrets · targets.
|
||||||
|
// 310-354 hold
|
||||||
|
// 354-366 exit drift (existing)
|
||||||
|
//
|
||||||
|
// What this replaces: a six-second post-typing hold where only the caret
|
||||||
|
// blinked. The parse beat surfaces what the AI literally extracts from
|
||||||
|
// the prompt — concrete, supports the VO line "the AI generates the
|
||||||
|
// code — the prompt path and the secret path never cross."
|
||||||
|
|
||||||
// Phase 1: realistic text-input field. Cursor blinks, prompt types in
|
// Segmented text — typing still feels continuous, but each segment can
|
||||||
// character by character (~20 chars/sec at 30fps = 1.5 frames/char), and a
|
// carry an independent highlight when the parse beat reaches it.
|
||||||
// "press Enter to build →" hint lights up once typing completes.
|
type HighlightKey = 'intent' | 'tool' | 'secret';
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
text: string;
|
||||||
|
highlight?: HighlightKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENTS: readonly Segment[] = [
|
||||||
|
{ text: 'Build me an ' },
|
||||||
|
{ text: 'MCP server', highlight: 'intent' },
|
||||||
|
{ text: ' that ' },
|
||||||
|
{ text: 'searches', highlight: 'tool' },
|
||||||
|
{ text: ' our ' },
|
||||||
|
{ text: 'Notion workspace', highlight: 'secret' },
|
||||||
|
{ text: '.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROMPT_TEXT = SEGMENTS.map((s) => s.text).join('');
|
||||||
|
|
||||||
|
// Timing constants (local frames)
|
||||||
|
const TYPE_CPS = 20; // ~1 char every 1.5 frames at 30fps
|
||||||
|
const TYPE_FRAMES_PER_CHAR = 30 / TYPE_CPS;
|
||||||
|
const TYPING_DONE_AT = PROMPT_TEXT.length * TYPE_FRAMES_PER_CHAR; // ~88
|
||||||
|
const HINT_START = TYPING_DONE_AT - 4;
|
||||||
|
const HINT_END = TYPING_DONE_AT + 10;
|
||||||
|
|
||||||
|
// Parse beat — each highlight starts when the previous chip has settled.
|
||||||
|
const PARSE_INTENT_AT = 110;
|
||||||
|
const PARSE_TOOL_AT = 145;
|
||||||
|
const PARSE_SECRET_AT = 180;
|
||||||
|
const PARSE_CHIP_DURATION = 20; // entrance duration
|
||||||
|
const HIGHLIGHT_HOLD = 90; // each highlight stays for this long after entrance
|
||||||
|
|
||||||
|
// Summary panel.
|
||||||
|
const SUMMARY_START = 230;
|
||||||
|
const SUMMARY_FULL = 270;
|
||||||
|
|
||||||
|
// "Never cross" mini-diagram — appears under the summary panel right
|
||||||
|
// at the 21 s mark in the global timeline, which is when the voice
|
||||||
|
// says "the prompt path and the secret path never cross." Visualises
|
||||||
|
// the architectural promise with two parallel arrows and a clear
|
||||||
|
// "no crossing" cross-mark between them.
|
||||||
|
const NEVER_CROSS_START = 250; // local frame 250 ≈ global 21 s
|
||||||
|
const NEVER_CROSS_FULL = 280;
|
||||||
|
|
||||||
|
const SCENE_LEN = 366;
|
||||||
|
const EXIT_START = SCENE_LEN - 12;
|
||||||
|
|
||||||
|
// Map highlight key → its "appeared" timestamp so PromptText can colour
|
||||||
|
// the segment background once the parser hits it.
|
||||||
|
const HIGHLIGHT_AT: Record<HighlightKey, number> = {
|
||||||
|
intent: PARSE_INTENT_AT,
|
||||||
|
tool: PARSE_TOOL_AT,
|
||||||
|
secret: PARSE_SECRET_AT,
|
||||||
|
};
|
||||||
|
|
||||||
export function PromptScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
export function PromptScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
const inputIn = springIn(localFrame, fps, 0);
|
const inputIn = springIn(localFrame, fps, 0);
|
||||||
const labelIn = clampLerp(localFrame, 4, 16);
|
const labelIn = clampLerp(localFrame, 4, 16);
|
||||||
|
|
||||||
// Type ~20 cps: floor(frame / 1.5)
|
const typedChars = Math.max(0, Math.floor(localFrame / TYPE_FRAMES_PER_CHAR));
|
||||||
const typedChars = Math.max(0, Math.floor(localFrame / 1.5));
|
const typingDone = localFrame >= TYPING_DONE_AT;
|
||||||
const typed = PROMPT_TEXT.slice(0, typedChars);
|
|
||||||
const typingDoneAt = PROMPT_TEXT.length * 1.5;
|
|
||||||
const typingDone = localFrame >= typingDoneAt;
|
|
||||||
|
|
||||||
// Hint appears once typing is done.
|
// Caret blinks at 0.5 Hz once typing settles.
|
||||||
const hintIn = clampLerp(localFrame, typingDoneAt - 4, typingDoneAt + 10);
|
|
||||||
|
|
||||||
// Caret blinks at 0.5Hz while idle; stays solid while typing.
|
|
||||||
const caretVisible = !typingDone || Math.floor(localFrame / 15) % 2 === 0;
|
const caretVisible = !typingDone || Math.floor(localFrame / 15) % 2 === 0;
|
||||||
|
|
||||||
// Exit motion: drifts upward slightly in last 12 frames (opacity is the
|
// Hint visibility — pops in once typing settles, then fades a bit
|
||||||
// parent crossfade — we just add a tiny lift so it feels purposeful).
|
// when the parse beat starts (less competition for attention).
|
||||||
const exitProgress = clampLerp(localFrame, 75, 81);
|
const hintIn = clampLerp(localFrame, HINT_START, HINT_END);
|
||||||
const exitY = interpolate(exitProgress, [0, 1], [0, -60]);
|
const hintFade = 1 - clampLerp(localFrame, PARSE_INTENT_AT - 10, PARSE_INTENT_AT + 14) * 0.55;
|
||||||
const exitScale = interpolate(exitProgress, [0, 1], [1, 0.95]);
|
|
||||||
|
|
||||||
|
// Exit drift in the final 12 frames.
|
||||||
|
const exitProgress = clampLerp(localFrame, EXIT_START, SCENE_LEN);
|
||||||
|
const exitY = interpolate(exitProgress, [0, 1], [0, -52]);
|
||||||
|
const exitScale = interpolate(exitProgress, [0, 1], [1, 0.96]);
|
||||||
const enterScale = interpolate(inputIn, [0, 1], [0.92, 1]);
|
const enterScale = interpolate(inputIn, [0, 1], [0.92, 1]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -52,7 +120,7 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
|
|||||||
letterSpacing: 4,
|
letterSpacing: 4,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: C.fgSubtle,
|
color: C.fgSubtle,
|
||||||
marginBottom: 28,
|
marginBottom: 24,
|
||||||
opacity: labelIn,
|
opacity: labelIn,
|
||||||
transform: `translateY(${interpolate(labelIn, [0, 1], [8, 0])}px)`,
|
transform: `translateY(${interpolate(labelIn, [0, 1], [8, 0])}px)`,
|
||||||
}}
|
}}
|
||||||
@ -60,7 +128,7 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
|
|||||||
describe your tool
|
describe your tool
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input field */}
|
{/* Input field — same shape as the actual product UI. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 1100,
|
width: 1100,
|
||||||
@ -95,14 +163,13 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
|
|||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: C.fg,
|
color: C.fg,
|
||||||
whiteSpace: 'pre',
|
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{typed}</span>
|
<PromptText typedChars={typedChars} localFrame={localFrame} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
@ -124,14 +191,474 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
|
|||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: typingDone ? C.fgMuted : C.fgSubtle,
|
color: typingDone ? C.fgMuted : C.fgSubtle,
|
||||||
marginTop: 28,
|
marginTop: 24,
|
||||||
opacity: hintIn,
|
opacity: hintIn * hintFade,
|
||||||
transform: `translateY(${interpolate(hintIn, [0, 1], [-6, 0])}px)`,
|
transform: `translateY(${interpolate(hintIn, [0, 1], [-6, 0])}px)`,
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
press Enter to build →
|
press Enter to build →
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Parse-beat chip row — three callouts appear in sequence, each
|
||||||
|
tied to a highlighted segment above. The third chip points
|
||||||
|
right with → arrow, foreshadowing the vault scene. */}
|
||||||
|
<ChipRow localFrame={localFrame} fps={fps} />
|
||||||
|
|
||||||
|
{/* Summary panel — three-stat row materialises after all three
|
||||||
|
chips have landed. Tells the viewer what the AI extracted. */}
|
||||||
|
<SummaryPanel localFrame={localFrame} fps={fps} />
|
||||||
|
|
||||||
|
{/* "Never cross" mini-architecture diagram — appears at ≈ 21 s
|
||||||
|
global to land precisely with the voice line "the prompt
|
||||||
|
path and the secret path never cross." Two parallel arrows
|
||||||
|
with a clear X-marker chip pinned between them. Compact so
|
||||||
|
it sits under the summary panel without breaking layout. */}
|
||||||
|
<NeverCrossMini localFrame={localFrame} fps={fps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sub-components ----
|
||||||
|
|
||||||
|
function PromptText({
|
||||||
|
typedChars,
|
||||||
|
localFrame,
|
||||||
|
}: {
|
||||||
|
typedChars: number;
|
||||||
|
localFrame: number;
|
||||||
|
}) {
|
||||||
|
// Render typed text segment-by-segment so each segment can carry an
|
||||||
|
// independent highlight state. A running `consumed` counter slices the
|
||||||
|
// single typing progress across segments — feels continuous to the
|
||||||
|
// viewer.
|
||||||
|
let consumed = 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{SEGMENTS.map((seg, i) => {
|
||||||
|
const segStart = consumed;
|
||||||
|
consumed += seg.text.length;
|
||||||
|
const visibleChars = Math.max(0, Math.min(seg.text.length, typedChars - segStart));
|
||||||
|
if (visibleChars <= 0) return null;
|
||||||
|
const visibleText = seg.text.slice(0, visibleChars);
|
||||||
|
|
||||||
|
// Highlight intensity: fades in over 14 frames after its
|
||||||
|
// chip lands, then holds bright.
|
||||||
|
let highlightT = 0;
|
||||||
|
if (seg.highlight) {
|
||||||
|
const startAt = HIGHLIGHT_AT[seg.highlight];
|
||||||
|
highlightT = clampLerp(localFrame, startAt, startAt + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHighlight = highlightT > 0.01;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${i}-${seg.text}`}
|
||||||
|
style={
|
||||||
|
hasHighlight
|
||||||
|
? {
|
||||||
|
background: `linear-gradient(180deg, transparent 55%, rgba(99,102,241,${0.22 * highlightT}) 55%)`,
|
||||||
|
color: C.fg,
|
||||||
|
padding: '0 2px',
|
||||||
|
borderRadius: 2,
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
}
|
||||||
|
: { whiteSpace: 'pre' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChipDef {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
startAt: number;
|
||||||
|
trailingArrow?: boolean; // chip's right edge shows → indicating "to vault"
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIPS: readonly ChipDef[] = [
|
||||||
|
{ label: 'intent', detail: 'build server', startAt: PARSE_INTENT_AT },
|
||||||
|
{ label: 'tool', detail: 'search_pages', startAt: PARSE_TOOL_AT },
|
||||||
|
{
|
||||||
|
label: 'secret',
|
||||||
|
detail: 'NOTION_API_KEY',
|
||||||
|
startAt: PARSE_SECRET_AT,
|
||||||
|
trailingArrow: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ChipRow({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
const anyShowing = localFrame >= PARSE_INTENT_AT - 2;
|
||||||
|
if (!anyShowing) return null;
|
||||||
|
|
||||||
|
// Whole row fades out subtly when the summary panel materialises so
|
||||||
|
// the focus shifts cleanly downward.
|
||||||
|
const rowFade = 1 - clampLerp(localFrame, SUMMARY_FULL + 10, SUMMARY_FULL + 40) * 0.35;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 18,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 14,
|
||||||
|
opacity: rowFade,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CHIPS.map((chip) => {
|
||||||
|
const t = springIn(localFrame, fps, chip.startAt);
|
||||||
|
if (t < 0.01) return <PlaceholderSlot key={chip.label} />;
|
||||||
|
const y = interpolate(t, [0, 1], [12, 0]);
|
||||||
|
const isSecret = chip.label === 'secret';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={chip.label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 16px',
|
||||||
|
backgroundColor: isSecret
|
||||||
|
? 'rgba(99,102,241,0.14)'
|
||||||
|
: 'rgba(99,102,241,0.06)',
|
||||||
|
border: `1px solid ${isSecret ? C.accent : C.accentDim}`,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
color: C.fg,
|
||||||
|
opacity: t,
|
||||||
|
transform: `translateY(${y}px)`,
|
||||||
|
boxShadow: isSecret
|
||||||
|
? `0 0 18px rgba(99,102,241,0.30)`
|
||||||
|
: '0 6px 18px rgba(0,0,0,0.30)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: isSecret ? C.accent : C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: C.fgMuted }}>·</span>
|
||||||
|
<span style={{ color: C.fg, letterSpacing: 0.3 }}>{chip.detail}</span>
|
||||||
|
{chip.trailingArrow && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: C.accent,
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→ vault
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holds the row width steady before its chip enters, so the rest of the
|
||||||
|
// layout doesn't shift as chips materialise.
|
||||||
|
function PlaceholderSlot() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 38,
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryStat {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATS: readonly SummaryStat[] = [
|
||||||
|
{ label: 'tools', value: '2', detail: 'search_pages, get_page_content' },
|
||||||
|
{ label: 'secrets', value: '1', detail: 'NOTION_API_KEY' },
|
||||||
|
{ label: 'targets', value: '3', detail: 'Claude · Cursor · ChatGPT' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function NeverCrossMini({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
const inT = springIn(localFrame, fps, NEVER_CROSS_START);
|
||||||
|
if (inT < 0.01) return null;
|
||||||
|
const fadeOut = 1 - clampLerp(localFrame, SCENE_LEN - 30, SCENE_LEN - 14);
|
||||||
|
const opacity = clampLerp(localFrame, NEVER_CROSS_START, NEVER_CROSS_FULL) * fadeOut;
|
||||||
|
if (opacity < 0.01) return null;
|
||||||
|
|
||||||
|
// Stroke-on for the two parallel arrows.
|
||||||
|
const strokeT = clampLerp(localFrame, NEVER_CROSS_START + 4, NEVER_CROSS_FULL + 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
opacity,
|
||||||
|
transform: `translateY(${interpolate(inT, [0, 1], [12, 0])}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1100,
|
||||||
|
padding: '18px 32px',
|
||||||
|
backgroundColor: 'rgba(17,17,20,0.65)',
|
||||||
|
border: `1px dashed ${C.borderStrong}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 28,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left rail — "prompt → AI" */}
|
||||||
|
<NeverCrossRail
|
||||||
|
label="prompt"
|
||||||
|
target="AI"
|
||||||
|
tone="accent"
|
||||||
|
strokeT={strokeT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Centre X-marker — explicit "no crossing" stamp */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
border: `1.5px solid ${C.fgMuted}`,
|
||||||
|
backgroundColor: 'rgba(17,17,20,0.85)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: `0 4px 14px rgba(0,0,0,0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M 5 5 L 15 15 M 15 5 L 5 15" stroke={C.fgMuted} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgMuted,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
never crosses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right rail — "vault → server" */}
|
||||||
|
<NeverCrossRail
|
||||||
|
label="vault"
|
||||||
|
target="server"
|
||||||
|
tone="accent"
|
||||||
|
strokeT={strokeT}
|
||||||
|
mirrored
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeverCrossRail({
|
||||||
|
label,
|
||||||
|
target,
|
||||||
|
tone,
|
||||||
|
strokeT,
|
||||||
|
mirrored,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
target: string;
|
||||||
|
tone: 'accent';
|
||||||
|
strokeT: number;
|
||||||
|
mirrored?: boolean;
|
||||||
|
}) {
|
||||||
|
const colour = C.accent;
|
||||||
|
// The rail is a chip → arrow → chip pattern.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
flexDirection: mirrored ? 'row-reverse' : 'row',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: mirrored ? 'flex-start' : 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pill text={label} colour={colour} />
|
||||||
|
<svg width="100" height="14" viewBox="0 0 100 14" style={{ flexShrink: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<marker id={`ncArrow-${label}`} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient={mirrored ? 'auto' : 'auto-start-reverse'}>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 Z" fill={colour} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<line
|
||||||
|
x1={mirrored ? 90 : 6}
|
||||||
|
y1={7}
|
||||||
|
x2={mirrored ? 6 : 90}
|
||||||
|
y2={7}
|
||||||
|
stroke={colour}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeDasharray={84}
|
||||||
|
strokeDashoffset={(1 - strokeT) * 84}
|
||||||
|
markerEnd={`url(#ncArrow-${label})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<Pill text={target} colour={colour} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({ text, colour }: { text: string; colour: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px',
|
||||||
|
backgroundColor: 'rgba(99,102,241,0.10)',
|
||||||
|
border: `1px solid ${colour}`,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fg,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryPanel({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
const panelT = springIn(localFrame, fps, SUMMARY_START);
|
||||||
|
if (panelT < 0.01) return null;
|
||||||
|
const fadeOutStart = SCENE_LEN - 30;
|
||||||
|
const fadeOut = 1 - clampLerp(localFrame, fadeOutStart, fadeOutStart + 18);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 22,
|
||||||
|
opacity: panelT * fadeOut,
|
||||||
|
transform: `translateY(${interpolate(panelT, [0, 1], [16, 0])}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1100,
|
||||||
|
padding: '20px 28px',
|
||||||
|
backgroundColor: 'rgba(17,17,20,0.65)',
|
||||||
|
border: `1px dashed ${C.borderStrong}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 28,
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 3.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
minWidth: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
parsed
|
||||||
|
</div>
|
||||||
|
{STATS.map((s, i) => {
|
||||||
|
const colT = springIn(localFrame, fps, SUMMARY_START + 4 + i * 5);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
opacity: colT,
|
||||||
|
transform: `translateY(${interpolate(colT, [0, 1], [8, 0])}px)`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 2.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: C.accent,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: C.fgMuted,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.detail}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,54 +2,54 @@ import { interpolate } from 'remotion';
|
|||||||
import { C } from '../lib/colors';
|
import { C } from '../lib/colors';
|
||||||
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
||||||
|
|
||||||
// Phase 1.5 (frames 75–165 global → localFrame 0..90): the prompt panel from
|
// Phase 3 (now 456 frames, ~15.2s): prompt panel + vault panel + arrow fork
|
||||||
// Phase 1 slides left and a second, vault-styled panel appears to its right.
|
// (original beats over first ~90 frames). Then new beats:
|
||||||
// The user's API key is masked (`secret_•••••••••••••••••••3a8f`) and a
|
// • AES-256-GCM stamp appears next to the vault (frame ~95).
|
||||||
// "stored AES-256 · never sent to the AI" sub-label calms the trust nerves.
|
// • Masked key value animates from the vault into a `key={NOTION_API_KEY}`
|
||||||
//
|
// env-var pill placed beside a small container outline → "injected at
|
||||||
// Toward the end of the phase, two arrows fork apart:
|
// runtime" moment (frames ~180–340).
|
||||||
// • prompt → LLM brain icon (active, glowing) → continues right/down
|
// • Late beat: the prompt panel flickers back in DIM and labeled
|
||||||
// • secrets → vault icon (active) → cross-marked through the LLM,
|
// "what the model sees", visually separating from the vault.
|
||||||
// re-emerging beyond it
|
|
||||||
// Both reconverge at the right edge to set up the Phase 2 build/server card.
|
|
||||||
//
|
|
||||||
// The arrow forking IS the architectural punchline: prose lives goes through
|
|
||||||
// the LLM, the secret value does not.
|
|
||||||
|
|
||||||
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.';
|
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.';
|
||||||
const KEY_LABEL = 'NOTION_API_KEY';
|
const KEY_LABEL = 'NOTION_API_KEY';
|
||||||
const KEY_VISIBLE = 'secret_'; // typed portion before masking
|
const KEY_VISIBLE = 'secret_';
|
||||||
const KEY_BULLETS = '•••••••••••••••••••';
|
const KEY_BULLETS = '•••••••••••••••••••';
|
||||||
const KEY_TAIL = '3a8f';
|
const KEY_TAIL = '3a8f';
|
||||||
|
|
||||||
export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
const SCENE_LEN = 456;
|
||||||
// Layout: two panels side-by-side, each ~620 wide, centered.
|
|
||||||
// Prompt mini-panel sits on the left at x≈260; secrets vault at x≈1040.
|
|
||||||
// Prompt panel slides in from its Phase-1 center position; secrets panel
|
|
||||||
// springs in from the right at frame ~8.
|
|
||||||
|
|
||||||
// Prompt panel: enters by sliding left from its previous (centered) home.
|
// Original phase-1.5 beats (unchanged):
|
||||||
|
const TYPE_START = 30;
|
||||||
|
const TYPE_END = 44;
|
||||||
|
const MASK_START = 46;
|
||||||
|
const MASK_END = 60;
|
||||||
|
const TAIL_START = 60;
|
||||||
|
const TAIL_END = 66;
|
||||||
|
const LOCK_SNAP_AT = 66;
|
||||||
|
const ARROW_START = 70;
|
||||||
|
const ARROW_END = 88;
|
||||||
|
|
||||||
|
// New beats:
|
||||||
|
const AES_STAMP_AT = 95;
|
||||||
|
const INJECT_START = 180; // ~6s — key chip pops out of vault
|
||||||
|
const INJECT_FLY_END = 240; // chip arrives at container
|
||||||
|
const CONTAINER_IN = 200;
|
||||||
|
const FLICKER_PROMPT_AT = 300; // "the model only sees the description"
|
||||||
|
|
||||||
|
const EXIT_START = SCENE_LEN - 12;
|
||||||
|
|
||||||
|
export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: number }) {
|
||||||
|
// Prompt panel: enters by sliding left from center.
|
||||||
const promptShift = clampLerp(localFrame, 0, 18);
|
const promptShift = clampLerp(localFrame, 0, 18);
|
||||||
// Phase 1 ended centered. Phase 1.5 puts the prompt panel at left third.
|
|
||||||
const promptX = interpolate(promptShift, [0, 1], [410, 60]);
|
const promptX = interpolate(promptShift, [0, 1], [410, 60]);
|
||||||
|
|
||||||
// Secrets panel: spring in from below-right.
|
// Secrets vault panel: spring in from below-right.
|
||||||
const vaultIn = springIn(localFrame, fps, 8);
|
const vaultIn = springIn(localFrame, fps, 8);
|
||||||
const vaultOpacity = clampLerp(localFrame, 8, 24);
|
const vaultOpacity = clampLerp(localFrame, 8, 24);
|
||||||
const vaultY = interpolate(vaultIn, [0, 1], [40, 0]);
|
const vaultY = interpolate(vaultIn, [0, 1], [40, 0]);
|
||||||
const vaultScale = interpolate(vaultIn, [0, 1], [0.92, 1]);
|
const vaultScale = interpolate(vaultIn, [0, 1], [0.92, 1]);
|
||||||
|
|
||||||
// Key field typing: "secret_" appears in plaintext for a beat (chars/sec ~24),
|
|
||||||
// then masking pass: bullets fade in while plaintext fades out, tail "3a8f"
|
|
||||||
// reveals at the end. Locked-icon snaps closed once masking finishes.
|
|
||||||
const TYPE_START = 30;
|
|
||||||
const TYPE_END = 44; // shows "secret_"
|
|
||||||
const MASK_START = 46;
|
|
||||||
const MASK_END = 60; // bullets in
|
|
||||||
const TAIL_START = 60;
|
|
||||||
const TAIL_END = 66;
|
|
||||||
const LOCK_SNAP_AT = 66;
|
|
||||||
|
|
||||||
const typingProgress = clampLerp(localFrame, TYPE_START, TYPE_END);
|
const typingProgress = clampLerp(localFrame, TYPE_START, TYPE_END);
|
||||||
const maskProgress = clampLerp(localFrame, MASK_START, MASK_END);
|
const maskProgress = clampLerp(localFrame, MASK_START, MASK_END);
|
||||||
const tailProgress = clampLerp(localFrame, TAIL_START, TAIL_END);
|
const tailProgress = clampLerp(localFrame, TAIL_START, TAIL_END);
|
||||||
@ -62,18 +62,45 @@ export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: num
|
|||||||
const tailChars = Math.floor(tailProgress * KEY_TAIL.length);
|
const tailChars = Math.floor(tailProgress * KEY_TAIL.length);
|
||||||
const tailShown = KEY_TAIL.slice(0, tailChars);
|
const tailShown = KEY_TAIL.slice(0, tailChars);
|
||||||
|
|
||||||
// Once masking begins, fade the "secret_" plaintext into a stable header
|
|
||||||
// of bullets (the user no longer sees raw prefix either).
|
|
||||||
const plaintextOpacity = interpolate(maskProgress, [0, 1], [1, 1]); // keep "secret_" as prefix
|
|
||||||
|
|
||||||
// Arrow fork animation starts at localFrame ~70 and runs until 88.
|
|
||||||
const ARROW_START = 70;
|
|
||||||
const ARROW_END = 88;
|
|
||||||
const arrowProgress = clampLerp(localFrame, ARROW_START, ARROW_END);
|
const arrowProgress = clampLerp(localFrame, ARROW_START, ARROW_END);
|
||||||
const xMarkPop = springIn(localFrame, fps, ARROW_START + 8);
|
const xMarkPop = springIn(localFrame, fps, ARROW_START + 8);
|
||||||
|
|
||||||
// Phase exit drift — last 6 frames, lift up subtly (parent already crossfades).
|
// After ~110 frames, the arrows + LLM icon fade out to clear the canvas for
|
||||||
const exitProgress = clampLerp(localFrame, 84, 90);
|
// the new beats (env-var injection).
|
||||||
|
const arrowFadeOut = 1 - clampLerp(localFrame, 110, 140);
|
||||||
|
|
||||||
|
// AES stamp.
|
||||||
|
const aesIn = springIn(localFrame, fps, AES_STAMP_AT);
|
||||||
|
|
||||||
|
// Env-var injection: the masked key value flies from the vault into a
|
||||||
|
// container outline.
|
||||||
|
const injectProgress = clampLerp(localFrame, INJECT_START, INJECT_FLY_END);
|
||||||
|
const containerIn = springIn(localFrame, fps, CONTAINER_IN);
|
||||||
|
|
||||||
|
// Lock-snap flash — a short bright indigo ring expands outward from the
|
||||||
|
// lock the moment it closes. Lifts the snap from "subtle SVG morph" to
|
||||||
|
// "the vault clicked shut". Lasts ~12 frames, peaks at frame 66+4.
|
||||||
|
const lockFlashT = clampLerp(localFrame, LOCK_SNAP_AT, LOCK_SNAP_AT + 16);
|
||||||
|
const lockFlashFade = 1 - clampLerp(localFrame, LOCK_SNAP_AT + 6, LOCK_SNAP_AT + 22);
|
||||||
|
const lockFlash = Math.min(lockFlashT, lockFlashFade);
|
||||||
|
|
||||||
|
// Late stage: keep vault at FULL opacity throughout so the eye still has
|
||||||
|
// it as the anchor while the env-var injection happens to the side. The
|
||||||
|
// prompt panel still fades — that beat is owned by the arrow fork.
|
||||||
|
const stageShift = clampLerp(localFrame, INJECT_START - 20, INJECT_START + 10);
|
||||||
|
const vaultLateOpacity = 1;
|
||||||
|
const promptLateOpacity = interpolate(stageShift, [0, 1], [1, 0]);
|
||||||
|
|
||||||
|
// Vault → container connector — a soft dashed line drawn between the
|
||||||
|
// vault's bottom edge and the container's env slot at the moment the
|
||||||
|
// chip travels. Anchors the architectural story: this key came FROM
|
||||||
|
// that vault.
|
||||||
|
const connectorReveal = clampLerp(localFrame, INJECT_START - 6, INJECT_START + 18);
|
||||||
|
const connectorFade = 1 - clampLerp(localFrame, INJECT_FLY_END + 6, INJECT_FLY_END + 30);
|
||||||
|
const connectorAlpha = Math.min(connectorReveal, connectorFade);
|
||||||
|
|
||||||
|
// Exit drift.
|
||||||
|
const exitProgress = clampLerp(localFrame, EXIT_START, SCENE_LEN);
|
||||||
const exitY = interpolate(exitProgress, [0, 1], [0, -24]);
|
const exitY = interpolate(exitProgress, [0, 1], [0, -24]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -84,17 +111,21 @@ export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: num
|
|||||||
transform: `translateY(${exitY}px)`,
|
transform: `translateY(${exitY}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Prompt mini-panel (left) */}
|
{/* Original prompt mini-panel (left). Fades into the late stage. */}
|
||||||
|
<div style={{ opacity: promptLateOpacity }}>
|
||||||
<PromptMiniPanel x={promptX} promptText={PROMPT_TEXT} />
|
<PromptMiniPanel x={promptX} promptText={PROMPT_TEXT} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Secrets vault panel (right) */}
|
{/* Vault panel (right) — stays at FULL opacity through the whole
|
||||||
|
scene so the eye keeps it as the anchor while the env-var
|
||||||
|
chip travels to the container below. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 1040,
|
left: 1040,
|
||||||
top: (1080 - 380) / 2,
|
top: (1080 - 380) / 2,
|
||||||
width: 820,
|
width: 820,
|
||||||
opacity: vaultOpacity,
|
opacity: vaultOpacity * vaultLateOpacity,
|
||||||
transform: `translateY(${vaultY}px) scale(${vaultScale})`,
|
transform: `translateY(${vaultY}px) scale(${vaultScale})`,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
@ -105,20 +136,312 @@ export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: num
|
|||||||
localFrame={localFrame}
|
localFrame={localFrame}
|
||||||
keyLabel={KEY_LABEL}
|
keyLabel={KEY_LABEL}
|
||||||
keyValue={`${typedShown}${bulletsShown}${tailShown}`}
|
keyValue={`${typedShown}${bulletsShown}${tailShown}`}
|
||||||
plaintextOpacity={plaintextOpacity}
|
/>
|
||||||
|
{/* Lock-snap flash — bright ring expanding out from the lock
|
||||||
|
icon at the exact moment it closes. The lock icon itself
|
||||||
|
sits at the vault panel's header (top-left), so the flash
|
||||||
|
is anchored there. */}
|
||||||
|
{lockFlash > 0.02 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 24,
|
||||||
|
top: 22,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `2px solid ${C.accent}`,
|
||||||
|
transform: `scale(${1 + lockFlash * 6})`,
|
||||||
|
opacity: lockFlash * (1 - lockFlash) * 4,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: '50%',
|
||||||
|
boxShadow: `0 0 ${20 * lockFlash}px ${10 * lockFlash}px rgba(99, 102, 241, ${0.55 * lockFlash})`,
|
||||||
|
opacity: lockFlash,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Arrow fork SVG overlay — drawn full-frame, but only the arrow paths
|
{/* AES-256-GCM stamp */}
|
||||||
are visible; the rest is transparent. */}
|
{aesIn > 0.02 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 1040,
|
||||||
|
top: (1080 - 380) / 2 - 50,
|
||||||
|
opacity: Math.min(aesIn, vaultLateOpacity),
|
||||||
|
transform: `translateY(${interpolate(aesIn, [0, 1], [10, 0])}px) scale(${interpolate(aesIn, [0, 1], [0.8, 1])})`,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.accent,
|
||||||
|
border: `1px solid ${C.accent}`,
|
||||||
|
backgroundColor: 'rgba(99,102,241,0.10)',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '6px 14px',
|
||||||
|
boxShadow: `0 4px 14px rgba(99,102,241,0.30)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AES-256-GCM
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Arrow fork SVG (fades out after initial beat) */}
|
||||||
|
<div style={{ opacity: arrowFadeOut * vaultLateOpacity }}>
|
||||||
<ArrowFork progress={arrowProgress} xPop={xMarkPop} />
|
<ArrowFork progress={arrowProgress} xPop={xMarkPop} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Vault → container connector — appears as the chip starts its
|
||||||
|
flight, so the eye reads "the key in the vault BECOMES the
|
||||||
|
env var in the container." Without this the chip-flight
|
||||||
|
reads as "thing moves" rather than "vault feeds container". */}
|
||||||
|
<VaultContainerConnector alpha={connectorAlpha} />
|
||||||
|
|
||||||
|
{/* New beat: container outline + env-var injection */}
|
||||||
|
{containerIn > 0.02 && (
|
||||||
|
<ContainerWithEnvVar
|
||||||
|
containerProgress={containerIn}
|
||||||
|
injectProgress={injectProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI-view vs your-stack reveal — replaces the earlier "flicker
|
||||||
|
back" beat. Two compact callouts that frame the architectural
|
||||||
|
contrast: what the AI saw (just the prompt sentence), versus
|
||||||
|
what your stack holds (vault + container with the secret).
|
||||||
|
Both labelled, both readable. Appears late and stays until
|
||||||
|
scene-exit. */}
|
||||||
|
<AiViewVsStack localFrame={localFrame} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VaultContainerConnector({ alpha }: { alpha: number }) {
|
||||||
|
if (alpha < 0.01) return null;
|
||||||
|
// Vault sits at (1040–1860, ~350–730). Container sits at (720–1200,
|
||||||
|
// 720–920). Draw a dashed indigo curve from the vault's bottom-left
|
||||||
|
// (about 1100, 720) to the container's right-top env slot
|
||||||
|
// (about 1200, 850). Slight arc for visual interest.
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: alpha }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M 1140 720 Q 1180 800 1200 850"
|
||||||
|
stroke={C.accent}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
fill="none"
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AiViewVsStack({ localFrame }: { localFrame: number }) {
|
||||||
|
// Appears at FLICKER_PROMPT_AT (300) after the env-var has landed
|
||||||
|
// in the container — once both halves of the stack exist on screen,
|
||||||
|
// we can label them. Designed in v10 to be more visually emphatic
|
||||||
|
// than the previous compact card: literal viewfinder bracket corners
|
||||||
|
// around the prompt text frame the idea "this is everything the
|
||||||
|
// model can see," plus a stronger three-line "denied list" below.
|
||||||
|
const inT = clampLerp(localFrame, FLICKER_PROMPT_AT, FLICKER_PROMPT_AT + 22);
|
||||||
|
if (inT < 0.02) return null;
|
||||||
|
const fadeOut = 1 - clampLerp(localFrame, SCENE_LEN - 24, SCENE_LEN - 8);
|
||||||
|
const opacity = inT * fadeOut;
|
||||||
|
|
||||||
|
const promptShortened = '“Build me an MCP server that searches our Notion workspace.”';
|
||||||
|
|
||||||
|
// Bigger, more central. Width 680 (was 560), positioned so the
|
||||||
|
// viewfinder frame is the dominant left-side element.
|
||||||
|
const W = 680;
|
||||||
|
const LEFT = 60;
|
||||||
|
const TOP = 180;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', left: LEFT, top: TOP, width: W, opacity }}>
|
||||||
|
{/* Header — "what the AI sees" with eye icon for instant readability */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 3.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
marginBottom: 16,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
what the AI sees
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viewfinder frame: prompt text surrounded by four corner brackets
|
||||||
|
to read as "this is the entire field of view." */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: '28px 32px',
|
||||||
|
backgroundColor: 'rgba(20,20,24,0.80)',
|
||||||
|
borderRadius: 14,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 18,
|
||||||
|
color: C.fg,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ViewfinderCorners />
|
||||||
|
<span style={{ color: C.fgMuted }}>{promptShortened}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Denied list — three explicit "not in view" lines so the
|
||||||
|
contrast with the stack on the right reads even on a quick
|
||||||
|
scrub. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 18,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
'no secrets',
|
||||||
|
'no environment variables',
|
||||||
|
'no tokens',
|
||||||
|
].map((line) => (
|
||||||
|
<div
|
||||||
|
key={line}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
color: '#9ca3af',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeniedDot />
|
||||||
|
<span>{line}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="14" viewBox="0 0 22 14" fill="none">
|
||||||
|
<path
|
||||||
|
d="M 1 7 Q 11 1 21 7 Q 11 13 1 7 Z"
|
||||||
|
stroke={C.fgSubtle}
|
||||||
|
strokeWidth={1.3}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<circle cx="11" cy="7" r="3" stroke={C.fgSubtle} strokeWidth={1.3} fill="rgba(161,161,170,0.15)" />
|
||||||
|
<circle cx="11" cy="7" r="1.4" fill={C.fgSubtle} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewfinderCorners() {
|
||||||
|
// Four L-shaped brackets at the corners of the parent frame. SVG
|
||||||
|
// path d-attributes do NOT accept CSS calc(); instead each corner is
|
||||||
|
// a separate <div> sized in pixels with two borders showing through.
|
||||||
|
// Positioned absolutely to the frame's inside, 4 px from each edge.
|
||||||
|
const armLen = 18;
|
||||||
|
const stroke = 1.6;
|
||||||
|
const colour = C.fgSubtle;
|
||||||
|
const opacity = 0.75;
|
||||||
|
const inset = 4;
|
||||||
|
const cornerStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
width: armLen,
|
||||||
|
height: armLen,
|
||||||
|
pointerEvents: 'none' as const,
|
||||||
|
opacity,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* top-left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...cornerStyle,
|
||||||
|
top: inset,
|
||||||
|
left: inset,
|
||||||
|
borderTop: `${stroke}px solid ${colour}`,
|
||||||
|
borderLeft: `${stroke}px solid ${colour}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* top-right */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...cornerStyle,
|
||||||
|
top: inset,
|
||||||
|
right: inset,
|
||||||
|
borderTop: `${stroke}px solid ${colour}`,
|
||||||
|
borderRight: `${stroke}px solid ${colour}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* bottom-left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...cornerStyle,
|
||||||
|
bottom: inset,
|
||||||
|
left: inset,
|
||||||
|
borderBottom: `${stroke}px solid ${colour}`,
|
||||||
|
borderLeft: `${stroke}px solid ${colour}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* bottom-right */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...cornerStyle,
|
||||||
|
bottom: inset,
|
||||||
|
right: inset,
|
||||||
|
borderBottom: `${stroke}px solid ${colour}`,
|
||||||
|
borderRight: `${stroke}px solid ${colour}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeniedDot() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="6.5" stroke="#9ca3af" strokeWidth={1.4} fill="rgba(156,163,175,0.08)" />
|
||||||
|
<path d="M 5 9 L 13 9" stroke="#9ca3af" strokeWidth={1.6} strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- Sub-components below -------
|
||||||
|
|
||||||
function PromptMiniPanel({ x, promptText }: { x: number; promptText: string }) {
|
function PromptMiniPanel({ x, promptText }: { x: number; promptText: string }) {
|
||||||
// A condensed version of Phase 1's input field. Same border / glow style
|
|
||||||
// (accent active) but the cursor is gone and the text is full.
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -176,7 +499,6 @@ function PromptMiniPanel({ x, promptText }: { x: number; promptText: string }) {
|
|||||||
{promptText}
|
{promptText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Sublabel — what gets sent here */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
@ -209,11 +531,7 @@ function VaultPanel({
|
|||||||
localFrame: number;
|
localFrame: number;
|
||||||
keyLabel: string;
|
keyLabel: string;
|
||||||
keyValue: string;
|
keyValue: string;
|
||||||
plaintextOpacity: number;
|
|
||||||
}) {
|
}) {
|
||||||
// Indigo accent border — same family as the active prompt's glow, but
|
|
||||||
// thicker (2px vs 1px) and with the accent border held even before "filled".
|
|
||||||
// Visually says: this is its own, equally important, primary surface.
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -226,7 +544,6 @@ function VaultPanel({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top row: lock icon + label + backend-only chip */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -267,14 +584,7 @@ function VaultPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key field */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
@ -306,7 +616,6 @@ function VaultPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-text */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 18,
|
marginTop: 18,
|
||||||
@ -338,14 +647,12 @@ function LockIcon({
|
|||||||
fps: number;
|
fps: number;
|
||||||
localFrame: number;
|
localFrame: number;
|
||||||
}) {
|
}) {
|
||||||
// Lock shackle pops down a hair when the lock snaps closed.
|
|
||||||
const snapAt = 66;
|
const snapAt = 66;
|
||||||
const snap = springIn(localFrame, fps, snapAt);
|
const snap = springIn(localFrame, fps, snapAt);
|
||||||
const shackleY = open ? -3 : interpolate(snap, [0, 1], [-3, 0]);
|
const shackleY = open ? -3 : interpolate(snap, [0, 1], [-3, 0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
{/* Shackle */}
|
|
||||||
<path
|
<path
|
||||||
d={
|
d={
|
||||||
open
|
open
|
||||||
@ -358,9 +665,7 @@ function LockIcon({
|
|||||||
fill="none"
|
fill="none"
|
||||||
transform={`translate(0 ${shackleY})`}
|
transform={`translate(0 ${shackleY})`}
|
||||||
/>
|
/>
|
||||||
{/* Body */}
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" stroke={C.accent} strokeWidth={2} fill="rgba(99,102,241,0.12)" />
|
<rect x="5" y="11" width="14" height="10" rx="2" stroke={C.accent} strokeWidth={2} fill="rgba(99,102,241,0.12)" />
|
||||||
{/* Keyhole */}
|
|
||||||
<circle cx="12" cy="15.5" r="1.4" fill={C.accent} />
|
<circle cx="12" cy="15.5" r="1.4" fill={C.accent} />
|
||||||
<rect x="11.5" y="16" width="1" height="3" fill={C.accent} />
|
<rect x="11.5" y="16" width="1" height="3" fill={C.accent} />
|
||||||
</svg>
|
</svg>
|
||||||
@ -393,44 +698,18 @@ function BrainIcon({ size = 14 }: { size?: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
||||||
// Two arrows drawn with dasharray reveal as `progress` goes 0→1.
|
|
||||||
// Prompt arrow (top): solid line from prompt panel right edge to LLM icon,
|
|
||||||
// continues onward.
|
|
||||||
// Secrets arrow (bottom): from vault panel left edge crosses *under* the LLM
|
|
||||||
// icon — drawn dashed and with an X-mark over the LLM, indicating "does not
|
|
||||||
// flow through here".
|
|
||||||
//
|
|
||||||
// Both reconverge near right edge offscreen — this scene exits to Build.
|
|
||||||
//
|
|
||||||
// The SVG is full-frame; coordinates match the panel layout we set up:
|
|
||||||
// Prompt panel: left=60, top=(1080-220)/2 ≈ 430, width 760, panel height ~92,
|
|
||||||
// anchor right-edge midpoint ~(820, 540).
|
|
||||||
// Vault panel: left=1040, top=(1080-380)/2 = 350, width 820, height ~340,
|
|
||||||
// anchor left-edge midpoint ~(1040, 520).
|
|
||||||
//
|
|
||||||
// We draw a dummy "LLM" icon at (960, 470) — between & above the two panels.
|
|
||||||
// The arrows fork toward this icon: prompt → through it; secrets → past it
|
|
||||||
// but cross-marked. Then both converge at right (x≈1880, y≈540) where the
|
|
||||||
// next scene takes over.
|
|
||||||
|
|
||||||
const LLM = { x: 960, y: 240 };
|
const LLM = { x: 960, y: 240 };
|
||||||
const VAULT_OUT = { x: 1040, y: 520 };
|
const VAULT_OUT = { x: 1040, y: 520 };
|
||||||
const PROMPT_OUT = { x: 820, y: 540 };
|
const PROMPT_OUT = { x: 820, y: 540 };
|
||||||
const CONVERGE = { x: 960, y: 920 };
|
const CONVERGE = { x: 960, y: 920 };
|
||||||
|
|
||||||
// Arrow 1: PROMPT → LLM → CONVERGE (passes through LLM)
|
|
||||||
// Drawn as a polyline. Reveal via strokeDashoffset.
|
|
||||||
const pathP = `M ${PROMPT_OUT.x} ${PROMPT_OUT.y} L 880 ${PROMPT_OUT.y} L 880 ${LLM.y + 30} L ${LLM.x} ${LLM.y + 30}`;
|
const pathP = `M ${PROMPT_OUT.x} ${PROMPT_OUT.y} L 880 ${PROMPT_OUT.y} L 880 ${LLM.y + 30} L ${LLM.x} ${LLM.y + 30}`;
|
||||||
// From LLM continuing toward CONVERGE
|
|
||||||
const pathPCont = `M ${LLM.x + 30} ${LLM.y + 30} L 1040 ${LLM.y + 30} L 1040 ${CONVERGE.y - 60} L ${CONVERGE.x} ${CONVERGE.y - 60}`;
|
const pathPCont = `M ${LLM.x + 30} ${LLM.y + 30} L 1040 ${LLM.y + 30} L 1040 ${CONVERGE.y - 60} L ${CONVERGE.x} ${CONVERGE.y - 60}`;
|
||||||
|
|
||||||
// Arrow 2: VAULT → bypass LLM → CONVERGE (dashed)
|
|
||||||
const pathS = `M ${VAULT_OUT.x} ${VAULT_OUT.y} L 900 ${VAULT_OUT.y} L 900 ${CONVERGE.y - 30} L ${CONVERGE.x} ${CONVERGE.y - 30}`;
|
const pathS = `M ${VAULT_OUT.x} ${VAULT_OUT.y} L 900 ${VAULT_OUT.y} L 900 ${CONVERGE.y - 30} L ${CONVERGE.x} ${CONVERGE.y - 30}`;
|
||||||
|
|
||||||
// Approximate lengths for reveal.
|
const lenP = 480;
|
||||||
const lenP = 80 + 240 + 80 + 80; // ~480 (a generous over-estimate is fine)
|
const lenPCont = 750;
|
||||||
const lenPCont = 80 + 600 + 70;
|
const lenS = 610;
|
||||||
const lenS = 140 + 350 + 60 + 60;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@ -447,7 +726,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Prompt arrow → LLM */}
|
|
||||||
<path
|
<path
|
||||||
d={pathP}
|
d={pathP}
|
||||||
stroke={C.accent}
|
stroke={C.accent}
|
||||||
@ -460,11 +738,9 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
opacity={progress > 0 ? 1 : 0}
|
opacity={progress > 0 ? 1 : 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* LLM icon — visible once first arrow reaches it (~progress 0.5) */}
|
|
||||||
{progress > 0.4 && (
|
{progress > 0.4 && (
|
||||||
<g transform={`translate(${LLM.x - 28} ${LLM.y - 4})`} opacity={Math.min(1, (progress - 0.4) * 4)}>
|
<g transform={`translate(${LLM.x - 28} ${LLM.y - 4})`} opacity={Math.min(1, (progress - 0.4) * 4)}>
|
||||||
<rect width="56" height="56" rx="12" fill={C.bgElevated} stroke={C.accent} strokeWidth={1.5} />
|
<rect width="56" height="56" rx="12" fill={C.bgElevated} stroke={C.accent} strokeWidth={1.5} />
|
||||||
{/* Brain-ish glyph */}
|
|
||||||
<circle cx="28" cy="28" r="14" stroke={C.accent} strokeWidth={1.5} fill="rgba(99,102,241,0.10)" />
|
<circle cx="28" cy="28" r="14" stroke={C.accent} strokeWidth={1.5} fill="rgba(99,102,241,0.10)" />
|
||||||
<path d="M 20 28 Q 28 20 36 28 Q 28 36 20 28 Z" stroke={C.accent} strokeWidth={1.5} fill="none" />
|
<path d="M 20 28 Q 28 20 36 28 Q 28 36 20 28 Z" stroke={C.accent} strokeWidth={1.5} fill="none" />
|
||||||
<circle cx="28" cy="28" r="2.5" fill={C.accent} />
|
<circle cx="28" cy="28" r="2.5" fill={C.accent} />
|
||||||
@ -474,7 +750,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prompt continuation (after LLM) */}
|
|
||||||
<path
|
<path
|
||||||
d={pathPCont}
|
d={pathPCont}
|
||||||
stroke={C.accent}
|
stroke={C.accent}
|
||||||
@ -488,7 +763,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
markerEnd="url(#arrowAccent)"
|
markerEnd="url(#arrowAccent)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Secrets arrow — dashed, bypasses LLM */}
|
|
||||||
<path
|
<path
|
||||||
d={pathS}
|
d={pathS}
|
||||||
stroke={C.fgSubtle}
|
stroke={C.fgSubtle}
|
||||||
@ -501,7 +775,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
markerEnd="url(#arrowMuted)"
|
markerEnd="url(#arrowMuted)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* X-mark on the LLM box for the secrets path — pops in with spring */}
|
|
||||||
{xPop > 0.05 && (
|
{xPop > 0.05 && (
|
||||||
<g
|
<g
|
||||||
transform={`translate(${LLM.x + 28} ${LLM.y + 24}) scale(${0.5 + xPop * 0.5})`}
|
transform={`translate(${LLM.x + 28} ${LLM.y + 24}) scale(${0.5 + xPop * 0.5})`}
|
||||||
@ -512,11 +785,9 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vault icon label near the secrets arrow start */}
|
|
||||||
{progress > 0.1 && (
|
{progress > 0.1 && (
|
||||||
<g transform={`translate(${VAULT_OUT.x - 36} ${VAULT_OUT.y - 22})`} opacity={Math.min(1, (progress - 0.1) * 4)}>
|
<g transform={`translate(${VAULT_OUT.x - 36} ${VAULT_OUT.y - 22})`} opacity={Math.min(1, (progress - 0.1) * 4)}>
|
||||||
<rect width="56" height="44" rx="8" fill={C.bgElevated} stroke={C.accentDim} strokeWidth={1.5} />
|
<rect width="56" height="44" rx="8" fill={C.bgElevated} stroke={C.accentDim} strokeWidth={1.5} />
|
||||||
{/* mini vault: lock body + shackle */}
|
|
||||||
<path d="M 19 22 L 19 16 A 9 9 0 0 1 37 16 L 37 22" stroke={C.accent} strokeWidth={1.5} fill="none" />
|
<path d="M 19 22 L 19 16 A 9 9 0 0 1 37 16 L 37 22" stroke={C.accent} strokeWidth={1.5} fill="none" />
|
||||||
<rect x="17" y="21" width="22" height="14" rx="2" fill="rgba(99,102,241,0.18)" stroke={C.accent} strokeWidth={1.5} />
|
<rect x="17" y="21" width="22" height="14" rx="2" fill="rgba(99,102,241,0.18)" stroke={C.accent} strokeWidth={1.5} />
|
||||||
<circle cx="28" cy="27" r="1.6" fill={C.accent} />
|
<circle cx="28" cy="27" r="1.6" fill={C.accent} />
|
||||||
@ -528,3 +799,144 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContainerWithEnvVar({
|
||||||
|
containerProgress,
|
||||||
|
injectProgress,
|
||||||
|
}: {
|
||||||
|
containerProgress: number;
|
||||||
|
injectProgress: number;
|
||||||
|
}) {
|
||||||
|
// Container outline sits below-center of the canvas (around y ~720), the
|
||||||
|
// vault is up at y ~520. The chip flies from the vault center down into
|
||||||
|
// the container's "env" slot.
|
||||||
|
const containerX = 1920 / 2 - 240;
|
||||||
|
const containerY = 720;
|
||||||
|
const containerW = 480;
|
||||||
|
const containerH = 200;
|
||||||
|
|
||||||
|
// Chip origin (vault center): roughly (1450, 540).
|
||||||
|
const ORIGIN = { x: 1450, y: 520 };
|
||||||
|
// Chip destination (env slot inside container): (containerX + 240, containerY + 130)
|
||||||
|
const DEST = { x: containerX + 240, y: containerY + 130 };
|
||||||
|
|
||||||
|
const chipX = interpolate(injectProgress, [0, 1], [ORIGIN.x, DEST.x]);
|
||||||
|
const chipY = interpolate(injectProgress, [0, 1], [ORIGIN.y, DEST.y]);
|
||||||
|
// Arc trajectory: gentle parabola
|
||||||
|
const arc = Math.sin(injectProgress * Math.PI) * -40;
|
||||||
|
|
||||||
|
// Chip scales down slightly as it travels.
|
||||||
|
const chipScale = interpolate(injectProgress, [0, 1], [1.0, 0.85]);
|
||||||
|
|
||||||
|
const opacity = clampLerp(containerProgress, 0, 1);
|
||||||
|
const scale = interpolate(containerProgress, [0, 1], [0.95, 1]);
|
||||||
|
|
||||||
|
// Once chip has landed, the env-var "slot" inside the container glows.
|
||||||
|
const slotGlow = clampLerp(injectProgress, 0.85, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Container outline */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: containerX,
|
||||||
|
top: containerY,
|
||||||
|
width: containerW,
|
||||||
|
height: containerH,
|
||||||
|
opacity,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -28,
|
||||||
|
left: 0,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 3,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: C.fgSubtle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
container · runtime
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: `1.5px dashed ${C.borderStrong}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: 'rgba(17,17,20,0.6)',
|
||||||
|
padding: '24px 28px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: C.fgSubtle,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
env
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${slotGlow > 0.2 ? C.accent : C.border}`,
|
||||||
|
backgroundColor: slotGlow > 0.2 ? 'rgba(99,102,241,0.10)' : C.bgSubtle,
|
||||||
|
padding: '14px 18px',
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 18,
|
||||||
|
color: C.fg,
|
||||||
|
boxShadow: slotGlow > 0.2 ? `0 0 ${10 + slotGlow * 14}px rgba(99,102,241,${0.25 + slotGlow * 0.20})` : 'none',
|
||||||
|
transition: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: C.fgMuted }}>key=</span>
|
||||||
|
<span>{KEY_LABEL}</span>
|
||||||
|
<span style={{ color: C.fgMuted, marginLeft: 8 }}>
|
||||||
|
{slotGlow > 0.4 ? '•••••••3a8f' : '...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flying chip (only visible during injection) */}
|
||||||
|
{injectProgress > 0.02 && injectProgress < 0.98 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: chipX,
|
||||||
|
top: chipY + arc,
|
||||||
|
transform: `translate(-50%, -50%) scale(${chipScale})`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 14px',
|
||||||
|
backgroundColor: 'rgba(99,102,241,0.18)',
|
||||||
|
border: `1px solid ${C.accent}`,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: C.fg,
|
||||||
|
boxShadow: `0 0 18px rgba(99,102,241,0.40)`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: C.accent }}>🔒</span>
|
||||||
|
<span>{KEY_LABEL}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
15
remotion/voice text von audio.txt
Normal file
15
remotion/voice text von audio.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
Code generation with AI is solved. What's left is hosting, oh-auth, domains, containers, secret injection — and keeping your keys out of the AI context.
|
||||||
|
|
||||||
|
BuildMyMCPServer closes that gap. One sentence describes your tool. The AI generates the code — [emphasis] the prompt path and the secret path never cross.
|
||||||
|
|
||||||
|
Your credentials live in a separate vault, encrypted with A-E-S two fifty six, isolated from the AI pipeline. Injected into your container as environment variables at runtime. [serious] The model only sees the description.
|
||||||
|
|
||||||
|
We generate the TypeScript, run static checks, ship the container — under sixty seconds.
|
||||||
|
|
||||||
|
Each server runs in its own hardened container. Read-only filesystem, dropped capabilities, no new privileges. Oh-auth two point one with pixie — no shared bearer tokens, no API keys in headers.
|
||||||
|
|
||||||
|
Export the code anytime, or share it as a template. The code travels, your secrets stay.
|
||||||
|
|
||||||
|
One sentence in. Live server out.
|
||||||
|
|
||||||
|
BuildMyMCPServer dot com
|
||||||
Loading…
Reference in New Issue
Block a user