{
  "name": "karaoke-captions",
  "type": "registry:ui",
  "description": "Active-word karaoke caption styling",
  "dependencies": [
    "remotion",
    "@remotion/captions"
  ],
  "registryDependencies": [
    "caption-utils",
    "layout",
    "motion-tokens"
  ],
  "files": [
    {
      "path": "registry/bases/default/primitives/karaoke-captions.tsx",
      "type": "registry:ui",
      "content": "import type { TikTokPage } from \"@remotion/captions\";\nimport { Fragment } from \"react\";\nimport {\n  interpolate,\n  interpolateColors,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  getAbsoluteTimeMs,\n  getTokenEmphasis,\n  isTokenActive,\n} from \"@/remotion/lib/caption-utils\";\nimport { scaleFont } from \"@/remotion/lib/layout\";\nimport { EASING, EMPHASIS } from \"@/remotion/lib/motion-tokens\";\n\nexport type KaraokeCaptionMode = \"scale\" | \"underline\";\n\nexport type KaraokeCaptionsProps = {\n  page: TikTokPage;\n  activeColor?: string;\n  completedColor?: string;\n  inactiveColor?: string;\n  fontSize?: number;\n  fontWeight?: number | string;\n  mode?: KaraokeCaptionMode;\n  /** Peak scale of the active word. Defaults to `EMPHASIS.subtle`. */\n  emphasisScale?: number;\n  /** Underline track behind the wipe. Defaults to `inactiveColor`. */\n  trackColor?: string;\n  /**\n   * Optional frame override.\n   * Pass a parent `frame` when using inside `<Sequence from={...}>`.\n   */\n  frame?: number;\n};\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\n/**\n * Caption tokens carry their own leading space. It has to sit outside the\n * scaled box — a word that grows inside its own space closes the gap to the\n * word before it.\n */\nfunction splitLeadingSpace(text: string) {\n  const leading = /^\\s+/.exec(text)?.[0] ?? \"\";\n  return { leading, word: text.slice(leading.length) };\n}\n\nexport const KaraokeCaptions: React.FC<KaraokeCaptionsProps> = ({\n  page,\n  activeColor = \"#ff6b00\",\n  completedColor = \"#111111\",\n  inactiveColor = \"rgba(17, 17, 17, 0.32)\",\n  fontSize: fontSizeProp,\n  fontWeight = 800,\n  mode = \"underline\",\n  emphasisScale = EMPHASIS.subtle,\n  trackColor,\n  frame: frameOverride,\n}) => {\n  const localFrame = useCurrentFrame();\n  const frame = frameOverride ?? localFrame;\n  const { fps, width } = useVideoConfig();\n  const fontSize = fontSizeProp ?? scaleFont(66, width);\n  const absoluteTimeMs = getAbsoluteTimeMs(page, frame, fps);\n\n  return (\n    <div\n      style={{\n        color: inactiveColor,\n        fontSize,\n        fontWeight,\n        letterSpacing: 0,\n        // The active word grows into the gaps on both sides, so the resting\n        // rhythm has to be wider than a bare space character.\n        wordSpacing: \"0.12em\",\n        lineHeight: 1.18,\n        textAlign: \"center\",\n        whiteSpace: \"pre-wrap\",\n      }}\n    >\n      {page.tokens.map((token) => {\n        const active = isTokenActive(token, absoluteTimeMs);\n        const completed = token.toMs <= absoluteTimeMs;\n        const emphasis = clamp01(getTokenEmphasis(frame, token, page, fps));\n        const durationMs = Math.max(1, token.toMs - token.fromMs);\n        const wipe = interpolate(\n          active\n            ? clamp01((absoluteTimeMs - token.fromMs) / durationMs)\n            : completed\n              ? 1\n              : 0,\n          [0, 1],\n          [0, 1],\n          {\n            extrapolateLeft: \"clamp\",\n            extrapolateRight: \"clamp\",\n            easing: EASING.enter,\n          },\n        );\n\n        // Colour, size and lift all ride the same emphasis ramp, so a word\n        // arrives as one gesture instead of recolouring a frame before it pops.\n        const restColor = completed ? completedColor : inactiveColor;\n        const color = interpolateColors(\n          emphasis,\n          [0, 1],\n          [restColor, activeColor],\n        );\n        const scale = interpolate(emphasis, [0, 1], [1, emphasisScale], {\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n          easing: EASING.pop,\n        });\n\n        const { leading, word } = splitLeadingSpace(token.text);\n\n        return (\n          <Fragment key={`${token.fromMs}-${token.text}`}>\n            {leading}\n            <span\n              style={{\n                color,\n                display: \"inline-block\",\n                position: \"relative\",\n                // `scale`/`translate` shorthands keep the keyframes editable in\n                // Remotion Studio.\n                scale,\n                translate: `0 ${(-0.03 * emphasis).toFixed(4)}em`,\n                transformOrigin: \"center bottom\",\n              }}\n            >\n            {word}\n            {mode === \"underline\" && emphasis > 0 ? (\n              <span\n                style={{\n                  position: \"absolute\",\n                  right: 0,\n                  bottom: \"-0.14em\",\n                  left: 0,\n                  height: \"0.075em\",\n                  overflow: \"hidden\",\n                  borderRadius: 999,\n                  background: trackColor ?? inactiveColor,\n                  opacity: emphasis,\n                }}\n              >\n                <span\n                  style={{\n                    display: \"block\",\n                    width: `${wipe * 100}%`,\n                    height: \"100%\",\n                    borderRadius: 999,\n                    background: activeColor,\n                  }}\n                />\n                </span>\n              ) : null}\n            </span>\n          </Fragment>\n        );\n      })}\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "signals",
    "drive": "data",
    "tier": "advanced",
    "tags": [
      "captions"
    ]
  }
}