{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-highlight-box",
  "title": "Caption Highlight Box",
  "description": "A solid box travels behind the word being spoken, sliding from word to word as the voice moves on.",
  "dependencies": ["remotion", "@remotion/captions"],
  "registryDependencies": ["@remocn/caption-core"],
  "files": [
    {
      "path": "registry/remocn/caption-highlight-box/index.tsx",
      "content": "\"use client\";\n\nimport { Fragment } from \"react\";\nimport { Easing, interpolate, interpolateColors } from \"remotion\";\nimport {\n  type CaptionBaseProps,\n  DEFAULT_HOLD_MS,\n  getExitWindowMs,\n  groupCaptionWords,\n  useCaptions,\n  useCaptionWordRects,\n} from \"@/lib/remocn/caption-core\";\n\nexport interface CaptionHighlightBoxProps extends CaptionBaseProps {\n  activeColor?: string;\n  boxColor?: string;\n  boxRadius?: number;\n  boxPadding?: number;\n  shadow?: boolean;\n}\n\nconst ENTER_MS = 160;\nconst EXIT_MS = 220;\nconst RISE_EM = 0.14;\nconst TRAVEL_MS = 120;\n\nconst enterEasing = Easing.bezier(0.2, 0.8, 0.2, 1);\nconst travelEasing = Easing.bezier(0.3, 0, 0.2, 1);\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nfunction legibilityShadow(strength: number): string | undefined {\n  if (strength <= 0) return undefined;\n  return `0 2px 3px rgba(0, 0, 0, ${0.5 * strength}), 0 4px 16px rgba(0, 0, 0, ${0.35 * strength})`;\n}\n\nfunction mix(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\nexport function CaptionHighlightBox({\n  captions,\n  combineTokensWithinMilliseconds = 1200,\n  holdMs = DEFAULT_HOLD_MS,\n  fontSize = 64,\n  fontWeight = 800,\n  color = \"#ffffff\",\n  activeColor = \"#0a0a0a\",\n  boxColor = \"#facc15\",\n  boxRadius = 0.16,\n  boxPadding = 0.14,\n  shadow = true,\n  className,\n}: CaptionHighlightBoxProps) {\n  const state = useCaptions({\n    captions,\n    combineTokensWithinMilliseconds,\n    holdMs,\n  });\n  const words = state ? groupCaptionWords(state.tokens) : [];\n  const pageIndex = state ? state.pageIndex : -1;\n  const { wordRef, rects } = useCaptionWordRects(pageIndex, words.length);\n\n  if (!state) return null;\n\n  const enter = interpolate(\n    state.previous ? ENTER_MS : state.sinceStartMs,\n    [0, ENTER_MS],\n    [0, 1],\n    {\n      ...clamp,\n      easing: enterEasing,\n    },\n  );\n  const exit = state.endsInSilence\n    ? interpolate(\n        state.untilEndMs,\n        [0, getExitWindowMs(state, EXIT_MS)],\n        [0, 1],\n        clamp,\n      )\n    : 1;\n\n  const activeWord = words.findIndex((word) =>\n    word.some(({ index }) => index === state.activeIndex),\n  );\n  const travel =\n    activeWord >= 0\n      ? interpolate(\n          state.timeMs - words[activeWord][0].token.fromMs,\n          [0, TRAVEL_MS],\n          [0, 1],\n          { ...clamp, easing: travelEasing },\n        )\n      : 0;\n\n  const target = rects && activeWord >= 0 ? rects[activeWord] : null;\n  const origin = rects && activeWord > 0 ? rects[activeWord - 1] : null;\n  const padX = boxPadding * fontSize;\n  const padY = boxPadding * 0.3 * fontSize;\n  const box = target\n    ? {\n        left: origin ? mix(origin.left, target.left, travel) : target.left,\n        top: origin ? mix(origin.top, target.top, travel) : target.top,\n        width: origin ? mix(origin.width, target.width, travel) : target.width,\n        height: origin\n          ? mix(origin.height, target.height, travel)\n          : target.height,\n        opacity: origin ? 1 : travel,\n      }\n    : null;\n\n  const coverage = (w: number): number => {\n    if (!box) return 0;\n    if (w === activeWord) {\n      return origin ? interpolate(travel, [0.3, 0.8], [0, 1], clamp) : travel;\n    }\n    if (origin && w === activeWord - 1) {\n      return 1 - interpolate(travel, [0.2, 0.7], [0, 1], clamp);\n    }\n    return 0;\n  };\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"relative\",\n        isolation: \"isolate\",\n        fontSize,\n        fontWeight,\n        color,\n        lineHeight: 1.2,\n        textAlign: \"center\",\n        opacity: Math.min(enter, exit),\n        transform: `translateY(${(1 - enter) * RISE_EM}em)`,\n      }}\n    >\n      {box && (\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            left: box.left - padX,\n            top: box.top - padY,\n            width: box.width + padX * 2,\n            height: box.height + padY * 2,\n            borderRadius: boxRadius * fontSize,\n            background: boxColor,\n            opacity: box.opacity,\n          }}\n        />\n      )}\n      {words.map((word, w) => {\n        const covered = coverage(w);\n        return (\n          <Fragment key={`${state.pageIndex}-${word[0].index}`}>\n            {w > 0 && \" \"}\n            <span\n              ref={wordRef(w)}\n              style={{\n                position: \"relative\",\n                display: \"inline-block\",\n                whiteSpace: \"nowrap\",\n                color: interpolateColors(covered, [0, 1], [color, activeColor]),\n                textShadow: legibilityShadow(shadow ? 1 - covered : 0),\n              }}\n            >\n              {word.map(({ index, token }) => (\n                <span key={index}>{token.text}</span>\n              ))}\n            </span>\n          </Fragment>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caption-highlight-box.tsx"
    }
  ],
  "type": "registry:component"
}
