{
  "name": "caption-utils",
  "type": "registry:lib",
  "description": "Caption page grouping and word-active helpers",
  "dependencies": [
    "@remotion/captions"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/bases/default/lib/caption-utils.ts",
      "type": "registry:lib",
      "content": "import {\n  createTikTokStyleCaptions,\n  type Caption,\n  type TikTokPage,\n} from \"@remotion/captions\";\nimport { interpolate } from \"remotion\";\nimport { EASING } from \"./motion-tokens\";\n\nexport const DEFAULT_CAPTION_PAGE_MS = 1200;\n\nexport function groupCaptionsIntoPages(\n  captions: Caption[],\n  combineTokensWithinMilliseconds = DEFAULT_CAPTION_PAGE_MS,\n): TikTokPage[] {\n  const { pages } = createTikTokStyleCaptions({\n    captions,\n    combineTokensWithinMilliseconds,\n  });\n  return pages;\n}\n\n/** Absolute composition time for the current sequence frame. */\nexport function getAbsoluteTimeMs(page: TikTokPage, frame: number, fps: number) {\n  const relativeMs = (frame / fps) * 1000;\n  return page.startMs + relativeMs;\n}\n\nexport function isTokenActive(\n  token: { fromMs: number; toMs: number },\n  absoluteTimeMs: number,\n) {\n  return token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;\n}\n\n/** Token timing relative to the page sequence start (required inside `<Sequence from={...}>`). */\nexport function getTokenRelativeFrames(\n  token: { fromMs: number; toMs: number },\n  page: TikTokPage,\n  fps: number,\n) {\n  const startFrame = Math.round(((token.fromMs - page.startMs) / 1000) * fps);\n  const endFrame = Math.round(((token.toMs - page.startMs) / 1000) * fps);\n  return { startFrame, endFrame };\n}\n\nexport function getTokenEmphasis(\n  frame: number,\n  token: { fromMs: number; toMs: number },\n  page: TikTokPage,\n  fps: number,\n  options?: { enterFrames?: number; exitFrames?: number },\n) {\n  // A word holds for 200-800ms, so emphasis has to arrive fast and release\n  // slower — a symmetric ramp reads as a flicker on short tokens.\n  const enterFrames = options?.enterFrames ?? 4;\n  const exitFrames = options?.exitFrames ?? 9;\n  const { startFrame, endFrame } = getTokenRelativeFrames(token, page, fps);\n  const absoluteTimeMs = getAbsoluteTimeMs(page, frame, fps);\n  const active = isTokenActive(token, absoluteTimeMs);\n\n  const enter = interpolate(\n    frame,\n    [startFrame, startFrame + enterFrames],\n    [0, 1],\n    {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n      easing: EASING.enter,\n    },\n  );\n\n  // Releasing emphasis is a settle, not an exit — accelerating away would snap\n  // the word back to its resting size.\n  const exit = interpolate(\n    frame,\n    [endFrame, endFrame + exitFrames],\n    [1, 0],\n    {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n      easing: EASING.editorial,\n    },\n  );\n\n  if (active) {\n    return enter;\n  }\n\n  // Tokens that have not been spoken yet sit at rest — the exit ramp clamps to\n  // 1 before its own end frame, so it must not be read for future words.\n  return frame < startFrame ? 0 : exit;\n}\n\nexport function getPageSequenceTiming(\n  pages: TikTokPage[],\n  pageIndex: number,\n  fps: number,\n  maxPageDurationMs = DEFAULT_CAPTION_PAGE_MS,\n) {\n  const page = pages[pageIndex];\n  const nextPage = pages[pageIndex + 1] ?? null;\n  const startFrame = (page.startMs / 1000) * fps;\n  const switchEndFrame = startFrame + (maxPageDurationMs / 1000) * fps;\n  const contentEndFrame = startFrame + (page.durationMs / 1000) * fps;\n  const nextPageStartFrame = nextPage\n    ? (nextPage.startMs / 1000) * fps\n    : Infinity;\n\n  const endFrame = nextPage\n    ? Math.min(nextPageStartFrame, switchEndFrame)\n    : Math.min(contentEndFrame, nextPageStartFrame);\n\n  const durationInFrames = endFrame - startFrame;\n\n  return { startFrame, durationInFrames };\n}\n"
    }
  ]
}