{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "edit-ripple",
  "title": "Edit Ripple",
  "description": "\"Change your mind, render it again.\" with the second line indented and ripple-coloured.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/edit-ripple/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 EditRipplePair {\n  cause: string;\n  effect: string;\n}\n\nexport interface EditRippleProps {\n  pairs?: EditRipplePair[];\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_PAIRS: EditRipplePair[] = [\n  { cause: \"Change one word,\", effect: \"every scene follows.\" },\n  { cause: \"Swap the color,\", effect: \"every shot repaints.\" },\n  { cause: \"Change your mind,\", effect: \"render it again.\" },\n];\n\nconst WGHT = 500;\nconst TRACK = -0.03;\nconst FONT_PX = 200;\nconst LH = 1.08;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 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 ENTER = 18;\nconst EXIT = 10;\nconst HANDOFF = 6;\nconst CAUSE_PER_EM = 0.5;\nconst LAG = 14;\nconst EFFECT_PER_EM = 1.1;\nconst COOL = 18;\nconst PAIR_MOTION = 56;\nconst TAIL = 34;\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(\"edit-ripple: 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(\"edit-ripple: 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\ninterface Placed {\n  word: string;\n  x: number;\n  row: number;\n  top: number;\n}\n\nfunction place(words: string[], m: Metrics, F: number, x0: number): Placed[] {\n  const out: Placed[] = [];\n  let x = x0;\n  for (const word of words) {\n    out.push({ word, x, row: 0, top: 0 });\n    x += (m[word] + m[\" \"]) * F;\n  }\n  return out;\n}\n\nfunction lineW(words: string[], m: Metrics) {\n  return words.reduce((acc, w, i) => acc + m[w] + (i > 0 ? m[\" \"] : 0), 0);\n}\n\nfunction breakLines(words: string[], m: Metrics, L: number): string[][] {\n  const n = words.length;\n  const lines = Math.max(1, Math.min(L, n));\n  if (lines === 1) return [words];\n  let best: string[][] = [words];\n  let bestW = Number.POSITIVE_INFINITY;\n  const walk = (from: number, left: number, acc: string[][]) => {\n    if (left === 1) {\n      const all = [...acc, words.slice(from)];\n      const w = Math.max(...all.map((l) => lineW(l, m)));\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, words.slice(from, cut)]);\n    }\n  };\n  walk(0, lines, []);\n  return best;\n}\n\nexport function EditRipple({\n  pairs = DEFAULT_PAIRS,\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: EditRippleProps) {\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 = pairs.length > 0 ? pairs : DEFAULT_PAIRS;\n  const causes = list.map((p) => splitWords(p.cause));\n  const effects = list.map((p) => splitWords(p.effect));\n  const m = useMetrics([\n    \" \",\n    ...list.map((p) => p.cause),\n    ...list.map((p) => p.effect),\n    ...causes.flat(),\n    ...effects.flat(),\n  ]);\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 span = durationInFrames * speed;\n  const period =\n    n > 1\n      ? Math.min(\n          96,\n          Math.max(PAIR_MOTION + 12, (span - PAIR_MOTION - TAIL) / (n - 1)),\n        )\n      : 0;\n  const startOf = (k: number) => k * period;\n\n  const portrait = H > W * 1.1;\n  const marginX = W * (portrait ? 0.08 : 0.07);\n  const measureW = W - marginX * 2;\n  let F = 0;\n  let causeLines: string[][][] = [];\n  let effectLines: string[][][] = [];\n  for (const L of portrait ? [1, 2, 3] : [1, 2]) {\n    const cl = causes.map((w) => breakLines(w, m, L));\n    const el = effects.map((w) => breakLines(w, m, L));\n    const widest = Math.max(\n      0.1,\n      ...[...cl, ...el].flat().map((l) => lineW(l, m)),\n    );\n    const rows =\n      Math.max(...cl.map((l) => l.length)) +\n      Math.max(...el.map((l) => l.length));\n    const size = Math.min(\n      H * (portrait ? 0.13 : 0.185),\n      (measureW * 0.94) / widest,\n      (H * 0.74) / (rows * LH + 0.5),\n    );\n    if (size > F * 1.12) {\n      F = size;\n      causeLines = cl;\n      effectLines = el;\n    }\n  }\n  const causeTop = (j: number, count: number) =>\n    H * 0.5 - F * 1.25 - (count - 1 - j) * LH * F;\n  const effectTop = (j: number) => H * 0.5 + F * 0.25 + j * LH * F;\n\n  const lineStyle: React.CSSProperties = {\n    position: \"relative\",\n    top: F * 0.1,\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  };\n\n  const nodes: React.ReactNode[] = [];\n\n  const word = (\n    key: string,\n    text: string,\n    x: number,\n    top: number,\n    y: number,\n    color: string,\n  ) => {\n    nodes.push(\n      <div\n        key={key}\n        style={{\n          position: \"absolute\",\n          left: x - F * 0.05,\n          top: top - F * 0.1,\n          height: F * 1.3,\n          paddingLeft: F * 0.05,\n          paddingRight: F * 0.1,\n          overflow: \"hidden\",\n        }}\n      >\n        <div\n          style={{\n            ...lineStyle,\n            color,\n            transform: `translateY(${y.toFixed(2)}px)`,\n          }}\n        >\n          {text}\n        </div>\n      </div>,\n    );\n  };\n\n  const enter = (d: number) =>\n    interpolate(t, [d, d + ENTER], [0, 1], { ...CLAMP, easing: EASE_OUT });\n  const exit = (d: number) =>\n    interpolate(t, [d, d + EXIT], [0, 1], { ...CLAMP, easing: EASE_IN });\n  const cool = (land: number, keep: boolean) =>\n    keep\n      ? hi\n      : interpolateColors(\n          Math.min(Math.max(t, land), land + COOL),\n          [land, land + COOL],\n          [hi, c.ink],\n        );\n\n  const drawLine = (\n    k: number,\n    words: Placed[],\n    lag: number,\n    perEm: number,\n    keep: boolean,\n    prefix: string,\n  ) => {\n    const first = k === 0;\n    const hasNext = k + 1 < n;\n    for (let i = 0; i < words.length; i++) {\n      const p = words[i];\n      const along = ((p.x - marginX) / F + p.row * 2) * perEm;\n      const dIn = startOf(k) + lag + along + (first ? 0 : HANDOFF);\n      if (t < dIn - 1) continue;\n      const pin = enter(dIn);\n      const pout = hasNext ? exit(startOf(k + 1) + lag + along) : 0;\n      if (pout >= 1) continue;\n      word(\n        `${prefix}${k}-${i}`,\n        p.word,\n        p.x,\n        p.top,\n        (1 - pin - pout) * F * 1.4,\n        cool(dIn + ENTER * 0.6, keep),\n      );\n    }\n  };\n\n  const layout = (\n    lines: string[][],\n    xOf: (line: string[]) => number,\n    topOf: (j: number) => number,\n  ) =>\n    lines.flatMap((line, j) =>\n      place(line, m, F, xOf(line)).map((p) => ({\n        ...p,\n        row: j,\n        top: topOf(j),\n      })),\n    );\n\n  for (let k = 0; k < n; k++) {\n    if (t < startOf(k) - 1) continue;\n    const cl = causeLines[k];\n    drawLine(\n      k,\n      layout(\n        cl,\n        () => marginX,\n        (j) => causeTop(j, cl.length),\n      ),\n      0,\n      CAUSE_PER_EM,\n      false,\n      \"c\",\n    );\n    drawLine(\n      k,\n      layout(\n        effectLines[k],\n        (line) => W - marginX - lineW(line, m) * F,\n        effectTop,\n      ),\n      LAG,\n      EFFECT_PER_EM,\n      k === n - 1,\n      \"e\",\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/edit-ripple.tsx"
    }
  ],
  "type": "registry:component"
}
