{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ruby-press",
  "title": "Ruby Press",
  "description": "A word is pressed out and swapped from a list: 'Subtitles for every market in one render.'.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/ruby-press/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadZenKaku } from \"@remotion/google-fonts/ZenKakuGothicNew\";\nimport type React from \"react\";\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 kaku = loadZenKaku(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface RubyPressProps {\n  before?: string;\n  words?: string[];\n  after?: string;\n  hold?: number;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst VERMILION = \"#E03C31\";\n\nconst THEMES = {\n  dark: { bg: \"#111111\", ink: \"#F5F1EA\" },\n  light: { bg: \"#F5F1EA\", ink: \"#111111\" },\n} as const;\n\nconst TRACK = -0.02;\nconst RUBY = 0.4;\nconst RUBY_GAP = 0.1;\n\nconst RISE_AT = 0;\nconst RISE_LEN = 18;\nconst RISE_GAP = 3;\nconst FIRST_GLOSS = 28;\nconst GLOSS_IN = 12;\nconst GLOSS_WAIT = 12;\nconst PRESS = 22;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_PRESS = Easing.bezier(0.6, 0, 0.25, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ninterface Metrics {\n  tokens: number[];\n  slots: number[];\n  slotDesc: number[];\n  space: number;\n  cap: number;\n  base: number;\n}\n\nfunction measure(tokens: string[], slots: string[]): Metrics {\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"ruby-press: no 2d context\");\n  ctx.font = `500 100px \"${kaku.fontFamily}\"`;\n  const w = (s: string) =>\n    ctx.measureText(s).width / 100 + TRACK * Math.max(0, s.length - 1);\n  const h = ctx.measureText(\"H\");\n  const asc = h.fontBoundingBoxAscent / 100;\n  const desc = h.fontBoundingBoxDescent / 100;\n  return {\n    tokens: tokens.map(w),\n    slots: slots.map(w),\n    slotDesc: slots.map((s) =>\n      Math.max(0, ctx.measureText(s).actualBoundingBoxDescent / 100),\n    ),\n    space: ctx.measureText(\" \").width / 100,\n    cap: h.actualBoundingBoxAscent / 100,\n    base: (1 - (asc + desc)) / 2 + asc,\n  };\n}\n\nfunction useMetrics(tokens: string[], slots: string[]) {\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"ruby-press: measuring\"));\n  const released = useRef(false);\n  const key = JSON.stringify([tokens, slots]);\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      kaku.waitUntilDone(),\n      document.fonts.load(`500 100px \"${kaku.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        const [tk, sl] = JSON.parse(key) as [string[], string[]];\n        setMetrics(measure(tk, sl));\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, handle]);\n  return metrics;\n}\n\ninterface Item {\n  text: string;\n  w: number;\n  slot: boolean;\n  hug: boolean;\n}\n\nexport function RubyPress({\n  before = \"Subtitles for every market in\",\n  words = [\"a fortnight\", \"a morning\", \"one render\"],\n  after = \".\",\n  hold = 14,\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: RubyPressProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const bg = background ?? c.bg;\n  const red = accent ?? VERMILION;\n\n  const slots = words.length ? words : [\"\"];\n  const beforeTokens = useMemo(\n    () => before.split(/\\s+/).filter(Boolean),\n    [before],\n  );\n  const afterTokens = useMemo(\n    () => after.split(/\\s+/).filter(Boolean),\n    [after],\n  );\n  const tokens = useMemo(\n    () => [...beforeTokens, ...afterTokens],\n    [beforeTokens, afterTokens],\n  );\n  const m = useMetrics(tokens, slots);\n\n  if (!m) {\n    return <div style={{ position: \"absolute\", inset: 0, background: bg }} />;\n  }\n\n  const swaps = slots.length - 1;\n  const swapAt = (i: number) =>\n    FIRST_GLOSS + i * (GLOSS_IN + GLOSS_WAIT + PRESS + hold);\n  const pressAt = (i: number) => swapAt(i) + GLOSS_IN + GLOSS_WAIT;\n  const pressP = (i: number) =>\n    interpolate(t, [pressAt(i), pressAt(i) + PRESS], [0, 1], {\n      ...CLAMP,\n      easing: EASE_PRESS,\n    });\n  let landed = 0;\n  while (landed < swaps && pressP(landed) >= 1) landed++;\n  const active = landed < swaps;\n  const p = active ? pressP(landed) : 0;\n  const slotW = active\n    ? m.slots[landed] + (m.slots[landed + 1] - m.slots[landed]) * p\n    : m.slots[landed];\n\n  const items: Item[] = [\n    ...beforeTokens.map((s, i) => ({\n      text: s,\n      w: m.tokens[i],\n      slot: false,\n      hug: false,\n    })),\n    { text: \"\", w: 0, slot: true, hug: false },\n    ...afterTokens.map((s, i) => ({\n      text: s,\n      w: m.tokens[beforeTokens.length + i],\n      slot: false,\n      hug: i === 0 && /^[.,;:!?…’”)]/.test(s),\n    })),\n  ];\n\n  const portrait = H > W * 1.1;\n  const marginX = W * (portrait ? 0.08 : 0.07);\n  const heroX = portrait ? marginX : W * 0.38;\n  const heroW = W - marginX - heroX;\n  const leadX = marginX;\n  const leadW = portrait ? W * 0.62 : heroX - marginX - W * 0.05;\n  const maxSlot = Math.max(...m.slots);\n  const afterW = afterTokens.reduce(\n    (acc, _s, i) =>\n      acc +\n      m.tokens[beforeTokens.length + i] +\n      (i === 0 && /^[.,;:!?…’”)]/.test(afterTokens[0]) ? 0 : m.space),\n    0,\n  );\n\n  const F = Math.min(heroW / (maxSlot + afterW), H * (portrait ? 0.1 : 0.22));\n  const wrap = (f: number) => {\n    const lines: number[][] = [];\n    let x = 0;\n    beforeTokens.forEach((_s, i) => {\n      const w = m.tokens[i] * f;\n      if (lines.length === 0 || (x > 0 && x + m.space * f + w > leadW)) {\n        lines.push([i]);\n        x = w;\n      } else {\n        lines[lines.length - 1].push(i);\n        x += m.space * f + w;\n      }\n    });\n    return lines;\n  };\n  let f = Math.min(F * 0.34, H * (portrait ? 0.034 : 0.056));\n  let lead = wrap(f);\n  for (let k = 0; k < 24 && lead.length > 5; k++) {\n    f *= 0.93;\n    lead = wrap(f);\n  }\n\n  const r0 = RUBY * F;\n  const leadPitch = f * 1.3;\n  const heroBase = portrait ? H * 0.58 : H * 0.64;\n  const leadBase0 = portrait\n    ? H * 0.1 + m.cap * f\n    : heroBase - m.cap * F - (RUBY + RUBY_GAP) * F + m.cap * f;\n\n  const placed: { it: Item; x: number; base: number; sz: number; o: number }[] =\n    [];\n  lead.forEach((idx, li) => {\n    let x = leadX;\n    idx.forEach((i, k) => {\n      if (k > 0) x += m.space * f;\n      placed.push({\n        it: items[i],\n        x,\n        base: leadBase0 + li * leadPitch,\n        sz: f,\n        o: placed.length,\n      });\n      x += m.tokens[i] * f;\n    });\n  });\n  const heroItems = items.slice(beforeTokens.length);\n  const heroWs = heroItems.map((it) => (it.slot ? slotW : it.w) * F);\n  let hx = heroX;\n  heroItems.forEach((it, k) => {\n    if (k > 0 && !it.hug) hx += m.space * F;\n    placed.push({ it, x: hx, base: heroBase, sz: F, o: placed.length + 1 });\n    hx += heroWs[k];\n  });\n\n  const riseOf = (i: number) =>\n    interpolate(\n      t,\n      [RISE_AT + i * RISE_GAP, RISE_AT + i * RISE_GAP + RISE_LEN],\n      [0, 1],\n      { ...CLAMP, easing: EASE_OUT },\n    );\n\n  const textStyle = (size: number): React.CSSProperties => ({\n    position: \"absolute\",\n    fontFamily: kaku.fontFamily,\n    fontWeight: 500,\n    fontSize: size,\n    lineHeight: `${size}px`,\n    height: size * 1.4,\n    letterSpacing: `${TRACK}em`,\n    whiteSpace: \"pre\",\n  });\n\n  const nodes: React.ReactNode[] = [];\n  const overlay: React.ReactNode[] = [];\n  placed.forEach((pl, k) => {\n    const sz = pl.sz;\n    const rise = riseOf(pl.o);\n    const maskPad = sz * 0.34;\n    const lineTop = pl.base - m.cap * sz - maskPad;\n    const maskH = m.cap * sz + maskPad * 2;\n    const dy = (1 - rise) * maskH;\n    if (!pl.it.slot) {\n      nodes.push(\n        <div\n          key={`w${k}`}\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: lineTop,\n            width: W,\n            height: maskH,\n            overflow: \"hidden\",\n          }}\n        >\n          <div\n            style={{\n              ...textStyle(sz),\n              left: pl.x,\n              top: pl.base - lineTop - m.base * sz + dy,\n              color: c.ink,\n            }}\n          >\n            {pl.it.text}\n          </div>\n        </div>,\n      );\n      return;\n    }\n\n    const slotX = pl.x;\n    const oldIdx = landed;\n    const oldW = m.slots[oldIdx] * F;\n    const glossIdx = oldIdx + 1;\n    const hasGloss = active && t >= swapAt(landed);\n    let flat = 1;\n    if (hasGloss) {\n      const size = r0 + (F - r0) * p;\n      const topBase = pl.base - m.cap * F - RUBY_GAP * F;\n      const gBase = topBase + (pl.base - topBase) * p;\n      const gIn = interpolate(\n        t,\n        [swapAt(landed), swapAt(landed) + GLOSS_IN],\n        [0, 1],\n        { ...CLAMP, easing: EASE_OUT },\n      );\n      const gW = m.slots[glossIdx] * size;\n      const bottom = gBase + m.slotDesc[glossIdx] * size + F * 0.02;\n      flat = Math.max(0, Math.min(1, (pl.base - bottom) / (m.cap * F)));\n      overlay.push(\n        <div\n          key=\"gloss\"\n          style={{\n            ...textStyle(size),\n            left: slotX + (slotW * F - gW) / 2,\n            top: gBase - m.base * size + (1 - gIn) * r0 * 0.6,\n            opacity: gIn,\n            color: red,\n          }}\n        >\n          {slots[glossIdx]}\n        </div>,\n      );\n    }\n    if (flat > 0.001) {\n      nodes.push(\n        <div\n          key=\"slot\"\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: lineTop,\n            width: W,\n            height: maskH,\n            overflow: \"hidden\",\n          }}\n        >\n          <div\n            style={{\n              ...textStyle(F),\n              left: slotX + (slotW * F - oldW) / 2,\n              top: pl.base - lineTop - m.base * F + dy,\n              transform: `scaleY(${flat.toFixed(4)})`,\n              transformOrigin: `50% ${(m.base * F).toFixed(2)}px`,\n              opacity: interpolate(flat, [0, 0.12], [0, 1], CLAMP),\n              color: oldIdx === 0 ? c.ink : red,\n            }}\n          >\n            {slots[oldIdx]}\n          </div>\n        </div>,\n      );\n    }\n  });\n\n  return (\n    <AbsoluteFill style={{ background: bg, overflow: \"hidden\" }}>\n      {nodes}\n      {overlay}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/ruby-press.tsx"
    }
  ],
  "type": "registry:component"
}
