{
  "name": "caption-bumper",
  "type": "registry:block",
  "description": "Between-beats card that punches its line in word by word, draws a rule under it, and wipes out when its hold is over",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "motion-tokens",
    "text-fit-utils"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/caption-bumper/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { CODE_THEMES } from \"@/remotion/lib/code-syntax\";\nimport { getSafeAreaPadding } from \"@/remotion/lib/layout\";\nimport { EASING } from \"@/remotion/lib/motion-tokens\";\nimport { fitHeadline } from \"@/remotion/lib/text-fit-utils\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type CaptionBumperProps = {\n  /** The line the bumper exists to land. */\n  text: string;\n  /** Small label above it — segment, chapter, timestamp. */\n  eyebrow?: string;\n  /** Largest type size at a 1280-wide stage. */\n  maxFontSize?: number;\n  /**\n   * Seconds the line holds before the bumper wipes out. Omit to hold to the end\n   * of the composition.\n   */\n  holdSeconds?: number;\n  backgroundColor?: string;\n  accentColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\n/** Beat plan in seconds. */\nconst T = {\n  /** Ground wipes in behind the line. */\n  ground: 0,\n  groundFor: 0.4,\n  eyebrow: 0.16,\n  word: 0.3,\n  /** Added per word — capped so long copy still finishes inside `wordsBudget`. */\n  wordStagger: 0.055,\n  /**\n   * Total span every word must have started landing within, regardless of\n   * word count. Without this, long copy (or a 2x-length stress test) staggers\n   * so far past the beat that later words are still mid-slide, clipped by\n   * their own reveal mask, when the line is supposed to be fully readable —\n   * the clipped fragment reads as a ghost overlapping the settled words.\n   */\n  wordsBudget: 0.7,\n  /** Rule draws under the line once the words have landed. */\n  rule: 0.62,\n  exitFor: 0.36,\n} as const;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n/**\n * A bumper that punches its line in rather than fading a sentence up: the\n * ground wipes in, the words land one after another, a rule draws under them,\n * and the whole card wipes out again when its hold is over — the shape a\n * between-beats card actually needs.\n */\nexport const CaptionBumper: React.FC<CaptionBumperProps> = ({\n  text,\n  eyebrow,\n  maxFontSize = 84,\n  holdSeconds,\n  backgroundColor,\n  accentColor = \"#F472B6\",\n  theme = \"dark\",\n  speed = 1,\n}) => {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const palette = CODE_THEMES[theme];\n  const safe = getSafeAreaPadding({ width, height });\n\n  const at = (seconds: number) => (seconds * fps) / speed;\n  const ease = (from: number, to: number, easing = EASING.enter) =>\n    interpolate(frame, [at(from), at(to)], [0, 1], { easing, ...clamp });\n\n  const stage = {\n    w: width - safe.paddingLeft - safe.paddingRight,\n    h: height - safe.paddingTop - safe.paddingBottom,\n  };\n  const portrait = height > width;\n  const u = portrait\n    ? Math.min(stage.w / 620, stage.h / 1120)\n    : Math.min(stage.w / 1120, stage.h / 620);\n\n  const words = text.split(/\\s+/).filter(Boolean);\n  const fontSize = fitHeadline({\n    text,\n    maxWidth: stage.w,\n    maxFontSize: maxFontSize * u,\n    minFontSize: 30 * u,\n  });\n\n  const ground = ease(T.ground, T.ground + T.groundFor, EASING.editorial);\n  const eyebrowIn = eyebrow ? ease(T.eyebrow, T.eyebrow + 0.4) : 0;\n  // Longer copy gets a tighter per-word stagger so the whole line always\n  // finishes landing inside `wordsBudget`, instead of the reveal stretching\n  // past the beat and leaving trailing words visibly mid-clip at the hold.\n  const wordStagger =\n    words.length > 1\n      ? Math.min(T.wordStagger, T.wordsBudget / (words.length - 1))\n      : 0;\n  const lastWord = T.word + (words.length - 1) * wordStagger + 0.4;\n  const rule = ease(Math.max(T.rule, lastWord - 0.1), lastWord + 0.4, EASING.editorial);\n  const exit =\n    holdSeconds === undefined\n      ? 0\n      : interpolate(\n          frame,\n          [at(holdSeconds), at(holdSeconds + T.exitFor)],\n          [0, 1],\n          { easing: EASING.exit, ...clamp },\n        );\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        background: backgroundColor ?? palette.page,\n        fontFamily,\n        position: \"relative\",\n        overflow: \"hidden\",\n        display: \"grid\",\n        placeItems: \"center\",\n      }}\n    >\n      {/* Ground: a tinted field that wipes in from the centre out */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `radial-gradient(ellipse 70% 55% at 50% 50%, ${accentColor}22, transparent 72%)`,\n          clipPath: `inset(${(1 - ground) * 50}% 0 ${(1 - ground) * 50}% 0)`,\n          opacity: 1 - exit,\n        }}\n      />\n\n      <div\n        style={{\n          position: \"relative\",\n          width: stage.w,\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          gap: 16 * u,\n          textAlign: \"center\",\n          opacity: 1 - exit,\n          transform: `translateY(${exit * -22 * u}px)`,\n        }}\n      >\n        {eyebrow ? (\n          <div\n            style={{\n              color: accentColor,\n              fontSize: 22 * u,\n              fontWeight: 600,\n              letterSpacing: \"0.16em\",\n              textTransform: \"uppercase\",\n              opacity: eyebrowIn,\n              transform: `translateY(${(1 - eyebrowIn) * 10 * u}px)`,\n            }}\n          >\n            {eyebrow}\n          </div>\n        ) : null}\n\n        <div\n          style={{\n            display: \"flex\",\n            flexWrap: \"wrap\",\n            justifyContent: \"center\",\n            gap: `${0.1 * fontSize}px ${0.28 * fontSize}px`,\n            color: palette.fg,\n            fontSize,\n            fontWeight: 700,\n            lineHeight: 1.06,\n            letterSpacing: \"-0.03em\",\n          }}\n        >\n          {words.map((word, index) => {\n            const wordIn = ease(\n              T.word + index * wordStagger,\n              T.word + index * wordStagger + 0.4,\n            );\n            return (\n              // Each word lands out of its own mask, so the line reads as\n              // spoken rather than as one block appearing.\n              <span\n                key={`${word}-${index}`}\n                style={{ display: \"block\", overflow: \"hidden\" }}\n              >\n                <span\n                  style={{\n                    display: \"block\",\n                    transform: `translateY(${(1 - wordIn) * 100}%)`,\n                  }}\n                >\n                  {word}\n                </span>\n              </span>\n            );\n          })}\n        </div>\n\n        <div\n          style={{\n            width: fontSize * 2.2,\n            height: 4 * u,\n            borderRadius: 999,\n            background: accentColor,\n            boxShadow: `0 0 ${16 * u}px ${accentColor}88`,\n            transformOrigin: \"center\",\n            transform: `scaleX(${rule})`,\n          }}\n        />\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "data",
    "tier": "advanced"
  }
}