{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "word-captions",
  "title": "Word Captions",
  "description": "Burned-in captions in the styles big channels actually use — heavy Montserrat, a real outside outline so they read on any footage, and the spoken word springing to an accent. Four presets: beast, hormozi, pop, clean.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/word-captions/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Montserrat\";\nimport { loadFont as loadRoboto } from \"@remotion/google-fonts/Roboto\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  continueRender,\n  delayRender,\n  getRemotionEnvironment,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme, withAlpha } from \"@/lib/snap-cn-ui\";\n\n// Montserrat, 700–900. This is the face the look is actually made of: the caption\n// styles every big channel uses are a heavy geometric grotesque, and Inter at 700\n// simply is not heavy enough to carry a stroke. Loaded through\n// @remotion/google-fonts so the Player, the mp4 and a user's project all agree.\nconst { fontFamily: MONTSERRAT, waitUntilDone: montserratReady } = loadFont(\n  \"normal\",\n  {\n    weights: [\"700\", \"800\", \"900\"],\n    subsets: [\"latin\"],\n  },\n);\n\n// The `boxed` look is the YouTube/CapCut auto-caption, and that is set in Roboto\n// — a neutral grotesque, not a geometric one. Montserrat would read as a logo.\nconst { fontFamily: ROBOTO } = loadRoboto(\"normal\", {\n  weights: [\"500\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type CaptionAspect = \"16:9\" | \"1:1\" | \"9:16\";\nexport type CaptionActiveStyle = \"pop\" | \"highlight\" | \"color\";\n\n/**\n * The four looks, and they are not invented — they are the caption styles that\n * actually run on big channels:\n *\n * - `beast`   — ALL CAPS, Montserrat Black, white with a heavy black outline and a\n *               hard shadow; the spoken word snaps to yellow and springs. The\n *               YouTube-hook look.\n * - `hormozi` — same weight class, but the spoken word lands inside a filled\n *               rounded block that cycles through an accent set. Reads at a glance\n *               on a phone.\n * - `pop`     — sentence case, outlined, the spoken word takes the accent and pops.\n *               Punchy without shouting.\n * - `clean`   — a quiet pill. No outline. For product footage where captions are\n *               support, not the show. (The old default.)\n */\nexport type CaptionPreset =\n  | \"boxed\"\n  | \"youtube\"\n  | \"beast\"\n  | \"hormozi\"\n  | \"pop\"\n  | \"clean\";\n\nexport interface CaptionWord {\n  text: string;\n  startFrame: number;\n  /** Defaults to the next word's startFrame (or startFrame + fallback for the last word). */\n  endFrame?: number;\n}\n\n/** A caption word with its endFrame resolved. */\nexport interface TimedWord {\n  text: string;\n  startFrame: number;\n  endFrame: number;\n}\n\n/** One caption beat: 1–3 words shown together in the pill. */\nexport interface CaptionGroup {\n  words: TimedWord[];\n  startFrame: number;\n  endFrame: number;\n}\n\nexport interface WordCaptionsProps {\n  /**\n   * Word-level captions in MILLISECONDS — the shape Whisper, whisper.cpp, CapCut,\n   * Submagic and Remotion's own `@remotion/captions` all speak. This is the input\n   * you will actually have.\n   */\n  captions?: Caption[];\n  /** An .srt, pasted straight in. Cues are split into words automatically. */\n  srt?: string;\n  /** The look. See {@link CaptionPreset}. */\n  preset?: CaptionPreset;\n  /** Words per page. Defaults per preset. */\n  maxWords?: number;\n  /** Character budget per page — what stops a page wrapping into a tower. */\n  maxChars?: number;\n  /** A pause longer than this (ms) starts a new page. */\n  pageBreakMs?: number;\n  /**\n   * Outline width, as a fraction of the font size. This is what makes a caption\n   * legible on ANY footage, and it is why hand-rolled captions look cheap without\n   * it. 0 turns it off.\n   */\n  strokeRatio?: number;\n  strokeColor?: string;\n  /** Force upper case. Defaults per preset — the loud ones shout.  */\n  uppercase?: boolean;\n  /** Accent set the `hormozi` block cycles through, one colour per beat. */\n  accentCycle?: string[];\n  /**\n   * Timed transcript. Pass an array of `{ text, startFrame, endFrame? }`\n   * (e.g. mapped from Whisper word timestamps), or a plain string that is\n   * paced evenly at `framesPerWord`.\n   */\n  words?: CaptionWord[] | string;\n  /** Tokens shown per caption beat (1–3). */\n  groupSize?: number;\n  /** How the currently-spoken word is emphasized. */\n  activeStyle?: CaptionActiveStyle;\n  /** Safe-area position preset for the target frame shape. */\n  aspect?: CaptionAspect;\n  /** Max width of the caption block in px. */\n  maxWidth?: number;\n  fontSize?: number;\n  /** Overrides the design system's `foreground`. */\n  textColor?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  /** Defaults to `\"dark\"` — a caption scrim is lit for footage. */\n  mode?: \"light\" | \"dark\";\n  /** Backdrop pill behind the caption line. Any CSS color; empty string hides it. */\n  pillColor?: string;\n  /** Active-word accent used by the `highlight` and `color` styles. */\n  accentColor?: string;\n  /** Pacing (frames per word) used when `words` is a plain string. */\n  framesPerWord?: number;\n  fontWeight?: number;\n  speed?: number;\n  className?: string;\n}\n\n/**\n * Bottom inset of the caption block per aspect, as a percentage of the\n * composition height — keeps captions clear of platform chrome (9:16 stays\n * above the feed UI, 16:9 sits in the lower third).\n */\nexport const SAFE_AREA_BOTTOM_PCT: Record<CaptionAspect, number> = {\n  \"16:9\": 8,\n  \"1:1\": 11,\n  \"9:16\": 20,\n};\n\n/** Clamps the beat size to the supported 1–3 token range. */\nexport function clampGroupSize(groupSize: number): number {\n  if (!Number.isFinite(groupSize)) return 1;\n  return Math.min(3, Math.max(1, Math.floor(groupSize)));\n}\n\n/** Evenly paces a plain transcript string into timed words. */\nexport function scheduleWords(\n  text: string,\n  framesPerWord: number,\n  startFrame = 0,\n): CaptionWord[] {\n  const pace = Math.max(1, framesPerWord);\n  return text\n    .split(/\\s+/)\n    .filter((token) => token.length > 0)\n    .map((token, i) => ({\n      text: token,\n      startFrame: startFrame + i * pace,\n      endFrame: startFrame + (i + 1) * pace,\n    }));\n}\n\n/**\n * Fills in missing endFrames: a word ends when the next one starts, and the\n * last word holds for `fallbackFrames`. Every word lasts at least 1 frame.\n */\nexport function resolveWordTimings(\n  words: CaptionWord[],\n  fallbackFrames: number,\n): TimedWord[] {\n  return words.map((word, i) => {\n    const next = words[i + 1];\n    const endFrame =\n      word.endFrame ??\n      (next ? next.startFrame : word.startFrame + Math.max(1, fallbackFrames));\n    return {\n      text: word.text,\n      startFrame: word.startFrame,\n      endFrame: Math.max(endFrame, word.startFrame + 1),\n    };\n  });\n}\n\n/** Chunks timed words into caption beats of `groupSize` tokens. */\nexport function groupWords(\n  words: TimedWord[],\n  groupSize: number,\n): CaptionGroup[] {\n  const size = clampGroupSize(groupSize);\n  const groups: CaptionGroup[] = [];\n  for (let i = 0; i < words.length; i += size) {\n    const chunk = words.slice(i, i + size);\n    groups.push({\n      words: chunk,\n      startFrame: chunk[0].startFrame,\n      endFrame: chunk[chunk.length - 1].endFrame,\n    });\n  }\n  return groups;\n}\n\n/** Index of the group on screen at `frame`, or -1 when no caption shows. */\nexport function activeGroupIndex(\n  groups: CaptionGroup[],\n  frame: number,\n): number {\n  for (let i = groups.length - 1; i >= 0; i--) {\n    if (frame >= groups[i].startFrame && frame < groups[i].endFrame) return i;\n  }\n  return -1;\n}\n\nexport interface CaptionLook {\n  weight: number;\n  uppercase: boolean;\n  /** Words per page, and the character budget that keeps a page on 1–2 lines. */\n  maxWords: number;\n  maxChars: number;\n  /** Font size as a fraction of the composition's SHORT side, so it reads the\n   *  same on 9:16 and 16:9 instead of shrinking to nothing on one of them. */\n  sizeRatio: number;\n  strokeRatio: number;\n  tracking: string;\n  /** Scale the spoken word springs to. */\n  pop: number;\n  /** Does the spoken word sit inside a filled block? */\n  block: boolean;\n  pill: boolean;\n  shadow: boolean;\n  /** Per-line solid box behind the whole line (YouTube auto-caption look). */\n  box?: boolean;\n}\n\n/**\n * Sizes are a fraction of the frame's SHORT side. The old default was 54px on a\n * 1920-tall frame — 2.8% of its height — which is a subtitle, not a caption. These\n * run 11–13% of the short side, which is where burned-in captions actually live.\n */\nexport const CAPTION_LOOKS: Record<CaptionPreset, CaptionLook> = {\n  /**\n   * The default: the YouTube / TikTok auto-caption look — white text on a solid\n   * black box that wraps each line tight (`box-decoration-break: clone`). No\n   * outline, no pop; the box carries the contrast. Clean and unmistakable.\n   */\n  boxed: {\n    weight: 700,\n    uppercase: false,\n    maxWords: 7,\n    maxChars: 28,\n    sizeRatio: 0.062,\n    strokeRatio: 0,\n    tracking: \"-0.005em\",\n    pop: 1,\n    block: false,\n    pill: false,\n    shadow: false,\n    box: true,\n  },\n  /**\n   * The default, and the one you actually see under a talking head: a phrase per\n   * page, big enough to read on a phone but small enough to stay on 1–2 lines, with\n   * the spoken word lit up inside it. This is what Submagic / Opus / CapCut put out\n   * and what most channels burn in.\n   */\n  youtube: {\n    weight: 800,\n    uppercase: false,\n    maxWords: 6,\n    maxChars: 26,\n    sizeRatio: 0.075,\n    strokeRatio: 0.075,\n    tracking: \"-0.015em\",\n    pop: 1.07,\n    block: false,\n    pill: false,\n    shadow: true,\n  },\n  beast: {\n    weight: 900,\n    uppercase: true,\n    maxWords: 3,\n    maxChars: 16,\n    sizeRatio: 0.125,\n    strokeRatio: 0.1,\n    tracking: \"-0.005em\",\n    pop: 1.14,\n    block: false,\n    pill: false,\n    shadow: true,\n  },\n  hormozi: {\n    weight: 800,\n    uppercase: true,\n    maxWords: 3,\n    maxChars: 14,\n    sizeRatio: 0.115,\n    strokeRatio: 0.085,\n    tracking: \"-0.005em\",\n    pop: 1.06,\n    block: true,\n    pill: false,\n    shadow: true,\n  },\n  pop: {\n    weight: 800,\n    uppercase: false,\n    maxWords: 4,\n    maxChars: 22,\n    sizeRatio: 0.11,\n    strokeRatio: 0.07,\n    tracking: \"-0.015em\",\n    pop: 1.12,\n    block: false,\n    pill: false,\n    shadow: true,\n  },\n  clean: {\n    weight: 700,\n    uppercase: false,\n    maxWords: 8,\n    maxChars: 42,\n    sizeRatio: 0.06,\n    strokeRatio: 0,\n    tracking: \"-0.01em\",\n    pop: 1.06,\n    block: false,\n    pill: true,\n    shadow: false,\n  },\n};\n\n/** The accent set the `hormozi` block cycles through, one colour per beat. */\nexport const DEFAULT_ACCENT_CYCLE = [\n  \"#27E36B\",\n  \"#FFE81F\",\n  \"#FF4D4D\",\n  \"#3EA8FF\",\n];\n\n/**\n * The spoken word's scale. A spring with a little overshoot — a caption that eases\n * politely into place does not read as a caption, it reads as a lower third.\n */\nexport function popScale(\n  frame: number,\n  startFrame: number,\n  fps: number,\n  peak: number,\n): number {\n  const s = spring({\n    frame: frame - startFrame,\n    fps,\n    config: { damping: 11, stiffness: 190, mass: 0.7 },\n    durationInFrames: 14,\n  });\n  return 1 + (peak - 1) * s;\n}\n\n/* ---------------------------------------------------------------------------\n * Real transcripts. This is the part that makes the component usable.\n *\n * Nobody has frame numbers. A caption comes out of Whisper, whisper.cpp, CapCut,\n * Submagic, Opus Clip or Descript, and it comes out in MILLISECONDS — or as an\n * .srt file. Remotion's own `@remotion/captions` type is `{ text, startMs, endMs }`,\n * and that is the shape the whole ecosystem speaks.\n *\n * So that is the shape this takes. Paste a Whisper JSON, or paste an .srt straight\n * in. The frame maths is ours, not yours.\n * ------------------------------------------------------------------------- */\n\n/** The standard caption token. Structurally identical to `@remotion/captions`. */\nexport interface Caption {\n  text: string;\n  startMs: number;\n  endMs: number;\n}\n\n/** ms → frame, at the composition's own rate. */\nexport function msToFrame(ms: number, fps: number): number {\n  return (ms / 1000) * fps;\n}\n\n/**\n * Word-level captions from an SRT. Handles both `,` and `.` as the millisecond\n * separator (Whisper writes commas; some tools write dots), and multi-line cues.\n */\nexport function parseSrt(srt: string): Caption[] {\n  const out: Caption[] = [];\n  const blocks = srt\n    .replace(/\\r/g, \"\")\n    .trim()\n    .split(/\\n\\s*\\n/);\n  for (const raw of blocks) {\n    const lines = raw.split(\"\\n\").filter((l) => l.trim().length > 0);\n    if (lines.length === 0) continue;\n    const timeLine = lines.find((l) => l.includes(\"-->\"));\n    if (!timeLine) continue;\n    const [from, to] = timeLine.split(\"-->\").map((t) => t.trim());\n    const startMs = srtTimeToMs(from);\n    const endMs = srtTimeToMs(to);\n    if (startMs === null || endMs === null) continue;\n    const text = lines\n      .filter((l) => l !== timeLine && !/^\\d+$/.test(l.trim()))\n      .join(\" \")\n      .trim();\n    if (text.length > 0) out.push({ text, startMs, endMs });\n  }\n  return out;\n}\n\n/** `00:00:01,234` (or `.234`, or `00:01,234`) → 1234. Null when it isn't a timestamp. */\nexport function srtTimeToMs(stamp: string): number | null {\n  const m = stamp.trim().match(/^(?:(\\d+):)?(\\d+):(\\d+)[,.](\\d{1,3})$/);\n  if (!m) return null;\n  const [, h, min, sec, frac] = m;\n  return (\n    (h ? Number(h) : 0) * 3600000 +\n    Number(min) * 60000 +\n    Number(sec) * 1000 +\n    Number(frac.padEnd(3, \"0\"))\n  );\n}\n\n/**\n * An SRT cue is usually a whole phrase, not a word. Split it into words and spread\n * them across the cue so the active-word highlight still has something to track.\n * (Word-level Whisper output needs none of this — it already IS one word per cue.)\n */\nexport function explodeCue(caption: Caption): Caption[] {\n  const words = caption.text.split(/\\s+/).filter(Boolean);\n  if (words.length <= 1) return [caption];\n  const span = Math.max(1, caption.endMs - caption.startMs);\n  const per = span / words.length;\n  return words.map((text, i) => ({\n    text,\n    startMs: caption.startMs + i * per,\n    endMs: caption.startMs + (i + 1) * per,\n  }));\n}\n\n/** Captions (ms) → the internal frame-timed model. */\nexport function captionsToWords(\n  captions: Caption[],\n  fps: number,\n  explode = true,\n): TimedWord[] {\n  const tokens = explode ? captions.flatMap(explodeCue) : captions;\n  return tokens.map((c) => ({\n    text: c.text,\n    startFrame: msToFrame(c.startMs, fps),\n    endFrame: Math.max(msToFrame(c.endMs, fps), msToFrame(c.startMs, fps) + 1),\n  }));\n}\n\nexport interface PageOptions {\n  /** Hard cap on words per page. */\n  maxWords: number;\n  /** Character budget, so a page actually FITS the frame instead of wrapping to a tower. */\n  maxChars: number;\n  /** A pause longer than this starts a new page — that is where a sentence breaks. */\n  maxGapFrames: number;\n}\n\n/**\n * Group words into PAGES the way real caption tools do — by the shape of the speech,\n * not by a fixed count.\n *\n * A page ends when it runs out of words, runs out of characters, or the speaker\n * pauses. The old `groupWords` chopped every 3 words regardless, which is how you\n * end up with \"STOP / LOSING / HOURS\" stacked in a tower: three words that never\n * belonged on their own page.\n */\nexport function buildPages(\n  words: TimedWord[],\n  opts: PageOptions,\n): CaptionGroup[] {\n  const pages: CaptionGroup[] = [];\n  let current: TimedWord[] = [];\n  let chars = 0;\n\n  const flush = () => {\n    if (current.length === 0) return;\n    pages.push({\n      words: current,\n      startFrame: current[0].startFrame,\n      endFrame: current[current.length - 1].endFrame,\n    });\n    current = [];\n    chars = 0;\n  };\n\n  for (const word of words) {\n    const prev = current[current.length - 1];\n    const gap = prev ? word.startFrame - prev.endFrame : 0;\n    const wouldOverflow =\n      current.length >= Math.max(1, opts.maxWords) ||\n      chars + word.text.length + 1 > Math.max(1, opts.maxChars);\n\n    if (prev && (wouldOverflow || gap > opts.maxGapFrames)) flush();\n\n    current.push(word);\n    chars += word.text.length + 1;\n  }\n  flush();\n  return pages;\n}\n\n/**\n * Burned-in captions, in the styles big channels actually use.\n *\n * Three things separate a premium caption from a subtitle, and all three are here:\n *\n * 1. **The outline.** A caption has to be legible over footage it has never seen —\n *    a face, a sky, a white desk. A heavy black outline does that, and nothing else\n *    does. It has to be an OUTSIDE outline: `-webkit-text-stroke` centres the stroke\n *    on the glyph, so half of it eats inwards and the letterform goes thin and\n *    mushy (measured: a 14px stroke takes a 38px stem down to 22px). `paint-order:\n *    stroke fill` draws the stroke first and the fill over it, and the stem comes\n *    back to 38px. That one line is most of the look.\n * 2. **The weight and the size.** Montserrat 800–900, at 11–13% of the frame's\n *    short side. The old default was 2.8%.\n * 3. **The spoken word pops.** A spring with overshoot, not an ease.\n *\n * That pop is a scale on text, which is where captions usually fall apart: a browser\n * gives glyph origins no vertical sub-pixel precision, so a scale that moves the\n * baseline makes the word climb the pixel grid in whole-pixel jumps. The scale\n * pivots on the **measured baseline**, so the baseline's device Y never changes.\n */\nexport function WordCaptions({\n  captions,\n  srt,\n  words = \"Stop losing hours to manual invoices\",\n  preset = \"boxed\",\n  maxWords,\n  maxChars,\n  pageBreakMs = 420,\n  groupSize,\n  activeStyle = \"pop\",\n  aspect = \"16:9\",\n  maxWidth,\n  fontSize,\n  textColor,\n  pillColor,\n  theme,\n  mode,\n  accentColor = \"#FFE81F\",\n  accentCycle = DEFAULT_ACCENT_CYCLE,\n  strokeColor = \"#000000\",\n  strokeRatio,\n  uppercase,\n  framesPerWord = 14,\n  fontWeight,\n  speed = 1,\n  className,\n}: WordCaptionsProps) {\n  const frame = useCurrentFrame() * speed;\n  const { fps, width, height } = useVideoConfig();\n  // A caption pill is a legibility scrim over footage, so its neutrals come\n  // from the dark end of the system by default. The accent, the outline and\n  // the cycle are the caption's look and stay props (design-system Rule 3c).\n  const t = useSnapCnTheme(theme, mode ?? \"dark\");\n  const ink = textColor ?? t.foreground;\n  const scrim = pillColor ?? withAlpha(t.background, 0.55);\n\n  const look = CAPTION_LOOKS[preset] ?? CAPTION_LOOKS.beast;\n  const shortSide = Math.min(width, height);\n  // 0 (or omitted) means \"the preset decides\" — the preset IS the design, so a\n  // customizer knob that silently overrode it would make every preset identical.\n  const size =\n    fontSize && fontSize > 0\n      ? fontSize\n      : Math.round(look.sizeRatio * shortSide);\n  const weight = fontWeight && fontWeight > 0 ? fontWeight : look.weight;\n  const stroke =\n    (strokeRatio !== undefined && strokeRatio >= 0\n      ? strokeRatio\n      : look.strokeRatio) * size;\n  const caps = uppercase ?? look.uppercase;\n  const block = maxWidth && maxWidth > 0 ? maxWidth : Math.round(width * 0.86);\n\n  // Real captions first, the toy string last. `captions` (ms) and `srt` are what a\n  // user actually has; `words` (frames) is the legacy path; a bare string is a demo.\n  const timed: TimedWord[] = captions?.length\n    ? captionsToWords(captions, fps)\n    : srt && srt.trim().length > 0\n      ? captionsToWords(parseSrt(srt), fps)\n      : resolveWordTimings(\n          typeof words === \"string\"\n            ? scheduleWords(words, framesPerWord)\n            : words,\n          framesPerWord,\n        );\n\n  // Pages, not fixed-size chunks. `groupSize` still forces a hard count if you pass\n  // it, so nothing that relied on it breaks.\n  const groups = groupSize\n    ? groupWords(timed, groupSize)\n    : buildPages(timed, {\n        maxWords: maxWords ?? look.maxWords,\n        maxChars: maxChars ?? look.maxChars,\n        maxGapFrames: (pageBreakMs / 1000) * fps,\n      });\n  const groupIdx = activeGroupIndex(groups, frame);\n  const group = groupIdx === -1 ? null : groups[groupIdx];\n\n  // The baseline inside a word span, measured once. Guessing it from a line-height\n  // ratio is what makes a popping word judder: the pivot has to be exact.\n  const probeRef = useRef<HTMLSpanElement>(null);\n  const baseRef = useRef<HTMLSpanElement>(null);\n  const [baselineY, setBaselineY] = useState<number | null>(null);\n  // The delayRender exists only to hold the *mp4 render* on its first frame until\n  // the baseline is measured — there the font is already loaded, so that one\n  // measurement is exact. In the Player we never block: `baselineY === null`\n  // falls back to a line-height pivot until the mount measurement lands, which is\n  // invisible on a looping preview. Blocking the Player here is what stranded\n  // every caption card behind an orphaned delayRender handle that React\n  // StrictMode's double-invoked initializer creates and never clears.\n  const [handle] = useState(() =>\n    getRemotionEnvironment().isRendering\n      ? delayRender(\"word-captions: measuring the baseline for the pop pivot\")\n      : null,\n  );\n\n  useLayoutEffect(() => {\n    const probe = probeRef.current;\n    const base = baseRef.current;\n    if (probe && base) {\n      setBaselineY(\n        base.getBoundingClientRect().top - probe.getBoundingClientRect().top,\n      );\n    } else if (handle != null) {\n      // Nothing to measure in a render — release it rather than hang the frame.\n      continueRender(handle);\n    }\n    // Re-measure once Montserrat lands: a best-effort refinement for the Player\n    // (the render already has the font). Never blocks — the pivot above is set.\n    let cancelled = false;\n    void montserratReady().then(() => {\n      if (cancelled) return;\n      const p = probeRef.current;\n      const b = baseRef.current;\n      if (p && b) {\n        setBaselineY(\n          b.getBoundingClientRect().top - p.getBoundingClientRect().top,\n        );\n      }\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [handle]);\n\n  // In a render, release the held first frame the moment the baseline is known.\n  useEffect(() => {\n    if (baselineY != null && handle != null) continueRender(handle);\n  }, [baselineY, handle]);\n\n  const accent =\n    look.block && accentCycle.length > 0\n      ? accentCycle[Math.max(0, groupIdx) % accentCycle.length]\n      : accentColor;\n\n  const lineHeight = Math.round(size * 1.14);\n  const willChange = getRemotionEnvironment().isRendering\n    ? undefined\n    : (\"transform\" as const);\n\n  const typeStyle = {\n    fontFamily: MONTSERRAT,\n    fontWeight: weight,\n    fontSize: size,\n    lineHeight: `${lineHeight}px`,\n    letterSpacing: look.tracking,\n    // Hinting re-snaps every stem to the pixel grid as a word scales, and the\n    // letterforms visibly boil. This turns it off.\n    textRendering: \"geometricPrecision\" as const,\n    fontVariantLigatures: \"none\" as const,\n  };\n\n  const outline =\n    stroke > 0\n      ? {\n          WebkitTextStrokeWidth: `${stroke}px`,\n          WebkitTextStrokeColor: strokeColor,\n          // Draw the stroke, THEN the fill on top of it. Without this the stroke is\n          // centred on the outline and eats the letterform from the inside.\n          paintOrder: \"stroke fill\" as const,\n        }\n      : {};\n\n  const shadow = look.shadow\n    ? {\n        textShadow: `0 ${size * 0.055}px ${size * 0.05}px ${withAlpha(\n          t.background,\n          0.42,\n        )}`,\n      }\n    : {};\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"flex-end\",\n        justifyContent: \"center\",\n        paddingBottom: `${SAFE_AREA_BOTTOM_PCT[aspect] ?? SAFE_AREA_BOTTOM_PCT[\"9:16\"]}%`,\n        paddingLeft: \"5%\",\n        paddingRight: \"5%\",\n        pointerEvents: \"none\",\n      }}\n    >\n      {/* Off-screen probe: one word, same type, with a zero-sized inline-block\n          sitting on its baseline. Read once, used as the pivot for every pop. */}\n      <span\n        ref={probeRef}\n        aria-hidden\n        style={{\n          ...typeStyle,\n          position: \"absolute\",\n          left: -99999,\n          top: 0,\n          visibility: \"hidden\",\n          display: \"inline-block\",\n        }}\n      >\n        Hg\n        <span\n          ref={baseRef}\n          style={{ display: \"inline-block\", width: 0, height: 0 }}\n        />\n      </span>\n\n      {/* Boxed look: white text on a solid black box that clones per line. The\n          words stay inline (no flex, no per-word transform), so the background\n          wraps each line tight — the YouTube auto-caption look. */}\n      {group && look.box && (\n        <div\n          style={{\n            maxWidth: block,\n            textAlign: \"center\",\n            opacity: interpolate(frame - group.startFrame, [0, 3], [0, 1], {\n              extrapolateLeft: \"clamp\",\n              extrapolateRight: \"clamp\",\n            }),\n          }}\n        >\n          <span\n            style={{\n              ...typeStyle,\n              fontFamily: ROBOTO,\n              display: \"inline\",\n              color: ink,\n              backgroundColor: withAlpha(t.background, 0.9),\n              padding: `${size * 0.06}px ${size * 0.32}px`,\n              boxDecorationBreak: \"clone\",\n              WebkitBoxDecorationBreak: \"clone\",\n              lineHeight: `${Math.round(size * 1.46)}px`,\n              borderRadius: 2,\n            }}\n          >\n            {group.words\n              .map((w) => (caps ? w.text.toUpperCase() : w.text))\n              .join(\" \")}\n          </span>\n        </div>\n      )}\n\n      {group && !look.box && (\n        <div\n          style={{\n            display: \"flex\",\n            flexWrap: \"wrap\",\n            alignItems: \"flex-end\",\n            justifyContent: \"center\",\n            // px, not em: `em` here would resolve against the CONTAINER's font size,\n            // which is the inherited 16px — not the caption's. That is how the words\n            // ended up welded together as \"STOPLOSING\".\n            columnGap: size * 0.26,\n            rowGap: size * 0.04,\n            maxWidth: block,\n            textAlign: \"center\",\n            ...(look.pill\n              ? {\n                  padding: `${size * 0.3}px ${size * 0.5}px`,\n                  borderRadius: size * 0.22,\n                  backgroundColor: pillColor === \"\" ? \"transparent\" : scrim,\n                }\n              : {}),\n            opacity: interpolate(frame - group.startFrame, [0, 3], [0, 1], {\n              extrapolateLeft: \"clamp\",\n              extrapolateRight: \"clamp\",\n            }),\n          }}\n        >\n          {group.words.map((word, i) => {\n            const spoken =\n              frame >= word.startFrame &&\n              (frame < word.endFrame || i === group.words.length - 1);\n            const isActive =\n              spoken && frame >= word.startFrame && frame < word.endFrame;\n\n            const scale =\n              isActive && activeStyle === \"pop\"\n                ? popScale(frame, word.startFrame, fps, look.pop)\n                : 1;\n\n            // Not yet spoken words in a beat stay white; the spoken one takes the\n            // accent (or the block). That is what makes the beat readable AHEAD of\n            // the voice instead of only with it.\n            const useAccent = isActive && activeStyle !== \"pop\";\n            const fill = look.block ? ink : isActive ? accent : ink;\n\n            return (\n              <span\n                key={`${word.text}-${word.startFrame}`}\n                style={{\n                  ...typeStyle,\n                  ...outline,\n                  ...shadow,\n                  display: \"inline-block\",\n                  color: useAccent ? accent : fill,\n                  transform: `scale(${scale})`,\n                  // The pivot is the BASELINE, not the middle of the box. 50% 100%\n                  // is the bottom of the box, which sits a descent BELOW the\n                  // baseline — it drags the word down the pixel grid as it scales.\n                  transformOrigin:\n                    baselineY === null ? \"50% 85%\" : `50% ${baselineY}px`,\n                  willChange,\n                  ...(look.block && isActive\n                    ? {\n                        backgroundColor: accent,\n                        borderRadius: size * 0.12,\n                        padding: `0 ${size * 0.1}px`,\n                        // The block wants the fill on top of the stroke too, or the\n                        // stroke shows as a dark halo inside the block.\n                        WebkitTextStrokeWidth: `${stroke * 0.6}px`,\n                      }\n                    : {}),\n                }}\n              >\n                {caps ? word.text.toUpperCase() : word.text}\n              </span>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/word-captions.tsx"
    }
  ],
  "type": "registry:component"
}