{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-core",
  "title": "Caption Core",
  "description": "The timing engine every caption style shares: pages a timed transcript, finds the word being spoken and how far into it the voice is, and decides when a page leaves the screen.",
  "dependencies": ["remotion", "@remotion/captions"],
  "files": [
    {
      "path": "registry/remocn/caption-core/index.ts",
      "content": "import {\n  type Caption,\n  createTikTokStyleCaptions,\n  type TikTokPage,\n} from \"@remotion/captions\";\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { useCurrentFrame, useVideoConfig } from \"remotion\";\n\nexport type SpeakerCaption = Caption & { speaker?: string };\n\nexport interface CaptionBaseProps {\n  captions: Caption[];\n  combineTokensWithinMilliseconds?: number;\n  holdMs?: number;\n  fontSize?: number;\n  fontWeight?: number;\n  color?: string;\n  className?: string;\n}\n\nexport const DEFAULT_HOLD_MS = 600;\nexport const MIN_SILENCE_BREAK_MS = 300;\n\nexport interface CaptionPageWindow {\n  page: TikTokPage;\n  index: number;\n  speaker: string | undefined;\n  startMs: number;\n  speechEndMs: number;\n  endMs: number;\n  endsInSilence: boolean;\n}\n\nexport type CaptionTokenStatus = \"upcoming\" | \"active\" | \"spoken\";\n\nexport interface CaptionTokenState {\n  text: string;\n  spaceBefore: boolean;\n  fromMs: number;\n  toMs: number;\n  progress: number;\n  status: CaptionTokenStatus;\n}\n\nexport interface CaptionPreviousPage {\n  page: TikTokPage;\n  pageIndex: number;\n  tokens: CaptionTokenState[];\n}\n\nexport interface CaptionsState {\n  page: TikTokPage;\n  pageIndex: number;\n  speaker: string | undefined;\n  timeMs: number;\n  activeIndex: number;\n  tokens: CaptionTokenState[];\n  sinceStartMs: number;\n  untilEndMs: number;\n  holdRoomMs: number;\n  endsInSilence: boolean;\n  previous: CaptionPreviousPage | null;\n}\n\nexport interface CaptionPagingOptions {\n  combineTokensWithinMilliseconds: number;\n  holdMs?: number;\n}\n\nconst SENTENCE_END = /[.!?…][\"')\\]]*$/;\n\nfunction markPageBreaks(captions: SpeakerCaption[]): Caption[] {\n  return captions.map((caption, i) => {\n    if (caption.pageBreakAfter !== undefined) return caption;\n    const next = captions[i + 1];\n    const speakerChanges =\n      next !== undefined && next.speaker !== caption.speaker;\n    return speakerChanges || SENTENCE_END.test(caption.text.trim())\n      ? { ...caption, pageBreakAfter: true }\n      : caption;\n  });\n}\n\nexport function buildCaptionPages(\n  captions: SpeakerCaption[],\n  {\n    combineTokensWithinMilliseconds,\n    holdMs = DEFAULT_HOLD_MS,\n  }: CaptionPagingOptions,\n): CaptionPageWindow[] {\n  if (captions.length === 0) return [];\n  const { pages } = createTikTokStyleCaptions({\n    captions: markPageBreaks(captions),\n    combineTokensWithinMilliseconds,\n    breakOnSilenceAfterMilliseconds: Math.max(holdMs, MIN_SILENCE_BREAK_MS),\n  });\n  const speakers = new Map<number, string | undefined>();\n  for (const caption of captions) {\n    if (!speakers.has(caption.startMs)) {\n      speakers.set(caption.startMs, caption.speaker);\n    }\n  }\n  const spoken = pages.filter((page) => page.tokens.length > 0);\n  return spoken.map((page, index) => {\n    const last = page.tokens[page.tokens.length - 1];\n    const speechEndMs = last ? last.toMs : page.startMs;\n    const heldUntil = speechEndMs + Math.max(0, holdMs);\n    const next = spoken[index + 1];\n    const endsInSilence = !next || next.startMs > heldUntil;\n    const endMs = endsInSilence\n      ? heldUntil\n      : Math.max(page.startMs, next.startMs);\n    return {\n      page,\n      index,\n      speaker: speakers.get(page.tokens[0].fromMs),\n      startMs: page.startMs,\n      speechEndMs,\n      endMs,\n      endsInSilence,\n    };\n  });\n}\n\nfunction findWindowIndex(windows: CaptionPageWindow[], timeMs: number): number {\n  let lo = 0;\n  let hi = windows.length - 1;\n  let found = -1;\n  while (lo <= hi) {\n    const mid = (lo + hi) >> 1;\n    if (windows[mid].startMs <= timeMs) {\n      found = mid;\n      lo = mid + 1;\n    } else {\n      hi = mid - 1;\n    }\n  }\n  if (found === -1) return -1;\n  return timeMs < windows[found].endMs ? found : -1;\n}\n\nfunction tokenProgress(fromMs: number, toMs: number, timeMs: number): number {\n  if (timeMs < fromMs) return 0;\n  if (toMs <= fromMs) return 1;\n  return Math.min(1, (timeMs - fromMs) / (toMs - fromMs));\n}\n\nfunction getTokenStates(\n  page: TikTokPage,\n  timeMs: number,\n): { activeIndex: number; tokens: CaptionTokenState[] } {\n  let activeIndex = -1;\n  page.tokens.forEach((token, i) => {\n    if (token.fromMs <= timeMs) activeIndex = i;\n  });\n  const tokens = page.tokens.map(\n    (token, i): CaptionTokenState => ({\n      text: token.text.trim(),\n      spaceBefore: i > 0 && /^\\s/.test(token.text),\n      fromMs: token.fromMs,\n      toMs: token.toMs,\n      progress: tokenProgress(token.fromMs, token.toMs, timeMs),\n      status:\n        i < activeIndex ? \"spoken\" : i === activeIndex ? \"active\" : \"upcoming\",\n    }),\n  );\n  return { activeIndex, tokens };\n}\n\nexport function getCaptionsState(\n  windows: CaptionPageWindow[],\n  timeMs: number,\n): CaptionsState | null {\n  const found = findWindowIndex(windows, timeMs);\n  if (found === -1) return null;\n  const window = windows[found];\n  const { activeIndex, tokens } = getTokenStates(window.page, timeMs);\n  const before = found > 0 ? windows[found - 1] : null;\n  const previous =\n    before && !before.endsInSilence\n      ? {\n          page: before.page,\n          pageIndex: before.index,\n          tokens: getTokenStates(before.page, before.endMs).tokens,\n        }\n      : null;\n  return {\n    page: window.page,\n    pageIndex: window.index,\n    speaker: window.speaker,\n    timeMs,\n    activeIndex,\n    tokens,\n    sinceStartMs: timeMs - window.startMs,\n    untilEndMs: window.endMs - timeMs,\n    holdRoomMs: window.endMs - window.speechEndMs,\n    endsInSilence: window.endsInSilence,\n    previous,\n  };\n}\n\nexport function getExitWindowMs(state: CaptionsState, exitMs: number): number {\n  return Math.max(1, Math.min(exitMs, state.holdRoomMs));\n}\n\nexport function groupCaptionWords(\n  tokens: CaptionTokenState[],\n): { index: number; token: CaptionTokenState }[][] {\n  const words: { index: number; token: CaptionTokenState }[][] = [];\n  tokens.forEach((token, index) => {\n    const current = words[words.length - 1];\n    if (!current || token.spaceBefore) {\n      words.push([{ index, token }]);\n    } else {\n      current.push({ index, token });\n    }\n  });\n  return words;\n}\n\nexport function normalizeCaptionWord(text: string): string {\n  return text\n    .toLowerCase()\n    .replace(/[\\u2018\\u2019]/g, \"'\")\n    .replace(/[^\\p{L}\\p{N}']/gu, \"\");\n}\n\nexport function createKeywordMatcher(\n  keywords: readonly string[] = [],\n): (text: string) => boolean {\n  const set = new Set(keywords.map(normalizeCaptionWord).filter(Boolean));\n  return (text) => set.has(normalizeCaptionWord(text));\n}\n\nexport function captionWordText(word: { token: CaptionTokenState }[]): string {\n  return word.map(({ token }) => token.text).join(\"\");\n}\n\nexport interface CaptionWordRect {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}\n\nfunction sameRects(a: CaptionWordRect[], b: CaptionWordRect[]): boolean {\n  return (\n    a.length === b.length &&\n    a.every(\n      (r, i) =>\n        r.left === b[i].left &&\n        r.top === b[i].top &&\n        r.width === b[i].width &&\n        r.height === b[i].height,\n    )\n  );\n}\n\nexport function useCaptionWordRects(\n  pageIndex: number,\n  count: number,\n): {\n  wordRef: (index: number) => (el: HTMLElement | null) => void;\n  rects: CaptionWordRect[] | null;\n} {\n  const elements = useRef<(HTMLElement | null)[]>([]);\n  const [measured, setMeasured] = useState<{\n    pageIndex: number;\n    rects: CaptionWordRect[];\n  } | null>(null);\n\n  useLayoutEffect(() => {\n    if (pageIndex === -1) return;\n    const rects = elements.current.slice(0, count).map((el) =>\n      el\n        ? {\n            left: el.offsetLeft,\n            top: el.offsetTop,\n            width: el.offsetWidth,\n            height: el.offsetHeight,\n          }\n        : { left: 0, top: 0, width: 0, height: 0 },\n    );\n    setMeasured((prev) =>\n      prev && prev.pageIndex === pageIndex && sameRects(prev.rects, rects)\n        ? prev\n        : { pageIndex, rects },\n    );\n  });\n\n  return {\n    wordRef: (index) => (el) => {\n      elements.current[index] = el;\n    },\n    rects: measured && measured.pageIndex === pageIndex ? measured.rects : null,\n  };\n}\n\nexport function useCaptions({\n  captions,\n  combineTokensWithinMilliseconds,\n  holdMs = DEFAULT_HOLD_MS,\n}: {\n  captions: SpeakerCaption[];\n  combineTokensWithinMilliseconds: number;\n  holdMs?: number;\n}): CaptionsState | null {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const windows = useMemo(\n    () =>\n      buildCaptionPages(captions, { combineTokensWithinMilliseconds, holdMs }),\n    [captions, combineTokensWithinMilliseconds, holdMs],\n  );\n  return getCaptionsState(windows, (frame / fps) * 1000);\n}\n",
      "type": "registry:lib",
      "target": "lib/remocn/caption-core.ts"
    }
  ],
  "type": "registry:lib"
}
