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) => , }, { 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) => , }, { 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) => , }, { 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) => , }, ]; 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 (
{/* Top label */}
the plumbing nobody talks about
{/* Resolved-row label, appears when icons land in clean row */}
what if one prompt handled all of it
{/* 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. */} {/* Tangle SVG (dotted lines between icons) */} {/* 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 (
{icon.draw(iconColor)} {/* Label below */}
0.5 ? C.fgMuted : C.fgSubtle, }} > {icon.label}
); })} {/* Hand-off cursor icon — slides in from the right at the end */} {handoffProgress > 0.02 && (
one prompt
)}
); } function IconCard({ children, color, glow }: { children: React.ReactNode; color: string; glow?: boolean }) { return (
{children}
); } 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 ( {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 ( ); })} ); } // ---- Icons ---- function ServerStackIcon({ color }: { color: string }) { return ( ); } function KeyIcon({ color }: { color: string }) { return ( ); } function ContainerIcon({ color }: { color: string }) { return ( ); } function LockBigIcon({ color }: { color: string }) { return ( ); } function PromptCursorIcon({ color }: { color: string }) { return ( ); } // =============== 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: () => , }, { id: 'oauth', appearAt: 100, x: 1450, y: 220, rotate: 2.4, render: () => , }, { id: 'env', appearAt: 135, x: 1330, y: 740, rotate: -2.6, render: () => , }, { id: 'error', appearAt: 175, x: 170, y: 750, rotate: 3.2, render: () => , }, ]; return (
{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 (
0.4 ? `blur(${collapse * 1.5}px)` : 'none', }} > {card.render()}
); })}
); } // ---- 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 (
{label && (
{label}
)}
{children}
); } function ConfigCard() { return (
{`services:
  nginx:
    image: nginx:alpine
    ports: ["443:443"]
    volumes:
      - ./certs:/etc/ssl
      - ./conf.d:/etc/nginx`}
      
); } function OAuthCodeCard() { return (
{`app.use(passport.authenticate(
  'oauth2', {
    clientID: process.env.CID,
    clientSecret: process.env.CS,
    callbackURL: '/cb',
  }
));`}
      
); } function EnvLeakCard() { // The squiggle hints at "this got committed to git by accident" — // a recognisable plumbing failure mode. return (
STRIPE_KEY=sk_live_•••
NOTION_API_KEY=secret_3a8f
⚠ in git history since v0.3.1
); } 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 (
!
502 Bad Gateway
upstream timed out · retrying ({retries}/8)
); }