{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loop-break",
  "title": "Loop Break",
  "description": "A loop of \"Render the cut. Spot a typo.\" breaks into \"Change one word. Done.\".",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/loop-break/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/DMSans\";\nimport type React from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst dmSans = loadFont(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface LoopBreakProps {\n  loop?: string[];\n  fix?: string;\n  highlight?: string;\n  passes?: number;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst THEMES = {\n  dark: { bg: \"#2E3440\", ink: \"#FFFFFF\", accent: \"#FFB020\" },\n  light: { bg: \"#F1F2F4\", ink: \"#2E3440\", accent: \"#B86E00\" },\n} as const;\n\nconst WGHT = 500;\nconst TRACK = -0.03;\nconst PROBE = 200;\nconst BASE = 215;\nconst LEAD = 1.14;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_IN = Easing.bezier(0.55, 0, 0.9, 0.4);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst START = 6;\nconst ENTER = 14;\nconst WORD_STAGGER = 3;\nconst ROW_GAP = 15;\nconst HOLD = 16;\nconst REWIND = 7;\nconst REWIND_WORD = 1.5;\nconst REWIND_ROW = 4;\nconst TEMPO = 0.6;\nconst MIN_TEMPO = 0.4;\nconst BREAK_PAUSE = 12;\nconst FIX_TEMPO = 1.25;\nconst DIM_AFTER = 6;\n\ninterface Word {\n  text: string;\n  start: number;\n  x: number;\n  w: number;\n}\n\ninterface Metrics {\n  rows: Word[][];\n  fix: Word[];\n  widest: number;\n}\n\ninterface Placed extends Word {\n  row: number;\n  px: number;\n}\n\nfunction layoutWords(s: string, em: (x: string) => number) {\n  const out: Word[] = [];\n  const re = /\\S+/g;\n  for (let m = re.exec(s); m; m = re.exec(s)) {\n    out.push({\n      text: m[0],\n      start: m.index,\n      x: em(s.slice(0, m.index)),\n      w: em(m[0]),\n    });\n  }\n  return out;\n}\n\nfunction measure(loop: string[], fix: string): Metrics {\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"loop-break: no 2d context\");\n  ctx.font = `${WGHT} ${PROBE}px \"${dmSans.fontFamily}\"`;\n  const em = (x: string) =>\n    ctx.measureText(x).width / PROBE + TRACK * Array.from(x).length;\n  return {\n    rows: loop.map((s) => layoutWords(s, em)),\n    fix: layoutWords(fix, em),\n    widest: Math.max(\n      ...loop.map(em),\n      ...(fix.match(/[^.!?…]+[.!?…]*/g) ?? []).map((x) => em(x.trim())),\n      0.1,\n    ),\n  };\n}\n\nfunction useMetrics(loop: string[], fix: string) {\n  const key = `${loop.join(\"\\u0002\")}\\u0001${fix}`;\n  const [state, setState] = useState<{ key: string; m: Metrics } | null>(null);\n  const [handle] = useState(() => delayRender(\"loop-break: measuring\"));\n  const released = useRef(false);\n  const src = useRef({ loop, fix });\n  src.current = { loop, fix };\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      dmSans.waitUntilDone(),\n      document.fonts.load(`${WGHT} 100px \"${dmSans.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setState({ key, m: measure(src.current.loop, src.current.fix) });\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\ninterface Take {\n  row: number;\n  fix: boolean;\n  at: number;\n  tempo: number;\n  back: number;\n  backTempo: number;\n}\n\nfunction wrapFix(words: Word[], measure: number) {\n  const out: Placed[] = [];\n  let row = 0;\n  let rowStart = 0;\n  words.forEach((w, i) => {\n    const prev = words[i - 1];\n    if (\n      i > 0 &&\n      (/[.!?…]$/.test(prev.text) || w.x + w.w - rowStart > measure + 0.01)\n    ) {\n      row += 1;\n      rowStart = w.x;\n    }\n    out.push({ ...w, row, px: w.x - rowStart });\n  });\n  return out;\n}\n\nfunction schedule(rowCount: number, words: number[], passes: number) {\n  const takes: Take[] = [];\n  let cursor = START;\n  let fixAt = 0;\n  for (let p = 0; p < passes; p++) {\n    const s = Math.max(MIN_TEMPO, TEMPO ** p);\n    const final = p === passes - 1;\n    const pass: Take[] = [];\n    for (let r = p === 0 ? 0 : 1; r < rowCount; r++) {\n      const fix = final && r === rowCount - 1;\n      if (fix) {\n        cursor += BREAK_PAUSE;\n        fixAt = cursor;\n      }\n      const take: Take = {\n        row: r,\n        fix,\n        at: cursor,\n        tempo: fix ? FIX_TEMPO : s,\n        back: Number.POSITIVE_INFINITY,\n        backTempo: s,\n      };\n      pass.push(take);\n      takes.push(take);\n      if (r < rowCount - 1) cursor += ROW_GAP * s;\n    }\n    const last = pass[pass.length - 1];\n    if (final) {\n      cursor =\n        last.at +\n        (ENTER + WORD_STAGGER * Math.max(0, words[rowCount] - 1)) * last.tempo;\n      break;\n    }\n    cursor =\n      last.at +\n      (ENTER + WORD_STAGGER * Math.max(0, words[last.row] - 1)) * s +\n      HOLD * s;\n    const back = [...pass].reverse().filter((tk) => tk.row > 0);\n    back.forEach((tk, i) => {\n      tk.back = cursor + i * REWIND_ROW * s;\n    });\n    const lastBack = back[back.length - 1];\n    cursor =\n      lastBack.back +\n      (REWIND + REWIND_WORD * Math.max(0, words[lastBack.row] - 1)) * s +\n      2;\n  }\n  return { takes, fixAt, end: cursor };\n}\n\nexport function LoopBreak({\n  loop = [\"Render the cut.\", \"Spot a typo.\", \"Start over.\"],\n  fix = \"Change one word. Done.\",\n  highlight = \"Done.\",\n  passes = 3,\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: LoopBreakProps) {\n  const frame = useCurrentFrame();\n  const { width: W, height: H, durationInFrames } = useVideoConfig();\n  const k = Math.min(1, durationInFrames / BASE);\n  const t = (frame * speed) / k;\n  const c = THEMES[theme];\n  const hi = accent ?? c.accent;\n  const stage =\n    background === \"transparent\" ? \"transparent\" : (background ?? c.bg);\n  const rows = loop.length >= 2 ? loop : [...loop, \"Start over.\"];\n  const m = useMetrics(rows, fix);\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 R = m.rows.length;\n  const measureW = W * (portrait ? 0.84 : 0.58);\n  const F = Math.min(measureW / m.widest, portrait ? W * 0.13 : H * 0.15);\n  const fixRows = wrapFix(m.fix, measureW / F);\n  const total = R - 1 + (fixRows[fixRows.length - 1]?.row ?? 0) + 1;\n  const pitch = F * LEAD;\n  const left = W * 0.08;\n  const blockTop = (H - total * pitch) / 2;\n\n  const words = [...m.rows.map((r) => r.length), m.fix.length];\n  const plan = schedule(R, words, Math.max(2, Math.round(passes)));\n  const settled = plan.end + DIM_AFTER;\n  const dim = interpolate(t, [settled, settled + 12], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const hiStart = highlight ? fix.lastIndexOf(highlight) : -1;\n  const hiEnd = hiStart >= 0 ? hiStart + highlight.length : -1;\n\n  const rowBox = (key: string, row: number, glyphs: React.ReactNode[]) => (\n    <div\n      key={key}\n      style={{\n        position: \"absolute\",\n        left: 0,\n        right: 0,\n        top: blockTop + row * pitch,\n        height: pitch,\n        overflow: \"hidden\",\n      }}\n    >\n      {glyphs}\n    </div>\n  );\n\n  const nodes: React.ReactNode[] = [];\n  for (let r = 0; r < R; r++) {\n    let take: Take | null = null;\n    for (const tk of plan.takes) {\n      if (tk.row === r && tk.at <= t) take = tk;\n    }\n    if (!take) continue;\n    const cur = take;\n    const list: Placed[] = cur.fix\n      ? fixRows\n      : m.rows[r].map((w) => ({ ...w, row: 0, px: w.x }));\n    const n = list.length;\n    const byRow = new Map<number, React.ReactNode[]>();\n    list.forEach((w, i) => {\n      const inAt = cur.at + i * WORD_STAGGER * cur.tempo;\n      const rise = interpolate(t, [inAt, inAt + ENTER * cur.tempo], [0, 1], {\n        ...CLAMP,\n        easing: EASE_OUT,\n      });\n      const outAt = cur.back + (n - 1 - i) * REWIND_WORD * cur.backTempo;\n      const sink = Number.isFinite(cur.back)\n        ? interpolate(t, [outAt, outAt + REWIND * cur.backTempo], [0, 1], {\n            ...CLAMP,\n            easing: EASE_IN,\n          })\n        : 0;\n      const shown = rise - sink;\n      if (shown <= 0) return;\n      const lit =\n        cur.fix &&\n        hiStart >= 0 &&\n        w.start < hiEnd &&\n        w.start + w.text.length > hiStart;\n      const fade = cur.fix ? 0 : dim * 0.55;\n      const color = lit\n        ? `color-mix(in oklab, ${hi} ${(rise * 100).toFixed(1)}%, ${c.ink})`\n        : c.ink;\n      const bucket = byRow.get(w.row) ?? [];\n      bucket.push(\n        <div\n          key={`${cur.fix ? \"f\" : \"l\"}${i}`}\n          style={{\n            position: \"absolute\",\n            left: left + w.px * F,\n            top: 0,\n            height: pitch,\n            lineHeight: `${pitch}px`,\n            whiteSpace: \"pre\",\n            fontFamily: dmSans.fontFamily,\n            fontWeight: WGHT,\n            fontSize: F,\n            letterSpacing: `${TRACK}em`,\n            fontKerning: \"normal\",\n            color,\n            opacity: 1 - fade,\n            transform: `translateY(${((1 - shown) * pitch).toFixed(2)}px)`,\n          }}\n        >\n          {w.text}\n        </div>,\n      );\n      byRow.set(w.row, bucket);\n    });\n    for (const [row, glyphs] of byRow) {\n      nodes.push(rowBox(`r${r}-${row}`, r + row, glyphs));\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/loop-break.tsx"
    }
  ],
  "type": "registry:component"
}
