{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-knockout",
  "title": "Caption Knockout",
  "description": "A solid plate with the words cut clean through it, each word punching out the moment it is spoken so the footage shows through the letters.",
  "dependencies": ["remotion", "@remotion/captions"],
  "registryDependencies": ["@remocn/caption-core"],
  "files": [
    {
      "path": "registry/remocn/caption-knockout/index.tsx",
      "content": "\"use client\";\n\nimport { Fragment, useId } from \"react\";\nimport { Easing, interpolate } from \"remotion\";\nimport {\n  type CaptionBaseProps,\n  type CaptionTokenState,\n  DEFAULT_HOLD_MS,\n  getExitWindowMs,\n  groupCaptionWords,\n  useCaptions,\n  useCaptionWordRects,\n} from \"@/lib/remocn/caption-core\";\n\nexport interface CaptionKnockoutProps extends Omit<CaptionBaseProps, \"color\"> {\n  plateColor?: string;\n  upcomingOpacity?: number;\n  radius?: number;\n}\n\nconst ENTER_MS = 160;\nconst EXIT_MS = 220;\nconst RISE_EM = 0.14;\nconst PUNCH_MS = 120;\nconst PAD_Y_EM = 0.22;\nconst PAD_X_EM = 0.42;\n\nconst enterEasing = Easing.bezier(0.2, 0.8, 0.2, 1);\nconst punchEasing = Easing.bezier(0.2, 0.8, 0.2, 1);\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nfunction holeFill(\n  token: CaptionTokenState,\n  timeMs: number,\n  upcomingOpacity: number,\n): string {\n  const since = timeMs - token.fromMs;\n  const hole =\n    since < 0\n      ? upcomingOpacity\n      : interpolate(since, [0, PUNCH_MS], [upcomingOpacity, 1], {\n          ...clamp,\n          easing: punchEasing,\n        });\n  const level = Math.round(255 * (1 - Math.min(1, Math.max(0, hole))));\n  return `rgb(${level}, ${level}, ${level})`;\n}\n\nexport function CaptionKnockout({\n  captions,\n  combineTokensWithinMilliseconds = 1200,\n  holdMs = DEFAULT_HOLD_MS,\n  fontSize = 64,\n  fontWeight = 800,\n  plateColor = \"#ffffff\",\n  upcomingOpacity = 0.15,\n  radius = 0.16,\n  className,\n}: CaptionKnockoutProps) {\n  const maskId = `caption-knockout-${useId().replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n  const state = useCaptions({\n    captions,\n    combineTokensWithinMilliseconds,\n    holdMs,\n  });\n  const words = state ? groupCaptionWords(state.tokens) : [];\n  const { wordRef, rects } = useCaptionWordRects(\n    state ? state.pageIndex : -1,\n    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    { ...clamp, easing: enterEasing },\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  return (\n    <div\n      className={className}\n      style={{\n        position: \"relative\",\n        fontSize,\n        fontWeight,\n        lineHeight: 1.2,\n        textAlign: \"center\",\n        padding: `${PAD_Y_EM}em ${PAD_X_EM}em`,\n        opacity: Math.min(enter, exit),\n        transform: `translateY(${(1 - enter) * RISE_EM}em)`,\n      }}\n    >\n      {rects && (\n        <svg\n          aria-hidden\n          width=\"100%\"\n          height=\"100%\"\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            width: \"100%\",\n            height: \"100%\",\n            overflow: \"visible\",\n          }}\n        >\n          <defs>\n            <mask\n              id={maskId}\n              maskUnits=\"userSpaceOnUse\"\n              x=\"0\"\n              y=\"0\"\n              width=\"100%\"\n              height=\"100%\"\n            >\n              <rect width=\"100%\" height=\"100%\" fill=\"#ffffff\" />\n              {words.map((word, w) => {\n                const rect = rects[w];\n                if (!rect) return null;\n                return (\n                  <text\n                    key={`${state.pageIndex}-${word[0].index}`}\n                    x={rect.left}\n                    y={rect.top + rect.height / 2}\n                    dominantBaseline=\"central\"\n                  >\n                    {word.map(({ index, token }) => (\n                      <tspan\n                        key={index}\n                        fill={holeFill(token, state.timeMs, upcomingOpacity)}\n                      >\n                        {token.text}\n                      </tspan>\n                    ))}\n                  </text>\n                );\n              })}\n            </mask>\n          </defs>\n          <rect\n            width=\"100%\"\n            height=\"100%\"\n            rx={radius * fontSize}\n            fill={plateColor}\n            mask={`url(#${maskId})`}\n          />\n        </svg>\n      )}\n      {words.map((word, w) => (\n        <Fragment key={`${state.pageIndex}-${word[0].index}`}>\n          {w > 0 && \" \"}\n          <span\n            ref={wordRef(w)}\n            style={{\n              display: \"inline-block\",\n              whiteSpace: \"nowrap\",\n              color: \"transparent\",\n            }}\n          >\n            {word.map(({ index, token }) => (\n              <span key={index}>{token.text}</span>\n            ))}\n          </span>\n        </Fragment>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caption-knockout.tsx"
    }
  ],
  "type": "registry:component"
}
