{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "karaoke-captions",
  "title": "Karaoke Captions",
  "description": "A caption line that fills word by word as it is spoken — heavy outlined type that reads on any footage. Three presets: karaoke, highlight (the word rides an accent bar), clean.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/karaoke-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  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme, withAlpha } from \"@/lib/snap-cn-ui\";\n\n// The face the look is made of. Inter at 600 is a subtitle; a caption is a heavy\n// geometric grotesque carrying an outline.\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 auto-caption, which is set in Roboto — a\n// neutral grotesque, not a geometric one.\nconst { fontFamily: ROBOTO } = loadRoboto(\"normal\", {\n  weights: [\"500\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type CaptionTheme = \"light\" | \"dark\";\nexport type SafeAreaPreset = \"landscape\" | \"portrait\" | \"square\";\n\nexport interface CaptionLine {\n  text: string;\n  /** First frame the line is on screen and the fill sweep begins. */\n  startFrame: number;\n  /** Frame the sweep completes; the line fades out shortly after. */\n  endFrame: number;\n  /**\n   * Absolute frame each word starts filling (one entry per word).\n   * Omitted → words are spread evenly across [startFrame, endFrame].\n   */\n  wordTimings?: number[];\n  /** Word indices rendered in the accent color with a subtle scale-up. */\n  emphasize?: number[];\n}\n\nexport interface KaraokeCaptionsProps {\n  /**\n   * Word-level captions in MILLISECONDS — the shape Whisper, CapCut and Remotion's\n   * own `@remotion/captions` all speak. This is the input you will actually have.\n   */\n  captions?: Caption[];\n  /** An .srt, pasted straight in. */\n  srt?: string;\n  /** The look. See {@link KaraokePreset}. */\n  preset?: KaraokePreset;\n  /** Outline width as a fraction of the font size. 0 turns it off. */\n  strokeRatio?: number;\n  strokeColor?: string;\n  uppercase?: boolean;\n  /** Timed caption lines. Omit to build a single line from `text`. */\n  lines?: CaptionLine[];\n  /** Convenience single line used when `lines` is not provided. */\n  text?: string;\n  /** Comma-separated words of `text` to emphasize (case-insensitive). */\n  emphasize?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  /** Light or dark pill chrome. */\n  mode?: CaptionTheme;\n  /** Color of emphasized words. */\n  accentColor?: string;\n  fontSize?: number;\n  fontWeight?: number;\n  /** Safe-area preset positioning the caption block per aspect ratio. */\n  aspect?: SafeAreaPreset;\n  /** Rounded surface behind the line. */\n  pill?: boolean;\n  /** Scale emphasized words grow to as their fill completes. */\n  emphasisScale?: number;\n  /** Unfilled (not-yet-spoken) word color override. Defaults per theme. */\n  baseColor?: string;\n  /** Filled (spoken) word color override. Defaults per theme. */\n  fillColor?: string;\n  speed?: number;\n  className?: string;\n}\n\n/**\n * The looks.\n *\n * - `karaoke`   — a heavy outlined line; each word snaps from dimmed to full white\n *                 as it is spoken. The read-along look.\n * - `highlight` — the spoken word rides a rounded accent bar that sweeps along the\n *                 line, like a marker running under the voice.\n * - `clean`     — the quiet pill. No outline. (The old default.)\n */\nexport type KaraokePreset = \"boxed\" | \"karaoke\" | \"highlight\" | \"clean\";\n\nexport interface KaraokeLook {\n  weight: number;\n  uppercase: boolean;\n  /** Font size as a fraction of the frame's SHORT side. */\n  sizeRatio: number;\n  strokeRatio: number;\n  bar: boolean;\n  pill: boolean;\n  shadow: boolean;\n  /** Per-line solid box behind the line (YouTube auto-caption look). */\n  box?: boolean;\n}\n\nexport const KARAOKE_LOOKS: Record<KaraokePreset, KaraokeLook> = {\n  /**\n   * The default: the YouTube auto-caption — white Roboto on a solid black box\n   * that wraps each line tight (`box-decoration-break: clone`). No outline, no\n   * sweep; the box carries the contrast.\n   */\n  boxed: {\n    weight: 700,\n    uppercase: false,\n    sizeRatio: 0.062,\n    strokeRatio: 0,\n    bar: false,\n    pill: false,\n    shadow: false,\n    box: true,\n  },\n  karaoke: {\n    weight: 900,\n    uppercase: true,\n    sizeRatio: 0.085,\n    strokeRatio: 0.09,\n    bar: false,\n    pill: false,\n    shadow: true,\n  },\n  highlight: {\n    weight: 800,\n    uppercase: false,\n    sizeRatio: 0.08,\n    strokeRatio: 0.07,\n    bar: true,\n    pill: false,\n    shadow: true,\n  },\n  clean: {\n    weight: 600,\n    uppercase: false,\n    sizeRatio: 0.067,\n    strokeRatio: 0,\n    bar: false,\n    pill: true,\n    shadow: false,\n  },\n};\n\n/** Frames the line takes to rise/fade in and to fade out past `endFrame`. */\nexport const LINE_FADE_FRAMES = 8;\n\n/**\n * The pill's chrome, taken from the design system.\n *\n * A caption pill IS app chrome — a card floating over footage — so it follows\n * the tokens. The burned-in type it carries does not: its accent, its outline\n * and its white fill are the caption's look, and stay props.\n */\nexport function karaokePalette(t: SnapCnTheme): {\n  base: string;\n  fill: string;\n  pillBg: string;\n  pillBorder: string;\n  pillShadow: string;\n} {\n  return {\n    base: t.mutedForeground,\n    fill: t.foreground,\n    pillBg: withAlpha(t.card, 0.92),\n    pillBorder: t.border,\n    pillShadow: `0 8px 24px ${withAlpha(t.foreground, 0.1)}`,\n  };\n}\n\n/**\n * Aspect-ratio safe areas for social placements. Shared caption-block\n * positioning (portrait keeps clear of TikTok/Reels UI chrome).\n */\nexport const CAPTION_SAFE_AREAS: Record<\n  SafeAreaPreset,\n  { bottom: string; horizontal: string }\n> = {\n  landscape: { bottom: \"8%\", horizontal: \"10%\" },\n  portrait: { bottom: \"18%\", horizontal: \"7%\" },\n  square: { bottom: \"10%\", horizontal: \"8%\" },\n};\n\n/** Whitespace-split words, empties dropped. */\nexport function splitWords(text: string): string[] {\n  return text.split(/\\s+/).filter((w) => w.length > 0);\n}\n\nexport interface WordWindow {\n  start: number;\n  end: number;\n}\n\n/**\n * Per-word fill windows for a line. Uses `wordTimings` (absolute start\n * frames; a word ends when the next begins, the last at `endFrame`) when\n * provided, otherwise splits [startFrame, endFrame] evenly.\n */\nexport function wordWindows(line: CaptionLine): WordWindow[] {\n  const words = splitWords(line.text);\n  const n = words.length;\n  if (n === 0) return [];\n  const { startFrame, endFrame, wordTimings } = line;\n  if (wordTimings && wordTimings.length > 0) {\n    return words.map((_, i) => {\n      const start = wordTimings[i] ?? wordTimings[wordTimings.length - 1];\n      const end = wordTimings[i + 1] ?? endFrame;\n      return { start, end: Math.max(end, start + 1) };\n    });\n  }\n  const step = (endFrame - startFrame) / n;\n  return words.map((_, i) => ({\n    start: startFrame + step * i,\n    end: startFrame + step * (i + 1),\n  }));\n}\n\n/** 0..1 sweep progress of one word at `frame`. */\nexport function fillProgress(frame: number, window: WordWindow): number {\n  if (window.end <= window.start) return frame >= window.end ? 1 : 0;\n  return Math.min(\n    1,\n    Math.max(0, (frame - window.start) / (window.end - window.start)),\n  );\n}\n\n/**\n * Word indices of `text` matching the comma-separated `emphasize` list.\n * Comparison is case-insensitive and ignores surrounding punctuation.\n */\nexport function emphasisIndices(text: string, emphasize: string): number[] {\n  const targets = emphasize\n    .split(\",\")\n    .map((w) => w.trim().toLowerCase())\n    .filter((w) => w.length > 0);\n  if (targets.length === 0) return [];\n  const strip = (w: string) =>\n    w.toLowerCase().replace(/^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu, \"\");\n  return splitWords(text)\n    .map((word, i) => (targets.includes(strip(word)) ? i : -1))\n    .filter((i) => i >= 0);\n}\n\n/** Single demo line built from the convenience `text` prop. */\nexport function linesFromText(\n  text: string,\n  emphasize: string,\n  startFrame: number,\n  endFrame: number,\n): CaptionLine[] {\n  return [\n    {\n      text,\n      startFrame,\n      endFrame: Math.max(endFrame, startFrame + 1),\n      emphasize: emphasisIndices(text, emphasize),\n    },\n  ];\n}\n\n/* ---------------------------------------------------------------------------\n * Real transcripts.\n *\n * Nobody has frame numbers. A caption comes out of Whisper, whisper.cpp, CapCut or\n * Descript in MILLISECONDS, or as an .srt. Remotion's own `@remotion/captions` type\n * is `{ text, startMs, endMs }` — so that is what this takes.\n *\n * (Deliberately duplicated from word-captions rather than imported: this is a\n * copy-paste registry, and installing a caption line should not drag a second\n * component in with it.)\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/** `00:00:01,234` (or `.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/** Cues from an .srt. Handles `,` and `.` separators and multi-line cues. */\nexport function parseSrt(srt: string): Caption[] {\n  const out: Caption[] = [];\n  for (const raw of srt\n    .replace(/\\r/g, \"\")\n    .trim()\n    .split(/\\n\\s*\\n/)) {\n    const lines = raw.split(\"\\n\").filter((l) => l.trim().length > 0);\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) out.push({ text, startMs, endMs });\n  }\n  return out;\n}\n\n/**\n * Captions (ms) → the line model this component draws. One cue is one line; its\n * words are spread across the cue so the fill has something to track. Word-level\n * Whisper output collapses naturally: consecutive words inside `groupWithinMs` of\n * each other become one line, which is how a caption tool builds a line too.\n */\nexport function linesFromCaptions(\n  captions: Caption[],\n  fps: number,\n  groupWithinMs = 1200,\n): CaptionLine[] {\n  if (captions.length === 0) return [];\n  const toFrame = (ms: number) => (ms / 1000) * fps;\n\n  const groups: Caption[][] = [];\n  let current: Caption[] = [];\n  for (const c of captions) {\n    const prev = current[current.length - 1];\n    if (prev && c.startMs - prev.endMs > groupWithinMs) {\n      groups.push(current);\n      current = [];\n    }\n    current.push(c);\n  }\n  if (current.length) groups.push(current);\n\n  return groups.map((group) => {\n    const words = group.flatMap((c) => c.text.split(/\\s+/).filter(Boolean));\n    const startMs = group[0].startMs;\n    const endMs = group[group.length - 1].endMs;\n    // One timing per word: a multi-word cue is spread across its own span.\n    const timings: number[] = [];\n    for (const c of group) {\n      const parts = c.text.split(/\\s+/).filter(Boolean);\n      const per = Math.max(1, c.endMs - c.startMs) / Math.max(1, parts.length);\n      for (let i = 0; i < parts.length; i++) {\n        timings.push(toFrame(c.startMs + i * per));\n      }\n    }\n    return {\n      text: words.join(\" \"),\n      startFrame: toFrame(startMs),\n      endFrame: Math.max(toFrame(endMs), toFrame(startMs) + 1),\n      wordTimings: timings,\n    };\n  });\n}\n\n/**\n * Index of the line on screen at `frame` (the last one whose window contains it,\n * including its fade-out tail), or -1 when nothing is showing.\n */\nexport function activeLineIndex(\n  lines: CaptionLine[],\n  frame: number,\n  fadeOut = LINE_FADE_FRAMES,\n): number {\n  let active = -1;\n  for (let i = 0; i < lines.length; i++) {\n    if (frame >= lines[i].startFrame && frame < lines[i].endFrame + fadeOut) {\n      active = i;\n    }\n  }\n  return active;\n}\n\n/**\n * A caption line that fills word by word as it is spoken — the read-along look.\n *\n * Same three things that separate a caption from a subtitle:\n *\n * 1. **An OUTSIDE outline.** `-webkit-text-stroke` centres the stroke on the glyph\n *    and eats it from the inside (measured: a 14px stroke takes a 38px stem down to\n *    22px). `paint-order: stroke fill` puts the fill back over it. Without this a\n *    caption is unreadable the moment the footage behind it is bright.\n * 2. **Weight and size.** Montserrat 800–900 at ~8.5% of the frame's short side.\n * 3. **The spoken word actually lands** — it snaps to full white (or rides an accent\n *    bar), rather than easing politely.\n *\n * The emphasis scale pivots on the MEASURED baseline: a browser gives glyph origins\n * no vertical sub-pixel precision, so a scale that moves the baseline makes the word\n * climb the pixel grid in whole-pixel jumps.\n */\nexport function KaraokeCaptions({\n  captions,\n  srt,\n  lines,\n  text = \"Acme reconciles every transaction automatically\",\n  emphasize = \"automatically\",\n  preset = \"boxed\",\n  theme,\n  mode = \"dark\",\n  accentColor = \"#FFE81F\",\n  fontSize,\n  fontWeight,\n  aspect = \"landscape\",\n  pill,\n  emphasisScale = 1.08,\n  baseColor,\n  fillColor,\n  strokeColor = \"#000000\",\n  strokeRatio,\n  uppercase,\n  speed = 1,\n  className,\n}: KaraokeCaptionsProps) {\n  const frame = useCurrentFrame() * speed;\n  const { durationInFrames, fps, width, height } = useVideoConfig();\n\n  const look = KARAOKE_LOOKS[preset] ?? KARAOKE_LOOKS.karaoke;\n  const t = useSnapCnTheme(theme, mode);\n  const palette = karaokePalette(t);\n  const safeArea = CAPTION_SAFE_AREAS[aspect] ?? CAPTION_SAFE_AREAS.landscape;\n\n  const shortSide = Math.min(width, height);\n  // 0 (or omitted) means \"the preset decides\" — the preset IS the design.\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 showPill = pill ?? look.pill;\n\n  // An outlined caption is white-on-black by definition; the muted grey of the old\n  // \"unspoken\" colour disappears the moment there is footage behind it. Unspoken\n  // words are DIMMED WHITE, which still reads.\n  const unfilled =\n    baseColor ?? (look.pill ? palette.base : \"rgba(255,255,255,0.45)\");\n  const filled = fillColor ?? (look.pill ? palette.fill : \"#FFFFFF\");\n\n  // Real captions first; the evenly-paced string is only a demo.\n  const resolvedLines = captions?.length\n    ? linesFromCaptions(captions, fps)\n    : srt && srt.trim().length > 0\n      ? linesFromCaptions(parseSrt(srt), fps)\n      : lines && lines.length > 0\n        ? lines\n        : linesFromText(\n            text,\n            emphasize,\n            10,\n            Math.max(11, durationInFrames - 24),\n          );\n\n  const lineIndex = activeLineIndex(resolvedLines, frame);\n\n  // The baseline inside a word span, measured once — the pivot for the emphasis\n  // scale. Guessing it from a line-height ratio is what makes an emphasised word\n  // judder.\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(\n          \"karaoke-captions: measuring the baseline for the emphasis pivot\",\n        )\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 lineHeight = Math.round(size * 1.2);\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: caps ? \"-0.005em\" : \"-0.015em\",\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          // Stroke first, fill over it — an OUTSIDE outline, not one eating the glyph.\n          paintOrder: \"stroke fill\" as const,\n        }\n      : {};\n\n  const shadow = look.shadow\n    ? { textShadow: `0 ${size * 0.05}px ${size * 0.045}px rgba(0,0,0,0.42)` }\n    : {};\n\n  const probe = (\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\n  if (lineIndex === -1) {\n    // The probe still has to mount, or delayRender never resolves and the render hangs.\n    return (\n      <div style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}>\n        {probe}\n      </div>\n    );\n  }\n\n  const line = resolvedLines[lineIndex];\n  const words = splitWords(line.text);\n  const windows = wordWindows(line);\n  const emphasized = new Set(line.emphasize ?? []);\n\n  const enter = interpolate(\n    frame - line.startFrame,\n    [0, LINE_FADE_FRAMES],\n    [0, 1],\n    { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n  );\n  const exit = interpolate(\n    frame - line.endFrame,\n    [0, LINE_FADE_FRAMES],\n    [1, 0],\n    { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\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: safeArea.bottom,\n        paddingLeft: safeArea.horizontal,\n        paddingRight: safeArea.horizontal,\n        pointerEvents: \"none\",\n      }}\n    >\n      {probe}\n      {look.box ? (\n        <div\n          style={{\n            maxWidth: Math.round(width * 0.82),\n            textAlign: \"center\",\n            opacity: Math.min(enter, exit),\n          }}\n        >\n          {/* White Roboto on a solid black box that clones per line — the\n              YouTube auto-caption look. Inline text so the box wraps each line. */}\n          <span\n            style={{\n              ...typeStyle,\n              fontFamily: ROBOTO,\n              display: \"inline\",\n              color: \"#FFFFFF\",\n              backgroundColor: \"rgba(0,0,0,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            {caps ? line.text.toUpperCase() : line.text}\n          </span>\n        </div>\n      ) : (\n        <div\n          style={{\n            display: \"flex\",\n            flexWrap: \"wrap\",\n            alignItems: \"flex-end\",\n            justifyContent: \"center\",\n            columnGap: size * 0.28,\n            rowGap: size * 0.06,\n            textAlign: \"center\",\n            opacity: Math.min(enter, exit),\n            ...(showPill\n              ? {\n                  padding: `${size * 0.3}px ${size * 0.55}px`,\n                  borderRadius: size * 0.24,\n                  background: palette.pillBg,\n                  border: `1px solid ${palette.pillBorder}`,\n                  boxShadow: palette.pillShadow,\n                }\n              : {}),\n          }}\n        >\n          {words.map((word, i) => {\n            const progress = fillProgress(frame, windows[i]);\n            const spoken = progress >= 0.999;\n            const isEmphasized = emphasized.has(i);\n            const scale = isEmphasized\n              ? interpolate(progress, [0, 1], [1, emphasisScale], {\n                  easing: Easing.out(Easing.cubic),\n                  extrapolateLeft: \"clamp\",\n                  extrapolateRight: \"clamp\",\n                })\n              : 1;\n\n            // The bar preset: the spoken word rides a rounded accent block.\n            const onBar =\n              look.bar && progress > 0 && !spoken ? false : look.bar && spoken;\n            const color = look.bar\n              ? spoken\n                ? \"#0B0B0C\"\n                : unfilled\n              : isEmphasized && spoken\n                ? accentColor\n                : spoken\n                  ? filled\n                  : unfilled;\n\n            return (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: words in a line repeat, so the index is what makes each key unique\n                key={`${word}-${i}`}\n                style={{\n                  ...typeStyle,\n                  ...outline,\n                  ...shadow,\n                  display: \"inline-block\",\n                  color,\n                  transform: `scale(${scale})`,\n                  // Pivot on the BASELINE. `50% 80%` is a guess, and a guess drags the\n                  // word down the pixel grid as it scales.\n                  transformOrigin:\n                    baselineY === null ? \"50% 82%\" : `50% ${baselineY}px`,\n                  willChange,\n                  ...(onBar\n                    ? {\n                        background: accentColor,\n                        borderRadius: size * 0.14,\n                        padding: `0 ${size * 0.11}px`,\n                        WebkitTextStrokeWidth: 0,\n                      }\n                    : {}),\n                }}\n              >\n                {caps ? word.toUpperCase() : word}\n              </span>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/karaoke-captions.tsx"
    }
  ],
  "type": "registry:component"
}