{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "outline-trace",
  "title": "Outline Trace",
  "description": "Every glyph contour draws itself as a stroke, letter by letter, then the fill wipes up inside each letter and the stroke fades into it.",
  "dependencies": ["remotion", "opentype.js"],
  "files": [
    {
      "path": "registry/remocn/outline-trace/index.tsx",
      "content": "\"use client\";\n\nimport { type Font, parse } from \"opentype.js\";\nimport { useEffect, useId, useState } from \"react\";\nimport {\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nexport interface OutlineTraceProps {\n  text?: string;\n  fontUrl?: string;\n  fontSize?: number;\n  color?: string;\n  strokeColor?: string;\n  strokeWidth?: number;\n  stagger?: number;\n  fill?: boolean;\n  keepStroke?: boolean;\n  speed?: number;\n  className?: string;\n}\n\n/** Static Inter ExtraBold (800) from Google Fonts. */\nexport const interExtraBoldTtf =\n  \"https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuDyYMZg.ttf\";\n\nexport const outlineTraceDefaults = {\n  text: \"Outline\",\n  fontUrl: interExtraBoldTtf,\n  fontSize: 150,\n  color: \"#fafafa\",\n  strokeColor: \"\",\n  strokeWidth: 2.5,\n  stagger: 3,\n  fill: true,\n  keepStroke: false,\n  speed: 1,\n} satisfies Required<Omit<OutlineTraceProps, \"className\">>;\n\n/** Per-letter beats on the 30 fps clock, relative to the letter's start. */\nexport const outlineTraceTiming = {\n  outer: [0, 18],\n  counter: [4, 18],\n  fill: [14, 26],\n} as const;\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst easeInOutCubic = Easing.inOut(Easing.cubic);\nconst easeOutCubic = Easing.out(Easing.cubic);\n\nfunction finite(value: number | undefined, fallback: number) {\n  return typeof value === \"number\" && Number.isFinite(value) ? value : fallback;\n}\n\n/** Maps a composition frame onto the 30 fps timeline, scaled by `speed`. */\nexport function getOutlineTraceTime(\n  frame: number,\n  options: { fps?: number; speed?: number } = {},\n) {\n  const fps = Math.max(1, finite(options.fps, 30));\n  const speed = Math.max(0, finite(options.speed, 1));\n  return (Math.max(0, finite(frame, 0)) * 30 * speed) / fps;\n}\n\nexport interface OutlineTraceLetterState {\n  /** Trim progress of the outer contours, 0..1. */\n  outer: number;\n  /** Trim progress of the counters, 0..1. */\n  counter: number;\n  /** Share of the glyph box the fill has risen through, 0..1. */\n  fill: number;\n}\n\nexport function getOutlineTraceLetter(\n  t: number,\n  index: number,\n  stagger = outlineTraceDefaults.stagger,\n): OutlineTraceLetterState {\n  const u = t - index * Math.max(0, finite(stagger, 3));\n  const { outer, counter, fill } = outlineTraceTiming;\n  return {\n    outer: interpolate(u, [...outer], [0, 1], {\n      ...CLAMP,\n      easing: easeInOutCubic,\n    }),\n    counter: interpolate(u, [...counter], [0, 1], {\n      ...CLAMP,\n      easing: easeInOutCubic,\n    }),\n    fill: interpolate(u, [...fill], [0, 1], { ...CLAMP, easing: easeOutCubic }),\n  };\n}\n\n/** Frame where the last of `count` letters has filled. */\nexport function getOutlineTraceLength(\n  count: number,\n  stagger = outlineTraceDefaults.stagger,\n) {\n  const last = Math.max(0, count - 1) * Math.max(0, finite(stagger, 3));\n  return Math.ceil(last + outlineTraceTiming.fill[1]);\n}\n\nexport interface PathCommand {\n  type: string;\n  x?: number;\n  y?: number;\n  x1?: number;\n  y1?: number;\n  x2?: number;\n  y2?: number;\n}\n\nexport interface Box {\n  x0: number;\n  y0: number;\n  x1: number;\n  y1: number;\n}\n\nexport interface OutlineContour {\n  d: string;\n  box: Box;\n  /** True for a hole inside another contour of the same glyph. */\n  counter: boolean;\n}\n\nconst fmt = (n: number) => Number(n.toFixed(2));\n\n/**\n * Splits glyph path commands into closed contours and marks the counters:\n * contours whose box lies inside another contour's box.\n */\nexport function getOutlineContours(commands: readonly PathCommand[]) {\n  const raw: { d: string; box: Box }[] = [];\n  let d = \"\";\n  let box: Box | null = null;\n  const grow = (x = 0, y = 0) => {\n    if (!box) box = { x0: x, y0: y, x1: x, y1: y };\n    else {\n      box.x0 = Math.min(box.x0, x);\n      box.y0 = Math.min(box.y0, y);\n      box.x1 = Math.max(box.x1, x);\n      box.y1 = Math.max(box.y1, y);\n    }\n  };\n  const close = () => {\n    if (d && box) raw.push({ d: `${d}Z`, box });\n    d = \"\";\n    box = null;\n  };\n  for (const c of commands) {\n    if (c.type === \"M\") {\n      close();\n      d = `M${fmt(c.x ?? 0)} ${fmt(c.y ?? 0)}`;\n      grow(c.x, c.y);\n    } else if (c.type === \"L\") {\n      d += `L${fmt(c.x ?? 0)} ${fmt(c.y ?? 0)}`;\n      grow(c.x, c.y);\n    } else if (c.type === \"Q\") {\n      d += `Q${fmt(c.x1 ?? 0)} ${fmt(c.y1 ?? 0)} ${fmt(c.x ?? 0)} ${fmt(c.y ?? 0)}`;\n      grow(c.x1, c.y1);\n      grow(c.x, c.y);\n    } else if (c.type === \"C\") {\n      d += `C${fmt(c.x1 ?? 0)} ${fmt(c.y1 ?? 0)} ${fmt(c.x2 ?? 0)} ${fmt(c.y2 ?? 0)} ${fmt(c.x ?? 0)} ${fmt(c.y ?? 0)}`;\n      grow(c.x1, c.y1);\n      grow(c.x2, c.y2);\n      grow(c.x, c.y);\n    } else if (c.type === \"Z\") {\n      close();\n    }\n  }\n  close();\n  const inside = (a: Box, b: Box) =>\n    a !== b && a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1;\n  return raw.map(\n    (c): OutlineContour => ({\n      ...c,\n      counter: raw.some((other) => inside(c.box, other.box)),\n    }),\n  );\n}\n\nexport interface OutlineGlyph {\n  contours: OutlineContour[];\n  box: Box | null;\n}\n\n/**\n * Lays out `text` in reference px with the baseline at y = 0. Glyphs are\n * looked up per character: full shaping runs GSUB lookups that opentype.js\n * does not support in every font.\n */\nexport function layoutOutlineGlyphs(\n  font: Font,\n  text: string,\n  fontSize: number,\n) {\n  const scale = fontSize / font.unitsPerEm;\n  const glyphs = Array.from(text).map((char) => font.charToGlyph(char));\n  let x = 0;\n  const letters: OutlineGlyph[] = glyphs.map((glyph, i) => {\n    if (i > 0) x += font.getKerningValue(glyphs[i - 1], glyph) * scale;\n    const contours = getOutlineContours(glyph.getPath(x, 0, fontSize).commands);\n    x += (glyph.advanceWidth ?? 0) * scale;\n    const box = contours.reduce<Box | null>(\n      (acc, c) =>\n        acc\n          ? {\n              x0: Math.min(acc.x0, c.box.x0),\n              y0: Math.min(acc.y0, c.box.y0),\n              x1: Math.max(acc.x1, c.box.x1),\n              y1: Math.max(acc.y1, c.box.y1),\n            }\n          : { ...c.box },\n      null,\n    );\n    return { contours, box };\n  });\n  const os2 = font.tables.os2 as { sCapHeight?: number } | undefined;\n  const capHeight =\n    ((os2?.sCapHeight ?? font.unitsPerEm * 0.7) * fontSize) / font.unitsPerEm;\n  return { letters, width: x, capHeight };\n}\n\nconst fontCache = new Map<string, Promise<Font>>();\n\nfunction loadOutlineFont(url: string) {\n  let pending = fontCache.get(url);\n  if (!pending) {\n    pending = fetch(url)\n      .then((res) => res.arrayBuffer())\n      .then((buffer) => parse(buffer));\n    fontCache.set(url, pending);\n    pending.catch(() => fontCache.delete(url));\n  }\n  return pending;\n}\n\nfunction useOutlineFont(url: string) {\n  const [font, setFont] = useState<Font | null>(null);\n\n  useEffect(() => {\n    const handle = delayRender(`outline-trace: loading ${url}`);\n    let released = false;\n    const release = () => {\n      if (released) return;\n      released = true;\n      continueRender(handle);\n    };\n    loadOutlineFont(url).then((parsed) => {\n      if (!released) setFont(parsed);\n      release();\n    }, release);\n    return release;\n  }, [url]);\n\n  return font;\n}\n\nexport function OutlineTrace({\n  text = outlineTraceDefaults.text,\n  fontUrl = outlineTraceDefaults.fontUrl,\n  fontSize = outlineTraceDefaults.fontSize,\n  color = outlineTraceDefaults.color,\n  strokeColor = outlineTraceDefaults.strokeColor,\n  strokeWidth = outlineTraceDefaults.strokeWidth,\n  stagger = outlineTraceDefaults.stagger,\n  fill = outlineTraceDefaults.fill,\n  keepStroke = outlineTraceDefaults.keepStroke,\n  speed = outlineTraceDefaults.speed,\n  className,\n}: OutlineTraceProps) {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const id = `outline-trace-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const font = useOutlineFont(fontUrl);\n  if (!font) return null;\n\n  const unit = height / 720;\n  const t = getOutlineTraceTime(frame, { fps, speed });\n  const layout = layoutOutlineGlyphs(font, text, fontSize);\n  const originX = (width - layout.width * unit) / 2;\n  const baseline = (height + layout.capHeight * unit) / 2;\n  const stroke = strokeColor || color;\n\n  return (\n    <div\n      className={className}\n      style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}\n    >\n      <svg\n        width=\"100%\"\n        height=\"100%\"\n        viewBox={`0 0 ${width} ${height}`}\n        style={{ display: \"block\" }}\n      >\n        <g transform={`translate(${originX} ${baseline}) scale(${unit})`}>\n          <filter id={`${id}-erode`}>\n            <feMorphology operator=\"erode\" radius={strokeWidth / 2} />\n          </filter>\n          {layout.letters.map((letter, i) => {\n            if (!letter.box) return null;\n            const state = getOutlineTraceLetter(t, i, stagger);\n            const box = letter.box;\n            const tall = box.y1 - box.y0 + 2 * strokeWidth;\n            const rise = tall * (fill ? state.fill : 0);\n            const strokeOpacity = fill && !keepStroke ? 1 - state.fill : 1;\n            const whole = letter.contours.map((c) => c.d).join(\"\");\n            const margin = 4 * strokeWidth;\n            return (\n              <g key={i}>\n                {/* Static instances of variable fonts keep overlapping\n                    contours (the bar of a t crosses its stem). The mask hides\n                    stroke that runs inside the glyph's filled silhouette. */}\n                <mask\n                  id={`${id}-mask-${i}`}\n                  maskUnits=\"userSpaceOnUse\"\n                  x={box.x0 - margin}\n                  y={box.y0 - margin}\n                  width={box.x1 - box.x0 + 2 * margin}\n                  height={box.y1 - box.y0 + 2 * margin}\n                >\n                  <rect\n                    x={box.x0 - margin}\n                    y={box.y0 - margin}\n                    width={box.x1 - box.x0 + 2 * margin}\n                    height={box.y1 - box.y0 + 2 * margin}\n                    fill=\"#ffffff\"\n                  />\n                  <path d={whole} fill=\"#000000\" filter={`url(#${id}-erode)`} />\n                </mask>\n                {fill && state.fill > 0 ? (\n                  <>\n                    <clipPath id={`${id}-${i}`}>\n                      <rect\n                        x={box.x0 - strokeWidth}\n                        y={box.y1 + strokeWidth - rise}\n                        width={box.x1 - box.x0 + 2 * strokeWidth}\n                        height={rise}\n                      />\n                    </clipPath>\n                    <path\n                      d={whole}\n                      fill={color}\n                      clipPath={`url(#${id}-${i})`}\n                    />\n                  </>\n                ) : null}\n                {strokeOpacity > 0 ? (\n                  <g mask={`url(#${id}-mask-${i})`}>\n                    {letter.contours.map((contour, k) => {\n                      const progress = contour.counter\n                        ? state.counter\n                        : state.outer;\n                      if (progress <= 0) return null;\n                      return (\n                        <path\n                          key={k}\n                          d={contour.d}\n                          pathLength={1}\n                          fill=\"none\"\n                          stroke={stroke}\n                          strokeWidth={strokeWidth}\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeDasharray=\"1 1\"\n                          strokeDashoffset={1 - progress}\n                          opacity={strokeOpacity}\n                        />\n                      );\n                    })}\n                  </g>\n                ) : null}\n              </g>\n            );\n          })}\n        </g>\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/outline-trace.tsx"
    }
  ],
  "type": "registry:component"
}
