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 && (
)}
);
}
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)
);
}