{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-timeline",
  "title": "Caption Timeline",
  "description": "The whole transcript laid out on a scrolling time strip that slides under a fixed playhead, so every pause shows up as space between words.",
  "dependencies": ["remotion", "@remotion/captions"],
  "registryDependencies": ["@remocn/caption-core"],
  "files": [
    {
      "path": "registry/remocn/caption-timeline/index.tsx",
      "content": "\"use client\";\n\nimport type { Caption } from \"@remotion/captions\";\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { interpolate, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport type { CaptionBaseProps } from \"@/lib/remocn/caption-core\";\n\nexport interface CaptionTimelineProps extends CaptionBaseProps {\n  width?: number;\n  pxPerSecond?: number;\n  playheadPosition?: number;\n  playheadColor?: string;\n  upcomingOpacity?: number;\n  shadow?: boolean;\n}\n\ninterface TimelineWord {\n  id: number;\n  text: string;\n  startMs: number;\n  endMs: number;\n}\n\nconst LINE_HEIGHT = 1.3;\nconst GAP_EM = 0.32;\nconst LIGHT_MS = 80;\nconst EDGE_FADE =\n  \"linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)\";\nconst LEGIBILITY_SHADOW =\n  \"0 2px 3px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.35)\";\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nexport function getTimelineWords(captions: Caption[]): TimelineWord[] {\n  const words: TimelineWord[] = [];\n  for (const caption of captions) {\n    if (caption.text.trim() === \"\") continue;\n    const last = words[words.length - 1];\n    if (last && !/^\\s/.test(caption.text)) {\n      last.text += caption.text;\n      last.endMs = caption.endMs;\n    } else {\n      words.push({\n        id: words.length,\n        text: caption.text.trim(),\n        startMs: caption.startMs,\n        endMs: caption.endMs,\n      });\n    }\n  }\n  return words;\n}\n\nexport function layoutTimeline(\n  words: TimelineWord[],\n  widths: number[],\n  pxPerSecond: number,\n  gap: number,\n): number[] {\n  const xs: number[] = [];\n  words.forEach((word, i) => {\n    const ideal = (word.startMs / 1000) * pxPerSecond;\n    xs.push(i === 0 ? ideal : Math.max(ideal, xs[i - 1] + widths[i - 1] + gap));\n  });\n  return xs;\n}\n\nexport function timelineOffset(\n  words: TimelineWord[],\n  xs: number[],\n  timeMs: number,\n  pxPerSecond: number,\n): number {\n  if (words.length === 0) return (timeMs / 1000) * pxPerSecond;\n  let i = -1;\n  while (i + 1 < words.length && words[i + 1].startMs <= timeMs) i++;\n  if (i === -1) {\n    return xs[0] - ((words[0].startMs - timeMs) / 1000) * pxPerSecond;\n  }\n  const next = words[i + 1];\n  if (!next) {\n    return xs[i] + ((timeMs - words[i].startMs) / 1000) * pxPerSecond;\n  }\n  const span = next.startMs - words[i].startMs;\n  const t = span > 0 ? (timeMs - words[i].startMs) / span : 1;\n  return xs[i] + (xs[i + 1] - xs[i]) * t;\n}\n\nfunction sameWidths(a: number[], b: number[]): boolean {\n  return a.length === b.length && a.every((w, i) => w === b[i]);\n}\n\nexport function CaptionTimeline({\n  captions,\n  fontSize = 64,\n  fontWeight = 800,\n  color = \"#ffffff\",\n  width = 1100,\n  pxPerSecond = 260,\n  playheadPosition = 0.35,\n  playheadColor = \"#facc15\",\n  upcomingOpacity = 0.4,\n  shadow = true,\n  className,\n}: CaptionTimelineProps) {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const timeMs = (frame / fps) * 1000;\n  const words = useMemo(() => getTimelineWords(captions), [captions]);\n  const elements = useRef<(HTMLSpanElement | null)[]>([]);\n  const [widths, setWidths] = useState<number[]>([]);\n\n  useLayoutEffect(() => {\n    const measured = words.map((_, i) => elements.current[i]?.offsetWidth ?? 0);\n    setWidths((prev) => (sameWidths(prev, measured) ? prev : measured));\n  });\n\n  const gap = fontSize * GAP_EM;\n  const resolved =\n    widths.length === words.length\n      ? widths\n      : words.map((word) => word.text.length * fontSize * 0.55);\n  const xs = layoutTimeline(words, resolved, pxPerSecond, gap);\n  const playheadX = width * playheadPosition;\n  const offset = timelineOffset(words, xs, timeMs, pxPerSecond);\n  const thickness = Math.max(2, Math.round(fontSize * 0.04));\n\n  if (words.length === 0) return null;\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"relative\",\n        width,\n        height: `${LINE_HEIGHT}em`,\n        fontSize,\n        fontWeight,\n        color,\n        lineHeight: LINE_HEIGHT,\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          overflow: \"hidden\",\n          maskImage: EDGE_FADE,\n          WebkitMaskImage: EDGE_FADE,\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            transform: `translateX(${playheadX - offset}px)`,\n          }}\n        >\n          {words.map((word, i) => (\n            <span\n              key={word.id}\n              ref={(el) => {\n                elements.current[i] = el;\n              }}\n              style={{\n                position: \"absolute\",\n                left: xs[i],\n                top: 0,\n                whiteSpace: \"nowrap\",\n                opacity: interpolate(\n                  timeMs - word.startMs,\n                  [-LIGHT_MS, 0],\n                  [upcomingOpacity, 1],\n                  clamp,\n                ),\n                textShadow: shadow ? LEGIBILITY_SHADOW : undefined,\n              }}\n            >\n              {word.text}\n            </span>\n          ))}\n        </div>\n      </div>\n      <div\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          top: \"8%\",\n          bottom: \"8%\",\n          left: playheadX - thickness / 2,\n          width: thickness,\n          borderRadius: thickness,\n          background: playheadColor,\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caption-timeline.tsx"
    }
  ],
  "type": "registry:component"
}
