{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "churn-count",
  "title": "Churn Count",
  "description": "A huge frame counter reaches 72,960 under a small blue caption on black.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/churn-count/index.tsx",
      "content": "\"use client\";\n\nimport { loadVariableFont } from \"@remotion/google-fonts/Urbanist\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst urbanist = loadVariableFont(\"normal\", { subsets: [\"latin\"] });\n\nexport interface ChurnCountProps {\n  value?: number;\n  from?: number;\n  label?: string;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst THEMES = {\n  dark: { bg: \"#0B0B0B\", ink: \"#FFFFFF\", blue: \"#2F6BFF\" },\n  light: { bg: \"#FFFFFF\", ink: \"#0A0A0A\", blue: \"#1F4FE0\" },\n} as const;\n\nconst W_THIN = 100;\nconst W_FULL = 600;\nconst RECOVER = 13;\nconst COUNT_AT = 8;\nconst COUNT_END = 84;\nconst LABEL_AT = 3;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_COUNT = Easing.bezier(0.3, 0.1, 0.05, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ninterface Metrics {\n  slot: number;\n  comma: number;\n  baseline: number;\n  cap: number;\n}\n\nfunction measure(): Metrics {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = 600;\n  canvas.height = 600;\n  const ctx = canvas.getContext(\"2d\");\n  let slot = 0.62;\n  let comma = 0.26;\n  let baseline = 0.8;\n  let cap = 0.7;\n  if (ctx) {\n    let maxW = 0;\n    for (const w of [W_THIN, 300, W_FULL]) {\n      ctx.font = `${w} 200px \"${urbanist.fontFamily}\"`;\n      for (const d of \"0123456789\") {\n        maxW = Math.max(maxW, ctx.measureText(d).width / 200);\n      }\n    }\n    slot = maxW;\n    ctx.font = `${W_FULL} 200px \"${urbanist.fontFamily}\"`;\n    comma = ctx.measureText(\",\").width / 200;\n    const fm = ctx.measureText(\"0\");\n    const asc = fm.fontBoundingBoxAscent / 200;\n    const desc = fm.fontBoundingBoxDescent / 200;\n    baseline = (1 - (asc + desc)) / 2 + asc;\n    cap = fm.actualBoundingBoxAscent / 200;\n  }\n  return { slot, comma, baseline, cap };\n}\n\nfunction useMetrics() {\n  const [m, setM] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"churn-count: measuring\"));\n  const released = useRef(false);\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      urbanist.waitUntilDone(),\n      document.fonts.load(`${W_THIN} 100px \"${urbanist.fontFamily}\"`),\n      document.fonts.load(`${W_FULL} 100px \"${urbanist.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setM(measure());\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [handle]);\n  return m;\n}\n\nfunction mix(a: string, b: string, k: number) {\n  const p = (h: string, i: number) => Number.parseInt(h.slice(i, i + 2), 16);\n  const c = [1, 3, 5].map((i) =>\n    Math.round(p(a, i) + (p(b, i) - p(a, i)) * k)\n      .toString(16)\n      .padStart(2, \"0\"),\n  );\n  return `#${c.join(\"\")}`;\n}\n\nfunction normalizeHex(c: string) {\n  if (/^#[0-9a-fA-F]{6}$/.test(c)) return c;\n  if (/^#[0-9a-fA-F]{3}$/.test(c)) {\n    return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;\n  }\n  return null;\n}\n\nexport function ChurnCount({\n  value = 72960,\n  from = 0,\n  label = \"frames played back live, straight from your props\",\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: ChurnCountProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const blue = normalizeHex(accent ?? \"\") ?? c.blue;\n  const m = useMetrics();\n\n  const transparent = background === \"transparent\";\n  const stage = transparent ? \"transparent\" : (background ?? c.bg);\n\n  const target = Math.max(0, Math.round(value));\n  const start = Math.max(0, Math.min(target, Math.round(from)));\n\n  const countAt = useMemo(\n    () => (f: number) => {\n      const g = interpolate(f, [COUNT_AT, COUNT_END], [0, 1], {\n        ...CLAMP,\n        easing: EASE_COUNT,\n      });\n      return Math.round(start + (target - start) * g);\n    },\n    [start, target],\n  );\n\n  if (!m) {\n    return (\n      <div style={{ position: \"absolute\", inset: 0, background: stage }} />\n    );\n  }\n\n  const portrait = H > W * 1.1;\n  const s = portrait ? W / 1080 : Math.min(W / 1280, H / 720);\n\n  const finalText = target.toLocaleString(\"en-US\");\n  const finalDigits = finalText.replace(/,/g, \"\").length;\n  const finalCommas = finalText.length - finalDigits;\n  const unitW = finalDigits * m.slot + finalCommas * m.comma;\n\n  const marginR = W * (portrait ? 0.07 : 0.055);\n  const marginB = H * (portrait ? 0.08 : 0.07);\n  const maxW = W * (portrait ? 0.86 : 0.72);\n  const maxInk = H * (portrait ? 0.24 : 0.52);\n  const F = Math.min(maxW / unitW, maxInk / m.cap);\n  const baseY = H - marginB;\n  const right = W - marginR;\n  const figLeft = right - unitW * F;\n\n  const current = countAt(t);\n  const text = current.toLocaleString(\"en-US\");\n  const chars = Array.from(text);\n\n  const placeOf: number[] = [];\n  let p = 0;\n  for (let i = chars.length - 1; i >= 0; i--) {\n    if (chars[i] === \",\") placeOf[i] = -1;\n    else placeOf[i] = p++;\n  }\n\n  const recency = (place: number) => {\n    const pow = 10 ** place;\n    const f0 = Math.floor(t);\n    const digitAt = (f: number) => Math.floor(countAt(f) / pow) % 10;\n    const now = digitAt(f0);\n    for (let k = 0; k <= RECOVER; k++) {\n      const f = f0 - k;\n      if (f < COUNT_AT) return 1;\n      if (digitAt(f - 1) !== now) {\n        return Math.min(1, (t - f) / RECOVER);\n      }\n    }\n    return 1;\n  };\n\n  const nodes: React.ReactNode[] = [];\n  let x = right;\n  for (let i = chars.length - 1; i >= 0; i--) {\n    const ch = chars[i];\n    const isComma = ch === \",\";\n    const w = (isComma ? m.comma : m.slot) * F;\n    x -= w;\n    let weight = W_FULL;\n    let color: string = c.ink;\n    if (!isComma) {\n      const r = EASE_OUT(recency(placeOf[i]));\n      weight = W_THIN + (W_FULL - W_THIN) * r;\n      color = mix(blue, c.ink, r);\n    } else {\n      const nextPlace = placeOf[i + 1] ?? 0;\n      const r = EASE_OUT(recency(nextPlace + 1));\n      weight = W_THIN + (W_FULL - W_THIN) * r;\n      color = mix(blue, c.ink, r);\n    }\n    nodes.push(\n      <span\n        key={`${chars.length - i}`}\n        style={{\n          position: \"absolute\",\n          left: x,\n          top: baseY - m.baseline * F,\n          width: w,\n          height: F,\n          lineHeight: `${F}px`,\n          textAlign: \"center\",\n          fontSize: F,\n          fontVariationSettings: `\"wght\" ${weight.toFixed(1)}`,\n          color,\n        }}\n      >\n        {ch}\n      </span>,\n    );\n  }\n\n  const enter = interpolate(t, [0, 10], [0, 1], { ...CLAMP, easing: EASE_OUT });\n  const labelP = interpolate(t, [LABEL_AT, LABEL_AT + 18], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const labelFs = (portrait ? 48 : 40) * s;\n  const labelLeft = portrait ? W * 0.07 : figLeft + F * 0.04;\n  const labelW = portrait ? W * 0.86 : Math.min(W * 0.4, unitW * F * 0.62);\n  const labelBottom = H - (baseY - m.cap * F) + labelFs * 1.1;\n\n  return (\n    <AbsoluteFill\n      style={{\n        background: stage,\n        overflow: \"hidden\",\n        fontFamily: urbanist.fontFamily,\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: enter,\n          fontVariantNumeric: \"lining-nums\",\n          letterSpacing: 0,\n          whiteSpace: \"pre\",\n        }}\n      >\n        {nodes}\n      </div>\n      {label ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: labelLeft,\n            bottom: labelBottom,\n            width: labelW,\n            fontSize: labelFs,\n            fontWeight: 400,\n            lineHeight: 1.18,\n            letterSpacing: \"-0.01em\",\n            textWrap: \"balance\",\n            color: blue,\n            opacity: labelP,\n            transform: `translateY(${((1 - labelP) * labelFs * 0.5).toFixed(2)}px)`,\n          }}\n        >\n          {label}\n        </div>\n      ) : null}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/churn-count.tsx"
    }
  ],
  "type": "registry:component"
}
