{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "script-cut",
  "title": "Script Cut",
  "description": "\"and every piece became a shot.\" with a tiny script line and cut ticks below.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/script-cut/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Epilogue\";\nimport type React from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  interpolateColors,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst epilogue = loadFont(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface ScriptCutProps {\n  shots?: string[];\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst THEMES = {\n  dark: { bg: \"#111418\", ink: \"#FFFFFF\", accent: \"#5CF2B5\" },\n  light: { bg: \"#F4F6F5\", ink: \"#111418\", accent: \"#12A06B\" },\n} as const;\n\nconst DEFAULT_SHOTS = [\n  \"We wrote the launch as one line,\",\n  \"cut it twice,\",\n  \"and every piece became a shot.\",\n];\n\nconst WGHT = 500;\nconst TRACK = -0.03;\nconst FONT_PX = 200;\nconst LH = 1.04;\nconst ROW_LH = 1.4;\nconst GAP_EM = 1.5;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_MOVE = Easing.bezier(0.65, 0, 0.35, 1);\nconst EASE_IN = Easing.bezier(0.55, 0, 1, 0.45);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst ROW_IN = 16;\nconst CUT_AT = 14;\nconst CUT_DUR = 14;\nconst CUT_STAGGER = 4;\nconst FIRST_SHOT = 36;\nconst FLY = 24;\nconst FLY_STAGGER = 2;\nconst EXIT = 10;\nconst HANDOFF = 8;\nconst HOLD = 32;\n\ntype Metrics = Record<string, number>;\n\nfunction measureAll(texts: string[]): Metrics {\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"script-cut: no 2d context\");\n  ctx.font = `${WGHT} ${FONT_PX}px \"${epilogue.fontFamily}\"`;\n  const w: Metrics = {};\n  for (const s of texts) {\n    w[s] = ctx.measureText(s).width / FONT_PX + TRACK * Array.from(s).length;\n  }\n  return w;\n}\n\nfunction useMetrics(texts: string[]) {\n  const key = texts.join(\"\\u0001\");\n  const [state, setState] = useState<{ key: string; m: Metrics } | null>(null);\n  const [handle] = useState(() => delayRender(\"script-cut: measuring\"));\n  const released = useRef(false);\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      epilogue.waitUntilDone(),\n      document.fonts.load(`${WGHT} 100px \"${epilogue.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setState({ key, m: measureAll(key.split(\"\\u0001\")) });\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, handle]);\n  return state && state.key === key ? state.m : null;\n}\n\nconst splitWords = (s: string) => s.split(\" \").filter(Boolean);\n\nfunction breakBalanced<T>(\n  items: T[],\n  L: number,\n  widthOf: (line: T[]) => number,\n): T[][] {\n  const n = items.length;\n  const lines = Math.max(1, Math.min(L, n));\n  if (lines === 1) return [items];\n  let best: T[][] = [items];\n  let bestW = Number.POSITIVE_INFINITY;\n  const walk = (from: number, left: number, acc: T[][]) => {\n    if (left === 1) {\n      const all = [...acc, items.slice(from)];\n      const w = Math.max(...all.map(widthOf));\n      if (w < bestW) {\n        bestW = w;\n        best = all;\n      }\n      return;\n    }\n    for (let cut = from + 1; cut <= n - left + 1; cut++) {\n      walk(cut, left - 1, [...acc, items.slice(from, cut)]);\n    }\n  };\n  walk(0, lines, []);\n  return best;\n}\n\ninterface Token {\n  word: string;\n  shot: number;\n  index: number;\n}\n\nexport function ScriptCut({\n  shots = DEFAULT_SHOTS,\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: ScriptCutProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H, durationInFrames } = useVideoConfig();\n  const c = THEMES[theme];\n  const hi = accent ?? c.accent;\n  const stage =\n    background === \"transparent\" ? \"transparent\" : (background ?? c.bg);\n\n  const list = shots.length > 0 ? shots : DEFAULT_SHOTS;\n  const shotWords = list.map(splitWords);\n  const m = useMetrics([\" \", ...shotWords.flat()]);\n\n  if (!m) {\n    return (\n      <div style={{ position: \"absolute\", inset: 0, background: stage }} />\n    );\n  }\n\n  const n = list.length;\n  const portrait = H > W * 1.1;\n  const marginX = W * (portrait ? 0.08 : 0.07);\n  const marginY = H * (portrait ? 0.08 : 0.1);\n  const measureW = W - marginX * 2;\n\n  const tokens: Token[] = [];\n  shotWords.forEach((ws, s) => {\n    for (const word of ws) tokens.push({ word, shot: s, index: tokens.length });\n  });\n\n  const rowLineW = (line: Token[], gap: number) =>\n    line.reduce((acc, tk, i) => {\n      if (i === 0) return acc + m[tk.word];\n      const sep = line[i - 1].shot !== tk.shot ? gap : m[\" \"];\n      return acc + sep + m[tk.word];\n    }, 0);\n\n  const rowMin = H * (portrait ? 0.018 : 0.03);\n  const rowCap = H * (portrait ? 0.024 : 0.042);\n  let rowF = 0;\n  let rowLines: Token[][] = [tokens];\n  for (const L of [1, 2, 3, 4]) {\n    const lines = breakBalanced(tokens, L, (l) => rowLineW(l, GAP_EM));\n    const widest = Math.max(0.1, ...lines.map((l) => rowLineW(l, GAP_EM)));\n    const size = Math.min(rowCap, measureW / widest);\n    rowF = size;\n    rowLines = lines;\n    if (size >= rowMin) break;\n  }\n  const rowBottom = H - marginY;\n  const rowTop = rowBottom - rowLines.length * ROW_LH * rowF;\n\n  const span = durationInFrames * speed;\n  const lastLen = (k: number) =>\n    FLY + FLY_STAGGER * Math.max(0, shotWords[k].length - 1);\n  const period =\n    n > 1\n      ? Math.min(\n          84,\n          Math.max(34, (span - FIRST_SHOT - lastLen(n - 1) - HOLD) / (n - 1)),\n        )\n      : 0;\n  const shotAt = (k: number) => FIRST_SHOT + k * period;\n\n  const cutP = (b: number) =>\n    interpolate(\n      t,\n      [CUT_AT + b * CUT_STAGGER, CUT_AT + b * CUT_STAGGER + CUT_DUR],\n      [0, 1],\n      { ...CLAMP, easing: EASE_MOVE },\n    );\n\n  interface RowPos {\n    x: number;\n    y: number;\n  }\n  const rowPos: RowPos[] = [];\n  const bars: { x: number; y: number; b: number }[] = [];\n  rowLines.forEach((line, j) => {\n    let x = marginX;\n    const y = rowTop + j * ROW_LH * rowF + (ROW_LH - 1) * 0.5 * rowF;\n    line.forEach((tk, i) => {\n      if (i > 0) {\n        const prev = line[i - 1];\n        if (prev.shot !== tk.shot) {\n          const p = cutP(prev.shot);\n          const gap = m[\" \"] + (GAP_EM - m[\" \"]) * p;\n          bars.push({ x: x + (gap * rowF) / 2, y, b: prev.shot });\n          x += gap * rowF;\n        } else {\n          x += m[\" \"] * rowF;\n        }\n      }\n      rowPos[tk.index] = { x, y };\n      x += m[tk.word] * rowF;\n    });\n    const lastTk = line[line.length - 1];\n    const nextTk = tokens[lastTk.index + 1];\n    if (nextTk && nextTk.shot !== lastTk.shot) {\n      bars.push({\n        x: x + (GAP_EM * rowF * cutP(lastTk.shot)) / 2,\n        y,\n        b: lastTk.shot,\n      });\n    }\n  });\n\n  const areaTop = marginY;\n  const areaBottom = rowTop - H * (portrait ? 0.06 : 0.08);\n  const areaH = Math.max(1, areaBottom - areaTop);\n  const heroLineW = (line: string[]) =>\n    line.reduce((acc, w, i) => acc + m[w] + (i > 0 ? m[\" \"] : 0), 0);\n\n  const heroLayouts = shotWords.map((ws) => {\n    let F = 0;\n    let lines: string[][] = [ws];\n    for (const L of [1, 2, 3, 4]) {\n      const cand = breakBalanced(ws, L, heroLineW);\n      const widest = Math.max(0.1, ...cand.map(heroLineW));\n      const size = Math.min(\n        H * (portrait ? 0.12 : 0.2),\n        (measureW * 0.98) / widest,\n        areaH / (cand.length * LH),\n      );\n      if (size > F * 1.1) {\n        F = size;\n        lines = cand;\n      }\n    }\n    const blockH = lines.length * LH * F;\n    const top = areaTop + (areaH - blockH) / 2;\n    const pos: { x: number; y: number }[] = [];\n    lines.forEach((line, j) => {\n      let x = marginX;\n      for (const w of line) {\n        pos.push({ x, y: top + j * LH * F });\n        x += (m[w] + m[\" \"]) * F;\n      }\n    });\n    return { F, pos };\n  });\n\n  const rowStyle: React.CSSProperties = {\n    position: \"absolute\",\n    height: rowF,\n    lineHeight: `${rowF}px`,\n    whiteSpace: \"pre\",\n    fontFamily: epilogue.fontFamily,\n    fontWeight: WGHT,\n    fontSize: rowF,\n    letterSpacing: `${TRACK}em`,\n  };\n\n  const nodes: React.ReactNode[] = [];\n\n  for (const bar of bars) {\n    const p = cutP(bar.b);\n    if (p <= 0) continue;\n    const h = rowF * 1.15;\n    nodes.push(\n      <div\n        key={`bar${bar.b}-${bar.y.toFixed(0)}`}\n        style={{\n          position: \"absolute\",\n          left: bar.x - rowF * 0.05,\n          top: bar.y - (h - rowF) / 2,\n          width: rowF * 0.1,\n          height: h,\n          overflow: \"hidden\",\n        }}\n      >\n        <div\n          style={{\n            width: \"100%\",\n            height: \"100%\",\n            background: hi,\n            borderRadius: rowF * 0.05,\n            transform: `translateY(${(-(1 - p) * h * 1.05).toFixed(2)}px)`,\n          }}\n        />\n      </div>,\n    );\n  }\n\n  for (const tk of tokens) {\n    const pos = rowPos[tk.index];\n    const inP = interpolate(\n      t,\n      [tk.index * 0.6, tk.index * 0.6 + ROW_IN],\n      [0, 1],\n      {\n        ...CLAMP,\n        easing: EASE_OUT,\n      },\n    );\n    const live = shotAt(tk.shot);\n    const lit = interpolate(t, [live - 4, live + 2], [0, 1], CLAMP);\n    const spent = interpolate(t, [live + 22, live + 40], [0, 1], CLAMP);\n    const color =\n      lit < 1\n        ? interpolateColors(lit, [0, 1], [c.ink, hi])\n        : interpolateColors(spent, [0, 1], [hi, c.ink]);\n    const opacity = inP * (0.88 - 0.56 * spent);\n    nodes.push(\n      <div\n        key={`r${tk.index}`}\n        style={{\n          ...rowStyle,\n          left: pos.x,\n          top: pos.y,\n          color,\n          opacity,\n          transform: `translateY(${((1 - inP) * rowF * 0.6).toFixed(2)}px)`,\n        }}\n      >\n        {tk.word}\n      </div>,\n    );\n  }\n\n  shotWords.forEach((ws, k) => {\n    const start = shotAt(k);\n    if (t < start) return;\n    const { F, pos } = heroLayouts[k];\n    const nextAt = k + 1 < n ? shotAt(k + 1) : Number.POSITIVE_INFINITY;\n    const first = tokens.findIndex((tk) => tk.shot === k);\n    ws.forEach((w, i) => {\n      const from = rowPos[first + i];\n      const to = pos[i];\n      const d = start + (k > 0 ? HANDOFF : 0) + i * FLY_STAGGER;\n      const p = interpolate(t, [d, d + FLY], [0, 1], {\n        ...CLAMP,\n        easing: EASE_MOVE,\n      });\n      const out = Number.isFinite(nextAt)\n        ? interpolate(t, [nextAt + i * 0.5, nextAt + i * 0.5 + EXIT], [0, 1], {\n            ...CLAMP,\n            easing: EASE_IN,\n          })\n        : 0;\n      if (out >= 1) return;\n      const s0 = rowF / F;\n      const s = s0 + (1 - s0) * p;\n      const x = from.x + (to.x - from.x) * p;\n      const y = from.y + (to.y - from.y) * p - out * F * 0.5;\n      const color = interpolateColors(p, [0, 0.7], [hi, c.ink]);\n      nodes.push(\n        <div\n          key={`h${k}-${i}`}\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            height: F,\n            lineHeight: `${F}px`,\n            whiteSpace: \"pre\",\n            fontFamily: epilogue.fontFamily,\n            fontWeight: WGHT,\n            fontSize: F,\n            letterSpacing: `${TRACK}em`,\n            color,\n            opacity: 1 - out,\n            transformOrigin: \"0 0\",\n            transform: `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${s.toFixed(4)})`,\n          }}\n        >\n          {w}\n        </div>,\n      );\n    });\n  });\n\n  return (\n    <AbsoluteFill style={{ background: stage, overflow: \"hidden\" }}>\n      {nodes}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/script-cut.tsx"
    }
  ],
  "type": "registry:component"
}
