{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-stack",
  "title": "Caption Stack",
  "description": "Kinetic captions stacked a couple of words per line, with the keyword of each page set large on its own line.",
  "dependencies": ["remotion", "@remotion/captions"],
  "registryDependencies": ["@remocn/caption-core"],
  "files": [
    {
      "path": "registry/remocn/caption-stack/index.tsx",
      "content": "\"use client\";\n\nimport { Fragment } from \"react\";\nimport { Easing, interpolate } from \"remotion\";\nimport {\n  type CaptionBaseProps,\n  captionWordText,\n  createKeywordMatcher,\n  DEFAULT_HOLD_MS,\n  getExitWindowMs,\n  groupCaptionWords,\n  normalizeCaptionWord,\n  useCaptions,\n} from \"@/lib/remocn/caption-core\";\n\nexport interface CaptionStackProps extends CaptionBaseProps {\n  keywords?: string[];\n  emphasisScale?: number;\n  emphasisColor?: string;\n  shadow?: boolean;\n}\n\ntype CaptionWord = ReturnType<typeof groupCaptionWords>[number];\n\ninterface StackLine {\n  words: CaptionWord[];\n  emphasis: boolean;\n}\n\nconst WORDS_PER_LINE = 2;\nconst EXIT_MS = 220;\nconst FADE_IN_MS = 120;\nconst RISE_MS = 220;\nconst RISE_EM = 0.3;\nconst LEGIBILITY_SHADOW =\n  \"0 2px 3px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.35)\";\n\nconst riseEasing = Easing.bezier(0.2, 0.8, 0.2, 1);\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nfunction buildLines(\n  words: CaptionWord[],\n  isEmphasis: (word: CaptionWord, index: number) => boolean,\n): StackLine[] {\n  const lines: StackLine[] = [];\n  let current: CaptionWord[] = [];\n  const flush = () => {\n    if (current.length > 0) {\n      lines.push({ words: current, emphasis: false });\n      current = [];\n    }\n  };\n  words.forEach((word, i) => {\n    if (isEmphasis(word, i)) {\n      flush();\n      lines.push({ words: [word], emphasis: true });\n      return;\n    }\n    current.push(word);\n    if (current.length >= WORDS_PER_LINE) flush();\n  });\n  flush();\n  return lines;\n}\n\nfunction longestWordIndex(words: CaptionWord[]): number {\n  let longest = -1;\n  let longestLength = 0;\n  words.forEach((word, i) => {\n    const length = normalizeCaptionWord(captionWordText(word)).length;\n    if (length > longestLength) {\n      longest = i;\n      longestLength = length;\n    }\n  });\n  return longest;\n}\n\nexport function CaptionStack({\n  captions,\n  combineTokensWithinMilliseconds = 1600,\n  holdMs = DEFAULT_HOLD_MS,\n  fontSize = 64,\n  fontWeight = 800,\n  color = \"#ffffff\",\n  keywords,\n  emphasisScale = 1.7,\n  emphasisColor = \"#facc15\",\n  shadow = true,\n  className,\n}: CaptionStackProps) {\n  const state = useCaptions({\n    captions,\n    combineTokensWithinMilliseconds,\n    holdMs,\n  });\n  if (!state) return null;\n\n  const words = groupCaptionWords(state.tokens);\n  const hasKeywords = keywords !== undefined && keywords.length > 0;\n  const isKeyword = createKeywordMatcher(keywords);\n  const longest = hasKeywords ? -1 : longestWordIndex(words);\n  const lines = buildLines(words, (word, i) =>\n    hasKeywords ? isKeyword(captionWordText(word)) : i === longest,\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  return (\n    <div\n      className={className}\n      style={{\n        fontSize,\n        fontWeight,\n        color,\n        lineHeight: 1,\n        textAlign: \"center\",\n        opacity: exit,\n      }}\n    >\n      {lines.map((line) => (\n        <div\n          key={`${state.pageIndex}-${line.words[0][0].index}`}\n          style={{\n            fontSize: line.emphasis ? `${emphasisScale}em` : undefined,\n            color: line.emphasis ? emphasisColor : undefined,\n          }}\n        >\n          {line.words.map((word, w) => {\n            const sinceMs = state.timeMs - word[0].token.fromMs;\n            const arrived = sinceMs >= 0;\n            const rise = arrived\n              ? interpolate(sinceMs, [0, RISE_MS], [0, 1], {\n                  ...clamp,\n                  easing: riseEasing,\n                })\n              : 0;\n            const opacity = arrived\n              ? interpolate(sinceMs, [0, FADE_IN_MS], [0, 1], clamp)\n              : 0;\n            return (\n              <Fragment key={word[0].index}>\n                {w > 0 && \" \"}\n                <span\n                  style={{\n                    display: \"inline-block\",\n                    whiteSpace: \"nowrap\",\n                    opacity,\n                    transform: `translateY(${(1 - rise) * RISE_EM}em)`,\n                    textShadow: shadow ? LEGIBILITY_SHADOW : undefined,\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    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caption-stack.tsx"
    }
  ],
  "type": "registry:component"
}
