{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-teleprompter",
  "title": "Caption Teleprompter",
  "description": "The whole transcript scrolls smoothly through a short window with the current line centred, past lines fading above and the next line waiting below.",
  "dependencies": ["remotion", "@remotion/captions"],
  "registryDependencies": ["@remocn/caption-core"],
  "files": [
    {
      "path": "registry/remocn/caption-teleprompter/index.tsx",
      "content": "\"use client\";\n\nimport type { TikTokPage } from \"@remotion/captions\";\nimport { Fragment, useMemo } from \"react\";\nimport { Easing, interpolate, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport {\n  buildCaptionPages,\n  type CaptionBaseProps,\n  DEFAULT_HOLD_MS,\n  useCaptionWordRects,\n} from \"@/lib/remocn/caption-core\";\n\nexport interface CaptionTeleprompterProps extends CaptionBaseProps {\n  visibleLines?: number;\n  pastOpacity?: number;\n  futureOpacity?: number;\n  upcomingOpacity?: number;\n  shadow?: boolean;\n}\n\ninterface PrompterWord {\n  key: number;\n  text: string;\n  fromMs: number;\n}\n\nconst LINE_HEIGHT = 1.3;\nconst LINE_GAP_EM = 0.2;\nconst SCROLL_MS = 320;\nconst LIGHT_MS = 80;\nconst EDGE_FADE_LINES = 0.66;\n\nfunction edgeFade(visibleLines: number): string | undefined {\n  const fadeLines = Math.min(\n    EDGE_FADE_LINES,\n    Math.max(0, visibleLines - 1) / 3,\n  );\n  if (fadeLines <= 0) return undefined;\n  const pct = (fadeLines / visibleLines) * 100;\n  return `linear-gradient(to bottom, transparent, #000 ${pct}%, #000 ${100 - pct}%, transparent)`;\n}\nconst LEGIBILITY_SHADOW =\n  \"0 2px 3px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.35)\";\n\nconst scrollEasing = Easing.bezier(0.45, 0, 0.2, 1);\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nfunction lineWords(page: TikTokPage): PrompterWord[] {\n  const words: PrompterWord[] = [];\n  page.tokens.forEach((token, i) => {\n    const last = words[words.length - 1];\n    if (last && !/^\\s/.test(token.text)) {\n      last.text += token.text;\n    } else {\n      words.push({ key: i, text: token.text.trim(), fromMs: token.fromMs });\n    }\n  });\n  return words;\n}\n\nexport function CaptionTeleprompter({\n  captions,\n  combineTokensWithinMilliseconds = 1500,\n  holdMs = DEFAULT_HOLD_MS,\n  fontSize = 64,\n  fontWeight = 800,\n  color = \"#ffffff\",\n  visibleLines = 3,\n  pastOpacity = 0.35,\n  futureOpacity = 0.35,\n  upcomingOpacity = 0.55,\n  shadow = true,\n  className,\n}: CaptionTeleprompterProps) {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const timeMs = (frame / fps) * 1000;\n  const lines = useMemo(\n    () =>\n      buildCaptionPages(captions, {\n        combineTokensWithinMilliseconds,\n        holdMs,\n      }).map((line) => ({ ...line, words: lineWords(line.page) })),\n    [captions, combineTokensWithinMilliseconds, holdMs],\n  );\n  const { wordRef: lineRef, rects } = useCaptionWordRects(0, lines.length);\n\n  const step = (LINE_HEIGHT + LINE_GAP_EM) * fontSize;\n  const windowHeight = visibleLines * step;\n  const center = (i: number): number => {\n    const rect = rects?.[i];\n    return rect\n      ? rect.top + rect.height / 2\n      : i * step + (LINE_HEIGHT * fontSize) / 2;\n  };\n\n  const scrollMsAt = (i: number): number => {\n    const before =\n      i > 0\n        ? lines[i].startMs - lines[i - 1].startMs\n        : Number.POSITIVE_INFINITY;\n    const after =\n      i < lines.length - 1\n        ? lines[i + 1].startMs - lines[i].startMs\n        : Number.POSITIVE_INFINITY;\n    return Math.max(1, Math.min(SCROLL_MS, before, after));\n  };\n  let k = -1;\n  lines.forEach((line, i) => {\n    if (line.startMs - scrollMsAt(i) / 2 <= timeMs) k = i;\n  });\n  const scrollMs = k > 0 ? scrollMsAt(k) : SCROLL_MS;\n  const progress =\n    k <= 0\n      ? 1\n      : interpolate(\n          timeMs - (lines[k].startMs - scrollMs / 2),\n          [0, scrollMs],\n          [0, 1],\n          { ...clamp, easing: scrollEasing },\n        );\n  const focus = Math.max(k, 0);\n  const centerY =\n    lines.length === 0\n      ? 0\n      : k <= 0\n        ? center(0)\n        : center(k - 1) + (center(k) - center(k - 1)) * progress;\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"relative\",\n        height: windowHeight,\n        fontSize,\n        fontWeight,\n        color,\n        textAlign: \"center\",\n        overflow: \"hidden\",\n        maskImage: edgeFade(visibleLines),\n        WebkitMaskImage: edgeFade(visibleLines),\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          right: 0,\n          top: 0,\n          transform: `translateY(${windowHeight / 2 - centerY}px)`,\n        }}\n      >\n        {lines.map((line, i) => {\n          const weight =\n            i === focus ? progress : i === focus - 1 ? 1 - progress : 0;\n          const rest = i < focus ? pastOpacity : futureOpacity;\n          return (\n            <div\n              key={line.index}\n              ref={lineRef(i)}\n              style={{\n                lineHeight: LINE_HEIGHT,\n                marginBottom: `${LINE_GAP_EM}em`,\n                textShadow: shadow ? LEGIBILITY_SHADOW : undefined,\n              }}\n            >\n              {line.words.map((word, w) => {\n                const lit = interpolate(\n                  timeMs - word.fromMs,\n                  [-LIGHT_MS, 0],\n                  [upcomingOpacity, 1],\n                  clamp,\n                );\n                return (\n                  <Fragment key={word.key}>\n                    {w > 0 && \" \"}\n                    <span style={{ opacity: rest + (lit - rest) * weight }}>\n                      {word.text}\n                    </span>\n                  </Fragment>\n                );\n              })}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caption-teleprompter.tsx"
    }
  ],
  "type": "registry:component"
}
